Modul-System (Registry Backend+Frontend, generierte Tabs, Settings-Sektion); ToDo-Modul: persistente Liste mit Anlegen/Bearbeiten im Panel (todos_add/todos_update, Formular mit Abbrechen); alte Hook-Todoliste (OFFENE-PUNKTE.md) komplett entfernt

This commit is contained in:
marcus hinz
2026-07-23 11:35:29 +02:00
parent 513e1a768f
commit 1523856ea7
40 changed files with 1832 additions and 600 deletions
+7 -6
View File
@@ -272,8 +272,6 @@ fn invoke_handlers() -> impl Fn(tauri::ipc::Invoke<tauri::Wry>) -> bool + Send +
commands::set_terminal_config,
commands::project_icon,
commands::pool_label,
commands::todo_state,
commands::set_todo,
commands::usage_stats,
commands::stop_project,
commands::restart_project,
@@ -296,19 +294,22 @@ fn invoke_handlers() -> impl Fn(tauri::ipc::Invoke<tauri::Wry>) -> bool + Send +
commands::archive_docs_cmd,
commands::reveal_path_cmd,
commands::spellcheck_lang,
commands::enabled_modules,
commands::module_registry,
commands::set_module,
terminal::open_terminal,
terminal::term_start,
terminal::term_log,
terminal::term_write,
terminal::term_resize,
terminal::panel_read,
terminal::commands_read,
terminal::buffer_read,
terminal::commands_delete,
terminal::todos_delete,
terminal::todos_add,
terminal::todos_update,
terminal::panel_set,
terminal::search_read,
terminal::search_run,
terminal::panel_load,
terminal::wiki_read,
terminal::wiki_open,
terminal::open_panel_window
]
+28 -13
View File
@@ -21,7 +21,6 @@ use crate::domain::project::{
DeletePreview, Project, TerminalConfig,
};
use crate::domain::settings;
use crate::domain::todo::{set_todo_in, todo_state_in};
use crate::domain::usage::{usage_stats_in, UsageRow};
use crate::platform::KeychainStore;
use crate::terminal;
@@ -41,7 +40,6 @@ pub(crate) fn create_project_full(
work_dir: Option<String>,
create_work_dir: bool,
terminal: TerminalConfig,
todo: bool,
) -> Result<String, String> {
create_project_full_in(
&Paths::real(),
@@ -51,7 +49,6 @@ pub(crate) fn create_project_full(
work_dir.as_deref(),
create_work_dir,
terminal,
todo,
)
}
@@ -111,16 +108,6 @@ pub(crate) fn project_work_dirs(project: String) -> Result<Vec<String>, String>
project_work_dirs_in(&Paths::real(), &project)
}
#[tauri::command]
pub(crate) fn todo_state(project: String) -> Result<bool, String> {
todo_state_in(&Paths::real(), &project)
}
#[tauri::command]
pub(crate) fn set_todo(project: String, enabled: bool) -> Result<(), String> {
set_todo_in(&Paths::real(), &project, enabled)
}
#[tauri::command]
pub(crate) fn set_terminal_config(
project: String,
@@ -384,6 +371,34 @@ pub(crate) fn panel_archive_dir_cmd(project: String) -> Option<String> {
project_archive_home(&project).map(|p| p.display().to_string())
}
/// Modul-Registry für den Settings-Dialog (Checkbox-Gruppe „Module").
#[tauri::command]
pub(crate) fn module_registry(project: String) -> Result<Vec<crate::domain::modules::ModuleInfo>, String> {
crate::domain::modules::module_infos_in(&Paths::real(), &project)
}
/// Schaltet ein Modul im Projekt an/ab (Settings-Dialog, schreibt sofort).
#[tauri::command]
pub(crate) fn set_module(project: String, module: String, enabled: bool) -> Result<(), String> {
crate::domain::modules::set_module_in(&Paths::real(), &project, &module, enabled)
}
/// Aktive Module fürs Frontend: Registry-Enablement der Projekt-Config plus
/// `requires_archive` — ohne konfiguriertes Archiv-Home fallen Archiv-Module
/// weg, ihre Tabs erscheinen nicht.
#[tauri::command]
pub(crate) fn enabled_modules(project: String) -> Result<Vec<String>, String> {
let paths = Paths::real();
let has_archive = read_project_config_in(&paths, &project)?.archive_home.is_some();
Ok(
crate::domain::modules::active_in(&paths, &project)?
.iter()
.filter(|m| !m.requires_archive || has_archive)
.map(|m| m.id.to_string())
.collect(),
)
}
/// Setzt das Archiv-Home eines Projekts (Einstellungsdialog) — inklusive
/// Permissions-Eintrag in der Projekt-settings.json.
#[tauri::command]
+1 -1
View File
@@ -6,12 +6,12 @@ pub(crate) mod archive;
pub(crate) mod archive_index;
pub(crate) mod archive_search;
pub(crate) mod credentials;
pub(crate) mod modules;
pub(crate) mod paths;
pub(crate) mod pool;
pub(crate) mod project;
pub(crate) mod registry;
pub(crate) mod settings;
pub(crate) mod todo;
pub(crate) mod usage;
pub(crate) mod watcher;
+294
View File
@@ -0,0 +1,294 @@
//! Modul-Registry: benennt die Funktionsblöcke der App (Entwurf, Befehle,
//! Archiv, …) mit ihren Beiträgen — MCP-Tools und Puffer-Kanäle. Die Tabelle
//! ist die eine Quelle für „was ist in diesem Projekt an?": Das Frontend baut
//! seine Tabs daraus (`enabled_modules`), der MCP-Server seine Tool-Liste
//! (tools/list) und den Guard vor dem Dispatch (tools/call).
//!
//! Die Abwahl eines Moduls ist Konfiguration, keine Sicherheitsgrenze —
//! Commands bleiben registriert, die Capability-Manifeste unverändert;
//! dieselbe Linie wie bei den Pools (Konfiguration trennen, nicht Zugriff).
use crate::domain::paths::Paths;
/// Ein Puffer-Kanal eines Moduls: MCP-Server oder Command schreibt die Datei,
/// der Watcher im Terminal-Prozess meldet neuen Inhalt als Event an die
/// Panel-Fenster.
pub(crate) struct BufferDesc {
/// Schlüssel für `buffer_read` (Erstbefüllung der Ansicht).
pub(crate) id: &'static str,
/// Env-Variable, unter der die PTY den Dateipfad an claudes MCP-Kinder gibt.
pub(crate) env: &'static str,
/// Event an die Panel-Fenster bei Dateiänderung.
pub(crate) event: &'static str,
/// Pufferdatei des Projekts.
pub(crate) file: fn(&str) -> std::path::PathBuf,
/// Dateiname-Suffix (`<projekt>.<suffix>`) — muss zu `file` passen
/// (Test buffer_datei_passt_zum_suffix); Grundlage der Löschvorschau.
pub(crate) suffix: &'static str,
/// Persistente Puffer überleben Sessions — der Session-Start legt sie nur
/// an, statt sie zu leeren.
pub(crate) persistent: bool,
}
pub(crate) struct ModuleDesc {
pub(crate) id: &'static str,
/// Kern-Module sind nicht abschaltbar; ein Config-Eintrag wird ignoriert.
pub(crate) core: bool,
/// Aktiv ohne Eintrag in der Projekt-Config.
pub(crate) default_enabled: bool,
/// Modul setzt ein konfiguriertes Archiv-Home voraus — ohne Home fallen
/// seine Tabs im Frontend weg. Seine MCP-Tools bleiben absichtlich
/// gelistet: Das Home kann mitten in der Session gesetzt werden, und die
/// Tools melden das fehlende Home selbst verständlich zurück.
pub(crate) requires_archive: bool,
pub(crate) mcp_tools: &'static [&'static str],
pub(crate) buffers: &'static [BufferDesc],
}
pub(crate) const MODULES: &[ModuleDesc] = &[
ModuleDesc {
id: "draft",
core: true,
default_enabled: true,
requires_archive: false,
mcp_tools: &["write_panel"],
buffers: &[BufferDesc {
id: "panel",
env: "AI_CONTROL_PANEL",
event: "panel-update",
file: crate::domain::paths::panel_file,
suffix: "md",
persistent: false,
}],
},
ModuleDesc {
id: "commands",
core: false,
default_enabled: true,
requires_archive: false,
mcp_tools: &["write_commands", "show_commands"],
buffers: &[BufferDesc {
id: "commands",
env: "AI_CONTROL_COMMANDS",
event: "commands-update",
file: crate::domain::paths::commands_file,
suffix: "commands.jsonl",
persistent: false,
}],
},
ModuleDesc {
id: "todo",
core: false,
// Opt-in: der Tab erscheint erst, wenn das Modul im Projekt gewählt ist.
default_enabled: false,
requires_archive: false,
mcp_tools: &["write_todos", "show_todos"],
buffers: &[BufferDesc {
id: "todos",
env: "AI_CONTROL_TODOS",
event: "todos-update",
file: crate::domain::paths::todos_file,
suffix: "todos.jsonl",
persistent: true,
}],
},
ModuleDesc {
id: "archive",
core: false,
default_enabled: true,
requires_archive: true,
mcp_tools: &["archive_panel", "show_archive", "search_archive"],
buffers: &[
BufferDesc {
id: "search",
env: "AI_CONTROL_SEARCH",
event: "search-update",
file: crate::domain::paths::search_file,
suffix: "search.json",
persistent: false,
},
BufferDesc {
id: "wiki",
env: "AI_CONTROL_WIKI",
event: "wiki-update",
file: crate::domain::paths::wiki_file,
suffix: "wiki.json",
persistent: false,
},
],
},
];
/// Aktive Module des Projekts: MODULES, gefiltert durch die `modules`-
/// Abweichungen der Projekt-Config. Kern-Module sind immer dabei.
pub(crate) fn active_in(
paths: &Paths,
project: &str,
) -> Result<Vec<&'static ModuleDesc>, String> {
let cfg = crate::domain::project::read_project_config_in(paths, project)?;
Ok(
MODULES
.iter()
.filter(|m| {
m.core || cfg.modules.get(m.id).copied().unwrap_or(m.default_enabled)
})
.collect(),
)
}
/// Modul, das dieses MCP-Tool beiträgt.
pub(crate) fn by_tool(tool: &str) -> Option<&'static ModuleDesc> {
MODULES.iter().find(|m| m.mcp_tools.contains(&tool))
}
/// Registry-Zeile für den Settings-Dialog: `enabled` ist der effektive
/// Config-Schalter (ohne `requires_archive` — die Pfadfrage zeigt der
/// Dialog daneben an).
#[derive(serde::Serialize)]
pub(crate) struct ModuleInfo {
pub(crate) id: &'static str,
pub(crate) core: bool,
pub(crate) enabled: bool,
}
pub(crate) fn module_infos_in(
paths: &Paths,
project: &str,
) -> Result<Vec<ModuleInfo>, String> {
let cfg = crate::domain::project::read_project_config_in(paths, project)?;
Ok(
MODULES
.iter()
.map(|m| ModuleInfo {
id: m.id,
core: m.core,
enabled: m.core || cfg.modules.get(m.id).copied().unwrap_or(m.default_enabled),
})
.collect(),
)
}
/// Schreibt die Modul-Abweichung in die Projekt-Config; der Default-Wert
/// löscht den Eintrag (nur Abweichungen werden gespeichert).
pub(crate) fn set_module_in(
paths: &Paths,
project: &str,
module: &str,
enabled: bool,
) -> Result<(), String> {
let m = MODULES
.iter()
.find(|m| m.id == module)
.ok_or_else(|| format!("unbekanntes Modul: {module}"))?;
if m.core {
return Err(format!("Modul {module} ist nicht abschaltbar"));
}
let mut cfg = crate::domain::project::read_project_config_in(paths, project)?;
if enabled == m.default_enabled {
cfg.modules.remove(module);
} else {
cfg.modules.insert(module.to_string(), enabled);
}
crate::domain::project::write_project_config_in(paths, project, &cfg)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::testutil::{create_project, tmp_paths};
fn ids(mods: &[&'static ModuleDesc]) -> Vec<&'static str> {
mods.iter().map(|m| m.id).collect()
}
#[test]
fn defaults_alle_aktiv() {
let p = tmp_paths();
create_project(&p, "proj").unwrap();
assert_eq!(
ids(&active_in(&p, "proj").unwrap()),
vec!["draft", "commands", "archive"]
);
}
#[test]
fn config_schaltet_ab_kern_bleibt() {
let p = tmp_paths();
create_project(&p, "proj").unwrap();
let cfg = p
.projects_dir()
.join("proj")
.join(".ai-control")
.join("config.json");
std::fs::write(
&cfg,
r#"{"id": "proj", "name": "proj", "modules": {"commands": false, "draft": false}}"#,
)
.unwrap();
// commands ist ab; draft ignoriert den Eintrag (Kern-Modul).
assert_eq!(ids(&active_in(&p, "proj").unwrap()), vec!["draft", "archive"]);
}
#[test]
fn unbekanntes_projekt_scheitert() {
let p = tmp_paths();
assert!(active_in(&p, "fehlt").is_err());
}
#[test]
fn todo_ist_opt_in() {
let p = tmp_paths();
create_project(&p, "proj").unwrap();
set_module_in(&p, "proj", "todo", true).unwrap();
assert_eq!(
ids(&active_in(&p, "proj").unwrap()),
vec!["draft", "commands", "todo", "archive"]
);
}
#[test]
fn buffer_datei_passt_zum_suffix() {
for b in MODULES.iter().flat_map(|m| m.buffers) {
let name = (b.file)("proj");
let name = name.file_name().unwrap().to_str().unwrap();
assert_eq!(name, format!("proj.{}", b.suffix));
}
}
#[test]
fn set_module_roundtrip() {
let p = tmp_paths();
create_project(&p, "proj").unwrap();
set_module_in(&p, "proj", "commands", false).unwrap();
assert_eq!(ids(&active_in(&p, "proj").unwrap()), vec!["draft", "archive"]);
assert!(!module_infos_in(&p, "proj").unwrap()[1].enabled);
// Zurück auf den Default löscht den Eintrag statt `true` zu speichern.
set_module_in(&p, "proj", "commands", true).unwrap();
let cfg = crate::domain::project::read_project_config_in(&p, "proj").unwrap();
assert!(cfg.modules.is_empty());
// Kern-Modul und unbekannte ID scheitern laut.
assert!(set_module_in(&p, "proj", "draft", false).is_err());
assert!(set_module_in(&p, "proj", "gibtsnicht", true).is_err());
}
#[test]
fn tool_zuordnung() {
assert_eq!(by_tool("write_panel").unwrap().id, "draft");
assert_eq!(by_tool("search_archive").unwrap().id, "archive");
assert!(by_tool("unbekannt").is_none());
}
#[test]
fn buffer_und_tool_ids_eindeutig() {
let mut tools: Vec<&str> = MODULES.iter().flat_map(|m| m.mcp_tools).copied().collect();
let mut buffers: Vec<&str> =
MODULES.iter().flat_map(|m| m.buffers).map(|b| b.id).collect();
let (t, b) = (tools.len(), buffers.len());
tools.sort();
tools.dedup();
buffers.sort();
buffers.dedup();
assert_eq!(tools.len(), t);
assert_eq!(buffers.len(), b);
}
}
+9
View File
@@ -98,6 +98,15 @@ pub(crate) fn wiki_file(project: &str) -> PathBuf {
.join(format!("{}.wiki.json", checked(project)))
}
/// Persistente ToDo-Liste eines Projekts (JSONL, anhängend — überlebt
/// Sessions; write_todos hängt an, Kachel-Löschen entfernt Zeilen). Der Pfad
/// landet als AI_CONTROL_TODOS in der PTY-Umgebung.
pub(crate) fn todos_file(project: &str) -> PathBuf {
Paths::real()
.panels_dir()
.join(format!("{}.todos.jsonl", checked(project)))
}
/// "~" bzw. "~/x" relativ zum Home auflösen; alles andere unverändert.
pub(crate) fn expand_home(paths: &Paths, p: &str) -> PathBuf {
if p == "~" {
+28 -44
View File
@@ -61,6 +61,11 @@ pub(crate) struct ProjectConfig {
skip_serializing_if = "Option::is_none"
)]
pub(crate) archive_home: Option<String>,
/// Modul-Abweichungen vom Default (`"commands": false`); fehlender Key =
/// Default aus der Registry (domain/modules.rs). Kern-Module ignorieren
/// den Eintrag.
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub(crate) modules: std::collections::BTreeMap<String, bool>,
/// Alle Keys, die dieser Build nicht kennt — unverändert durchgereicht.
/// Ohne das verliert jeder read-modify-write (Pool-Zuweisung, Terminal-
/// Einstellungen) still die Felder neuerer Versionen: serde verwirft
@@ -241,7 +246,6 @@ pub(crate) fn project_config(project: &str) -> Result<ProjectConfig, String> {
/// Berechtigungen), Registry-Eintrag mit Pool, .ai-control/config.json mit
/// ID, Name und Terminal-Config. Ohne Zielordner landet das Projekt unter
/// ~/claude-projects/<name>. Liefert die neue Projekt-ID.
#[allow(clippy::too_many_arguments)]
pub(crate) fn create_project_full_in(
paths: &Paths,
name: &str,
@@ -250,7 +254,6 @@ pub(crate) fn create_project_full_in(
work_dir: Option<&str>,
create_work_dir: bool,
terminal: TerminalConfig,
todo: bool,
) -> Result<String, String> {
check_name(name)?;
let dir = match dir {
@@ -312,12 +315,10 @@ pub(crate) fn create_project_full_in(
name: Some(name.to_string()),
terminal,
archive_home: None,
modules: Default::default(),
rest: Default::default(),
};
write_project_config_in(paths, &id, &cfg)?;
if todo {
crate::domain::todo::set_todo_in(paths, &id, true)?;
}
crate::platform::write_terminal_desktop(paths, &id, &cfg);
Ok(id)
}
@@ -484,7 +485,6 @@ pub(crate) struct DeletePreview {
pub(crate) project_dir: String,
pub(crate) ai_control_dir: bool,
pub(crate) archive_permission: bool,
pub(crate) todo_hook: bool,
pub(crate) panel_files: usize,
pub(crate) archive_home: Option<String>,
pub(crate) archive_docs: usize,
@@ -511,7 +511,6 @@ pub(crate) fn delete_preview_in(paths: &Paths, project: &str) -> Result<DeletePr
project_dir: contract_home(paths, &dir),
ai_control_dir: dir.join(PROJECT_CONFIG_DIR).is_dir(),
archive_permission: cfg.archive_home.is_some(),
todo_hook: crate::domain::todo::todo_state_in(paths, project)?,
panel_files: session_files(paths, project).iter().filter(|f| f.is_file()).count(),
archive_home: cfg.archive_home.clone(),
archive_docs,
@@ -520,22 +519,22 @@ pub(crate) fn delete_preview_in(paths: &Paths, project: &str) -> Result<DeletePr
}
/// Flüchtige Panel-Kanaldateien des Projekts unter ~/.config/ai-control/panels.
fn session_files(paths: &Paths, project: &str) -> [PathBuf; 4] {
/// Panel-Kanaldateien des Projekts — aus der Modul-Registry, damit neue
/// Puffer automatisch in Löschvorschau und Löschung landen.
fn session_files(paths: &Paths, project: &str) -> Vec<PathBuf> {
let panels = paths.config_dir().join("panels");
[
panels.join(format!("{project}.md")),
panels.join(format!("{project}.commands.jsonl")),
panels.join(format!("{project}.search.json")),
panels.join(format!("{project}.wiki.json")),
]
crate::domain::modules::MODULES
.iter()
.flat_map(|m| m.buffers)
.map(|b| panels.join(format!("{project}.{}", b.suffix)))
.collect()
}
/// Löscht ein Projekt in drei Stufen (Eskalationsleiter, jede schließt die
/// vorige ein):
/// - "integration": nur die ai-control-Spuren — Registry, .ai-control/,
/// Archiv-Rechte und Todo-Hook in der settings.json, Panel-Dateien,
/// .desktop. Ordner, memory/ und das Claude-Code-Grundgerüst der
/// settings.json bleiben.
/// Archiv-Rechte in der settings.json, Panel-Dateien, .desktop. Ordner,
/// memory/ und das Claude-Code-Grundgerüst der settings.json bleiben.
/// - "archive": zusätzlich der Archiv-Ordner samt Dokumenten.
/// - "full": zusätzlich der Projektordner; Arbeitsordner per Flag.
pub(crate) fn delete_project_scoped_in(
@@ -567,9 +566,6 @@ pub(crate) fn delete_project_scoped_in(
if let Some(a) = cfg.archive_home.as_deref() {
crate::domain::archive::remove_archive_permission(paths, project, a)?;
}
if crate::domain::todo::todo_state_in(paths, project)? {
crate::domain::todo::set_todo_in(paths, project, false)?;
}
let ac = dir.join(PROJECT_CONFIG_DIR);
if ac.is_dir() {
fs::remove_dir_all(&ac).map_err(|e| format!("{}: {e}", ac.display()))?;
@@ -784,7 +780,6 @@ pub(crate) fn migrate_layout_in(paths: &Paths) -> Result<(), String> {
mod tests {
use super::*;
use crate::domain::testutil::{create_project, make_apikey_pool, make_oauth_pool, map_store, tmp_paths};
use crate::domain::todo::TODO_FILE;
fn project_config_path(paths: &Paths, project: &str) -> Result<PathBuf, String> {
Ok(project_dir(paths, project)?.join(PROJECT_CONFIG_DIR).join(PROJECT_FILE))
@@ -841,7 +836,6 @@ mod tests {
title: Some("Neu".into()),
..Default::default()
},
true,
)
.unwrap();
@@ -876,21 +870,14 @@ mod tests {
"Edit(~/claude-projects/neu/**)"
);
assert_eq!(settings["permissions"]["additionalDirectories"][0], "~/projects/neu");
// todo=true: einziger SessionStart-Hook (kein pool-guard mehr), Datei da
assert!(dir.join(TODO_FILE).is_file());
let groups = settings["hooks"]["SessionStart"].as_array().unwrap();
assert_eq!(groups.len(), 1);
assert!(groups[0]["hooks"][0]["command"]
.as_str()
.unwrap()
.contains(TODO_FILE));
assert!(settings.get("hooks").is_none());
}
#[test]
fn projekt_wizard_minimal_ohne_pool_und_workdir() {
let p = tmp_paths();
let id =
create_project_full_in(&p, "neu", None, None, None, false, TerminalConfig::default(), false)
create_project_full_in(&p, "neu", None, None, None, false, TerminalConfig::default())
.unwrap();
let dir = p.projects_dir().join("neu");
assert!(dir.join(".claude").join("settings.json").is_file());
@@ -915,7 +902,7 @@ mod tests {
let p = tmp_paths();
create_project(&p, "neu").unwrap();
let err =
create_project_full_in(&p, "neu", None, None, None, false, TerminalConfig::default(), false)
create_project_full_in(&p, "neu", None, None, None, false, TerminalConfig::default())
.unwrap_err();
assert!(err.contains("existiert bereits"));
}
@@ -931,7 +918,6 @@ mod tests {
Some("~/projects/gibtsnicht"),
false,
TerminalConfig::default(),
false,
)
.unwrap_err();
assert!(err.contains("Arbeitsverzeichnis fehlt"));
@@ -1225,7 +1211,7 @@ mod tests {
#[test]
fn projekt_loeschen_laesst_arbeitsordner() {
let p = tmp_paths();
let id = create_project_full_in(&p, "proj", None, None, Some("~/projects/proj"), true, TerminalConfig::default(), false)
let id = create_project_full_in(&p, "proj", None, None, Some("~/projects/proj"), true, TerminalConfig::default())
.unwrap();
delete_project_scoped_in(&p, &id, "full", false).unwrap();
assert!(!p.projects_dir().join("proj").exists());
@@ -1235,7 +1221,7 @@ mod tests {
#[test]
fn projekt_loeschen_mit_arbeitsordner() {
let p = tmp_paths();
let id = create_project_full_in(&p, "proj", None, None, Some("~/projects/proj"), true, TerminalConfig::default(), false)
let id = create_project_full_in(&p, "proj", None, None, Some("~/projects/proj"), true, TerminalConfig::default())
.unwrap();
delete_project_scoped_in(&p, &id, "full", true).unwrap();
assert!(!p.projects_dir().join("proj").exists());
@@ -1245,7 +1231,7 @@ mod tests {
#[test]
fn projekt_loeschen_fehlender_arbeitsordner_scheitert() {
let p = tmp_paths();
let id = create_project_full_in(&p, "proj", None, None, Some("~/projects/proj"), true, TerminalConfig::default(), false)
let id = create_project_full_in(&p, "proj", None, None, Some("~/projects/proj"), true, TerminalConfig::default())
.unwrap();
fs::remove_dir_all(p.home.join("projects").join("proj")).unwrap();
assert!(delete_project_scoped_in(&p, &id, "full", true).is_err());
@@ -1254,7 +1240,7 @@ mod tests {
#[test]
fn projekt_arbeitsordner_auslesen() {
let p = tmp_paths();
let id = create_project_full_in(&p, "proj", None, None, Some("~/projects/proj"), true, TerminalConfig::default(), false)
let id = create_project_full_in(&p, "proj", None, None, Some("~/projects/proj"), true, TerminalConfig::default())
.unwrap();
assert_eq!(project_work_dirs_in(&p, &id).unwrap(), vec!["~/projects/proj"]);
// Projekt ohne settings.json → leer
@@ -1272,7 +1258,7 @@ mod tests {
#[test]
fn arbeitsordner_nachtraeglich_erfassen_und_entfernen() {
let p = tmp_paths();
let id = create_project_full_in(&p, "proj", None, None, None, false, TerminalConfig::default(), false)
let id = create_project_full_in(&p, "proj", None, None, None, false, TerminalConfig::default())
.unwrap();
fs::create_dir_all(p.home.join("projects/extra")).unwrap();
let picked = p.home.join("projects/extra").to_string_lossy().into_owned();
@@ -1294,7 +1280,7 @@ mod tests {
#[test]
fn projektordner_verlegen() {
let p = tmp_paths();
let id = create_project_full_in(&p, "proj", None, None, None, false, TerminalConfig::default(), false)
let id = create_project_full_in(&p, "proj", None, None, None, false, TerminalConfig::default())
.unwrap();
// Nutzer hat den Ordner selbst verschoben; die App ordnet nur neu zu.
let new_dir = p.home.join("elsewhere").join("proj");
@@ -1350,7 +1336,7 @@ mod tests {
#[test]
fn loeschen_nur_integration() {
let p = tmp_paths();
let id = create_project_full_in(&p, "proj", None, None, None, false, TerminalConfig::default(), true)
let id = create_project_full_in(&p, "proj", None, None, None, false, TerminalConfig::default())
.unwrap();
let dir = p.projects_dir().join("proj");
// Archiv-Home samt Permission wie über die UI gesetzt
@@ -1365,7 +1351,7 @@ mod tests {
fs::write(p.config_dir().join("panels").join(format!("{id}.md")), "x").unwrap();
let preview = delete_preview_in(&p, &id).unwrap();
assert!(preview.ai_control_dir && preview.archive_permission && preview.todo_hook);
assert!(preview.ai_control_dir && preview.archive_permission);
assert_eq!(preview.panel_files, 1);
assert_eq!(preview.archive_docs, 1);
@@ -1375,9 +1361,7 @@ mod tests {
assert!(!dir.join(PROJECT_CONFIG_DIR).exists());
let sp = fs::read_to_string(settings_path(&dir)).unwrap();
assert!(!sp.contains("archiv/proj"));
assert!(!sp.contains("OFFENE-PUNKTE.md")); // Hook weg (leere Hook-Liste darf bleiben)
assert!(sp.contains("autoMemoryDirectory")); // Claude-Code-Bestand bleibt
assert!(dir.join(crate::domain::todo::TODO_FILE).is_file()); // Nutzerinhalt bleibt
assert!(p.home.join("archiv/proj/doc.md").is_file()); // Archiv bleibt
assert!(!p.config_dir().join("panels").join(format!("{id}.md")).exists());
assert!(load_registry(&p).unwrap().is_empty());
@@ -1387,7 +1371,7 @@ mod tests {
#[test]
fn loeschen_integration_und_archiv() {
let p = tmp_paths();
let id = create_project_full_in(&p, "proj", None, None, None, false, TerminalConfig::default(), false)
let id = create_project_full_in(&p, "proj", None, None, None, false, TerminalConfig::default())
.unwrap();
let mut cfg = read_project_config_in(&p, &id).unwrap();
cfg.archive_home = Some("~/archiv/proj".into());
-166
View File
@@ -1,166 +0,0 @@
//! Feature: Todoliste. Muster robotunits: Datei im Projekt-Root, per
//! SessionStart-Hook (jq) als additionalContext in jede Session injiziert.
use std::fs;
use crate::domain::check_name;
use crate::domain::paths::Paths;
use crate::domain::project::settings_path;
use crate::domain::registry::project_dir;
pub(crate) const TODO_FILE: &str = "OFFENE-PUNKTE.md";
const TODO_SKELETON: &str = "# Offene Punkte — bei jedem Start prüfen und abhaken\n\nKeine offenen Punkte.\n";
/// Der Hook landet in der `settings.json` des Projekts und wird von Claude Code
/// bei jedem Sessionstart über die Shell ausgeführt. Der Pfad muss darum
/// gequotet werden: Er stammt aus dem Ordnernamen, den der Nutzer im Dialog
/// wählt — bei einem geklonten Fremd-Repo also von außen. Unquotiert genügte
/// ein Ordner `repo$(…)` für dauerhafte Codeausführung, und schon ein
/// Leerzeichen im Pfad hätte den Hook still zerbrochen.
fn todo_hook_command(dir: &std::path::Path) -> String {
format!(
"jq -Rs '{{systemMessage: ., hookSpecificOutput:{{hookEventName:\"SessionStart\", additionalContext: .}}}}' {}",
shell_quote(&dir.join(TODO_FILE).to_string_lossy())
)
}
/// Ein Argument für `sh -c` in einfache Anführungszeichen setzen. Innerhalb
/// davon ist jedes Zeichen literal; einzig das Apostroph selbst muss die
/// Quotierung verlassen und wieder betreten (`'\''`).
fn shell_quote(s: &str) -> String {
format!("'{}'", s.replace('\'', r"'\''"))
}
fn hook_is_todo(group: &serde_json::Value) -> bool {
group["hooks"]
.as_array()
.map(|hs| {
hs.iter().any(|h| {
h["command"]
.as_str()
.map_or(false, |c| c.contains(TODO_FILE))
})
})
.unwrap_or(false)
}
pub(crate) fn todo_state_in(paths: &Paths, name: &str) -> Result<bool, String> {
check_name(name)?;
let sp = settings_path(&project_dir(paths, name)?);
if !sp.is_file() {
return Ok(false);
}
let raw = fs::read_to_string(&sp).map_err(|e| format!("{}: {e}", sp.display()))?;
let v: serde_json::Value =
serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", sp.display()))?;
Ok(
v["hooks"]["SessionStart"]
.as_array()
.map(|a| a.iter().any(hook_is_todo))
.unwrap_or(false),
)
}
pub(crate) fn set_todo_in(paths: &Paths, name: &str, enabled: bool) -> Result<(), String> {
check_name(name)?;
let dir = project_dir(paths, name)?;
let sp = settings_path(&dir);
if !sp.is_file() {
return Err(format!("settings.json fehlt: {}", sp.display()));
}
let raw = fs::read_to_string(&sp).map_err(|e| format!("{}: {e}", sp.display()))?;
let mut v: serde_json::Value =
serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", sp.display()))?;
let root = v.as_object_mut().ok_or("settings.json ist kein Objekt")?;
let hooks = root
.entry("hooks")
.or_insert_with(|| serde_json::json!({}))
.as_object_mut()
.ok_or("hooks ist kein Objekt")?;
let session_start = hooks
.entry("SessionStart")
.or_insert_with(|| serde_json::json!([]))
.as_array_mut()
.ok_or("SessionStart ist kein Array")?;
session_start.retain(|g| !hook_is_todo(g));
if enabled {
session_start.insert(
0,
serde_json::json!({
"hooks": [
{ "type": "command", "command": todo_hook_command(&dir) }
]
}),
);
let todo_path = dir.join(TODO_FILE);
if !todo_path.is_file() {
fs::write(&todo_path, TODO_SKELETON)
.map_err(|e| format!("{}: {e}", todo_path.display()))?;
}
}
let raw = serde_json::to_string_pretty(&v).map_err(|e| e.to_string())?;
crate::domain::write_atomic(&sp, &(raw + "\n"))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::project::{create_project_full_in, TerminalConfig};
use crate::domain::testutil::{create_project, tmp_paths};
/// Der Hook-Befehl geht durch die Shell; ein Pfad mit Metazeichen darf dort
/// nichts ausführen. Ordnernamen sind bei geklonten Repos Fremdeingabe.
#[test]
fn hook_befehl_quotet_den_pfad() {
let cmd = todo_hook_command(std::path::Path::new("/tmp/repo$(touch /tmp/pwned)"));
assert!(cmd.ends_with("'/tmp/repo$(touch /tmp/pwned)/OFFENE-PUNKTE.md'"), "{cmd}");
// Ein Apostroph im Pfad darf die Quotierung nicht aufbrechen.
let cmd = todo_hook_command(std::path::Path::new("/tmp/o'brien"));
assert!(cmd.ends_with(r"'/tmp/o'\''brien/OFFENE-PUNKTE.md'"), "{cmd}");
// Nach dem Zerlegen an den Quotes bleibt kein unquotierter Bereich übrig,
// in dem eine Shell noch etwas zu interpretieren hätte.
assert!(!cmd.contains("$("));
}
#[test]
fn todo_zuschalten_und_abschalten() {
let p = tmp_paths();
let id =
create_project_full_in(&p, "proj", None, None, None, false, TerminalConfig::default(), false)
.unwrap();
assert!(!todo_state_in(&p, &id).unwrap());
set_todo_in(&p, &id, true).unwrap();
assert!(todo_state_in(&p, &id).unwrap());
let todo_path = p.projects_dir().join("proj").join(TODO_FILE);
assert_eq!(fs::read_to_string(&todo_path).unwrap(), TODO_SKELETON);
// doppelt aktivieren erzeugt keinen zweiten Hook
set_todo_in(&p, &id, true).unwrap();
let settings: serde_json::Value = serde_json::from_str(
&fs::read_to_string(settings_path(&p.projects_dir().join("proj"))).unwrap(),
)
.unwrap();
let groups = settings["hooks"]["SessionStart"].as_array().unwrap();
assert_eq!(groups.iter().filter(|g| hook_is_todo(g)).count(), 1);
// Abschalten: Hook weg, Datei (mit Inhalt) bleibt
fs::write(&todo_path, "# Offene Punkte\n\n- [ ] wichtig\n").unwrap();
set_todo_in(&p, &id, false).unwrap();
assert!(!todo_state_in(&p, &id).unwrap());
assert!(todo_path.is_file());
assert!(fs::read_to_string(&todo_path).unwrap().contains("wichtig"));
}
#[test]
fn todo_ohne_settings_scheitert() {
let p = tmp_paths();
create_project(&p, "alt").unwrap();
assert!(set_todo_in(&p, "alt", true).is_err());
assert!(!todo_state_in(&p, "alt").unwrap());
}
}
+191 -26
View File
@@ -57,9 +57,43 @@ fn handle(method: &str, req: &Value) -> Option<Value> {
"serverInfo": { "name": "text-panel", "version": env!("CARGO_PKG_VERSION") },
}))
}
// Die Tool-Liste kommt aus der Modul-Registry: nur Tools der im Projekt
// aktiven Module. Eine laufende Session behält ihre Liste vom Start —
// nachträglich abgeschaltete Module fängt der Guard in call_tool ab.
"tools/list" => Some(json!({
"tools": [
{
"tools": active_modules()
.iter()
.flat_map(|m| m.mcp_tools)
.map(|name| tool_def(name))
.collect::<Vec<Value>>(),
})),
"tools/call" => Some(call_tool(req)),
"ping" => Some(json!({})),
_ => None,
}
}
/// Aktive Module des Projekts aus AI_CONTROL_PROJECT. Ohne lesbare
/// Projekt-Config (Terminal außerhalb ai-control) gelten die
/// Registry-Defaults — die Tools erscheinen, ihre Aufrufe scheitern dann wie
/// bisher an der fehlenden Env.
fn active_modules() -> Vec<&'static crate::domain::modules::ModuleDesc> {
let project = std::env::var("AI_CONTROL_PROJECT").unwrap_or_default();
crate::domain::modules::active_in(&crate::domain::paths::Paths::real(), &project)
.unwrap_or_else(|_| {
crate::domain::modules::MODULES
.iter()
.filter(|m| m.default_enabled)
.collect()
})
}
/// MCP-Definition eines Tools. Welche davon gelistet werden, entscheidet die
/// Modul-Registry (active_modules); jeder Name aus MODULES muss hier einen
/// Arm haben.
fn tool_def(name: &str) -> Value {
match name {
"write_panel" => json!({
"name": "write_panel",
"description":
"Legt einen längeren Entwurf (ADR, E-Mail, Dokument, Spezifikation, \
@@ -86,8 +120,8 @@ fn handle(method: &str, req: &Value) -> Option<Value> {
}
},
},
},
{
}),
"write_commands" => json!({
"name": "write_commands",
"description":
"Listet Shell-Befehle, die der Nutzer ausführen soll, als kopierbare \
@@ -120,8 +154,8 @@ fn handle(method: &str, req: &Value) -> Option<Value> {
},
"required": ["commands"],
},
},
{
}),
"show_commands" => json!({
"name": "show_commands",
"description":
"Zeigt die Befehls-History im ai-control-Panel (Kachel-Ansicht mit \
@@ -129,8 +163,54 @@ fn handle(method: &str, req: &Value) -> Option<Value> {
wenn der Nutzer die Befehlsliste sehen will („zeig die Befehle“, \
„zeige die Befehlsliste“).",
"inputSchema": { "type": "object", "properties": {} },
},
{
}),
"write_todos" => json!({
"name": "write_todos",
"description":
"Hängt Aufgaben an die persistente ToDo-Liste des Projekts an; \
sie erscheinen als Kacheln im ToDo-Tab des ai-control-Panels und \
überleben die Session. Nutzen, wenn der Nutzer etwas auf die \
ToDo-Liste setzen will („auf die Liste“, „als ToDo merken“). \
`due` optional als ISO-Datum YYYY-MM-DD — das Panel zeigt die \
Fälligkeit als Ampel.",
"inputSchema": {
"type": "object",
"properties": {
"todos": {
"type": "array",
"description": "Aufgaben in Listenreihenfolge.",
"items": {
"type": "object",
"properties": {
"text": {
"type": "string",
"description": "Die Aufgabe, knapp formuliert.",
},
"note": {
"type": "string",
"description": "Optionale Kurznotiz.",
},
"due": {
"type": "string",
"description": "Optionales Fälligkeitsdatum (YYYY-MM-DD).",
}
},
"required": ["text"],
},
}
},
"required": ["todos"],
},
}),
"show_todos" => json!({
"name": "show_todos",
"description":
"Zeigt die ToDo-Liste im ai-control-Panel (Kachel-Ansicht), ohne \
etwas hinzuzufügen. Nutzen, wenn der Nutzer die ToDos sehen will \
(„zeig die ToDos“).",
"inputSchema": { "type": "object", "properties": {} },
}),
"archive_panel" => json!({
"name": "archive_panel",
"description":
"Archiviert den aktuell im Panel liegenden Entwurf dauerhaft als \
@@ -160,8 +240,8 @@ fn handle(method: &str, req: &Value) -> Option<Value> {
}
},
},
},
{
}),
"show_archive" => json!({
"name": "show_archive",
"description":
"Zeigt die Archiv-Übersicht des Projekts als Wiki-Seite im Panel: \
@@ -178,8 +258,8 @@ fn handle(method: &str, req: &Value) -> Option<Value> {
}
},
},
},
{
}),
"search_archive" => json!({
"name": "search_archive",
"description":
"Volltext-Suche über das Panel-Archiv des Projekts (FTS5-Syntax: \
@@ -200,20 +280,26 @@ fn handle(method: &str, req: &Value) -> Option<Value> {
}
},
},
},
],
})),
"tools/call" => Some(call_tool(req)),
"ping" => Some(json!({})),
_ => None,
}),
other => unreachable!("tool_def ohne Definition: {other}"),
}
}
fn call_tool(req: &Value) -> Value {
match req["params"]["name"].as_str().unwrap_or("") {
let name = req["params"]["name"].as_str().unwrap_or("");
// Guard für Sessions, deren Tool-Liste älter ist als die Projekt-Config:
// Tools mittlerweile abgeschalteter Module werden abgewiesen.
if let Some(m) = crate::domain::modules::by_tool(name) {
if !active_modules().iter().any(|a| a.id == m.id) {
return err(format!("Modul „{}“ ist in diesem Projekt abgeschaltet.", m.id));
}
}
match name {
"write_panel" => call_write(req),
"write_commands" => call_write_commands(req),
"show_commands" => call_show_commands(),
"write_todos" => call_write_todos(req),
"show_todos" => call_show_todos(),
"archive_panel" => call_archive(req),
"search_archive" => call_search(req),
"show_archive" => call_show_archive(req),
@@ -316,11 +402,11 @@ fn call_write_commands(req: &Value) -> Value {
}
}
/// Öffnet die Kachel-Ansicht, ohne der History etwas hinzuzufügen: mtime der
/// History-Datei anfassen genügt — der Watcher im Terminal-Prozess meldet sie
/// als `commands-update`, das Panel schaltet auf „Befehle“ um.
fn call_show_commands() -> Value {
let path = match env_path("AI_CONTROL_COMMANDS") {
/// Öffnet eine Kachel-Ansicht, ohne der Datei etwas hinzuzufügen: mtime
/// anfassen genügt — der Watcher im Terminal-Prozess meldet die Datei als
/// Update, das Panel schaltet auf den zugehörigen Tab um.
fn touch_buffer(env: &str, ok_msg: &str, err_ctx: &str) -> Value {
let path = match env_path(env) {
Ok(path) => path,
Err(e) => return e,
};
@@ -330,8 +416,87 @@ fn call_show_commands() -> Value {
.open(&path)
.and_then(|f| f.set_modified(std::time::SystemTime::now()));
match res {
Ok(()) => ok("Befehls-History im Panel geöffnet.".into()),
Err(e) => err(format!("Command-History nicht erreichbar: {e}")),
Ok(()) => ok(ok_msg.into()),
Err(e) => err(format!("{err_ctx}: {e}")),
}
}
fn call_show_commands() -> Value {
touch_buffer(
"AI_CONTROL_COMMANDS",
"Befehls-History im Panel geöffnet.",
"Command-History nicht erreichbar",
)
}
fn call_show_todos() -> Value {
touch_buffer(
"AI_CONTROL_TODOS",
"ToDo-Liste im Panel geöffnet.",
"ToDo-Liste nicht erreichbar",
)
}
/// Prüft ein Fälligkeitsdatum: YYYY-MM-DD, Monat 0112, Tag 0131.
pub(crate) fn valid_due(due: &str) -> bool {
let b = due.as_bytes();
if b.len() != 10 || b[4] != b'-' || b[7] != b'-' {
return false;
}
let (y, m, d) = (
due[0..4].parse::<u16>(),
due[5..7].parse::<u8>(),
due[8..10].parse::<u8>(),
);
matches!((y, m, d), (Ok(_), Ok(m), Ok(d)) if (1..=12).contains(&m) && (1..=31).contains(&d))
}
/// Hängt ToDos an die persistente Liste (JSONL, ein ToDo pro Zeile); jedes
/// bekommt eine stabile ID fürs Kachel-Löschen. Der Watcher zieht den neuen
/// Stand als `todos-update` ins Panel.
fn call_write_todos(req: &Value) -> Value {
let path = match env_path("AI_CONTROL_TODOS") {
Ok(path) => path,
Err(e) => return e,
};
let Some(todos) = req["params"]["arguments"]["todos"].as_array() else {
return err("todos fehlt".into());
};
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let mut out = String::new();
for todo in todos {
let text = todo["text"].as_str().unwrap_or("").trim();
if text.is_empty() {
return err("ToDo ohne text".into());
}
let mut rec = json!({
"id": uuid::Uuid::new_v4().to_string(),
"ts": ts,
"text": text,
});
if let Some(note) = todo["note"].as_str() {
rec["note"] = json!(note);
}
if let Some(due) = todo["due"].as_str() {
if !valid_due(due) {
return err(format!("ungültiges due-Datum: {due} (erwartet YYYY-MM-DD)"));
}
rec["due"] = json!(due);
}
out.push_str(&rec.to_string());
out.push('\n');
}
let res = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.and_then(|mut f| std::io::Write::write_all(&mut f, out.as_bytes()));
match res {
Ok(()) => ok(format!("{} ToDo(s) auf der Liste.", todos.len())),
Err(e) => err(format!("ToDo-Liste nicht schreibbar: {e}")),
}
}
+204 -55
View File
@@ -126,29 +126,25 @@ pub fn term_start(
.openpty(PtySize { rows, cols, pixel_width: 0, pixel_height: 0 })
.map_err(|e| e.to_string())?;
// Panel-Kanal: leere Datei anlegen (definierter Startzustand für den
// Watcher) und ihren Pfad als AI_CONTROL_PANEL in die PTY geben — der Skill
// schreibt seinen Entwurf dorthin.
let panel_path = panel_file(&project);
if let Some(parent) = panel_path.parent() {
let _ = std::fs::create_dir_all(parent);
// Puffer-Kanäle aller Module (Modul-Registry): flüchtige Puffer leeren
// (Inhalte gelten pro Session), persistente (ToDo) nur anlegen; die Pfade
// für Env und Watcher vormerken. Bewusst alle Module, nicht nur aktive:
// Die Kanäle sind billig, und ein mitten in der Session zugeschaltetes
// Modul findet sie vor; die Abwahl wirkt auf Tool-Liste und Tabs.
let buffers: Vec<(&'static crate::domain::modules::BufferDesc, std::path::PathBuf)> =
crate::domain::modules::MODULES
.iter()
.flat_map(|m| m.buffers)
.map(|b| (b, (b.file)(&project)))
.collect();
for (b, path) in &buffers {
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
if !b.persistent || !path.is_file() {
let _ = std::fs::write(path, "");
}
}
let _ = std::fs::write(&panel_path, "");
// Command-Kanal: History-Datei (JSONL) leeren — die Befehls-History ist
// flüchtig und gilt nur für diese Session; write_commands hängt Records an.
let commands_path = commands_file(&project);
let _ = std::fs::write(&commands_path, "");
// Such-Kanal: Treffer-Datei leeren — search_archive schreibt den jeweils
// letzten Suchlauf hinein, der Watcher zieht ihn als Kacheln ins Panel.
let search_path = search_file(&project);
let _ = std::fs::write(&search_path, "");
// Wiki-Kanal: Puffer leeren — show_archive und wiki_open schreiben die
// jeweils aktuelle Wiki-Seite (JSON) hinein.
let wiki_path = wiki_file(&project);
let _ = std::fs::write(&wiki_path, "");
let mut cmd = crate::platform::shell_command(&claude_command(&paths));
cmd.cwd(&cwd);
@@ -158,10 +154,9 @@ pub fn term_start(
cmd.env_remove("ANTHROPIC_API_KEY");
cmd.env_remove("ANTHROPIC_AUTH_TOKEN");
cmd.env("TERM", "xterm-256color");
cmd.env("AI_CONTROL_PANEL", &panel_path);
cmd.env("AI_CONTROL_COMMANDS", &commands_path);
cmd.env("AI_CONTROL_SEARCH", &search_path);
cmd.env("AI_CONTROL_WIKI", &wiki_path);
for (b, path) in &buffers {
cmd.env(b.env, path);
}
cmd.env("AI_CONTROL_PROJECT", &project);
if let Some(pool_dir) = project_pool_dir(&project)? {
cmd.env("CLAUDE_CONFIG_DIR", pool_dir);
@@ -219,14 +214,9 @@ pub fn term_start(
let _ = app.emit_to(&label, "pty-exit", ());
});
spawn_file_watcher(window.app_handle().clone(), panel_path, "panel-update");
spawn_file_watcher(
window.app_handle().clone(),
commands_path,
"commands-update",
);
spawn_file_watcher(window.app_handle().clone(), search_path, "search-update");
spawn_file_watcher(window.app_handle().clone(), wiki_path, "wiki-update");
for (b, path) in buffers {
spawn_file_watcher(window.app_handle().clone(), path, b.event);
}
terminals.0.lock().unwrap().insert(
window.label().to_string(),
@@ -257,16 +247,17 @@ fn spawn_file_watcher(app: AppHandle, path: std::path::PathBuf, event: &'static
});
}
/// Aktueller Panel-Inhalt (Erstbefüllung eines gerade geöffneten Panel-Fensters).
/// Aktueller Inhalt eines Modul-Puffers (Erstbefüllung einer Panel-Ansicht);
/// `buffer` ist die Puffer-ID aus der Modul-Registry. Eine noch nicht
/// geschriebene Datei liest sich als leer — wie ein leerer Puffer.
#[tauri::command]
pub fn panel_read(project: String) -> String {
std::fs::read_to_string(panel_file(&project)).unwrap_or_default()
}
/// Aktuelle Command-History (JSONL; Erstbefüllung der Kachel-Ansicht).
#[tauri::command]
pub fn commands_read(project: String) -> String {
std::fs::read_to_string(commands_file(&project)).unwrap_or_default()
pub fn buffer_read(project: String, buffer: String) -> Result<String, String> {
let b = crate::domain::modules::MODULES
.iter()
.flat_map(|m| m.buffers)
.find(|b| b.id == buffer)
.ok_or_else(|| format!("unbekannter Puffer: {buffer}"))?;
Ok(std::fs::read_to_string((b.file)(&project)).unwrap_or_default())
}
/// Entfernt einen Befehl aus der Command-History (Löschen einer Kachel im
@@ -303,6 +294,131 @@ pub fn commands_delete(project: String, id: String) -> Result<(), String> {
std::fs::write(&path, out).map_err(|e| e.to_string())
}
/// Entfernt ein ToDo aus der persistenten Liste (Löschen einer Kachel) über
/// seine stabile ID, die write_todos beim Schreiben vergibt. Der Watcher
/// meldet den neuen Stand als `todos-update`.
#[tauri::command]
pub fn todos_delete(project: String, id: String) -> Result<(), String> {
let path = crate::domain::paths::todos_file(&project);
let text = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
let mut removed = false;
let mut out = String::new();
for line in text.lines().filter(|l| !l.trim().is_empty()) {
let rec: serde_json::Value = serde_json::from_str(line).map_err(|e| e.to_string())?;
if rec["id"].as_str() == Some(id.as_str()) {
removed = true;
continue;
}
out.push_str(line);
out.push('\n');
}
if !removed {
return Err("ToDo bereits entfernt".into());
}
std::fs::write(&path, out).map_err(|e| e.to_string())
}
/// Baut die JSONL-Zeile eines ToDos — gleiche Form wie call_write_todos im
/// MCP-Server; `id` und `ts` kommen vom Aufrufer, damit das Update beide
/// erhalten kann.
fn todo_line(
id: &str,
ts: u64,
text: &str,
note: Option<&str>,
due: Option<&str>,
) -> Result<String, String> {
let text = text.trim();
if text.is_empty() {
return Err("ToDo ohne Text".into());
}
let mut rec = serde_json::json!({ "id": id, "ts": ts, "text": text });
if let Some(note) = note {
rec["note"] = serde_json::json!(note);
}
if let Some(due) = due {
if !crate::mcp::valid_due(due) {
return Err(format!("ungültiges due-Datum: {due} (erwartet YYYY-MM-DD)"));
}
rec["due"] = serde_json::json!(due);
}
Ok(rec.to_string())
}
/// Ersetzt die Zeile mit passender ID durch Text/Notiz/Fälligkeit aus dem
/// Formular; ID und ts der Zeile bleiben, alle anderen Zeilen bleiben
/// wörtlich erhalten.
fn replace_todo_line(
raw: &str,
id: &str,
text: &str,
note: Option<&str>,
due: Option<&str>,
) -> Result<String, String> {
let mut found = false;
let mut out = String::new();
for line in raw.lines().filter(|l| !l.trim().is_empty()) {
let rec: serde_json::Value = serde_json::from_str(line).map_err(|e| e.to_string())?;
if rec["id"].as_str() == Some(id) {
found = true;
let ts = rec["ts"].as_u64().ok_or("ToDo ohne ts")?;
out.push_str(&todo_line(id, ts, text, note, due)?);
} else {
out.push_str(line);
}
out.push('\n');
}
if !found {
return Err("ToDo nicht gefunden".into());
}
Ok(out)
}
/// Legt ein ToDo aus dem Panel-Formular an (Plus-Button im ToDo-Tab). Der
/// Watcher meldet den neuen Stand als `todos-update`.
#[tauri::command]
pub fn todos_add(
project: String,
text: String,
note: Option<String>,
due: Option<String>,
) -> Result<(), String> {
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let line = todo_line(
&uuid::Uuid::new_v4().to_string(),
ts,
&text,
note.as_deref(),
due.as_deref(),
)?;
let path = crate::domain::paths::todos_file(&project);
std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.and_then(|mut f| std::io::Write::write_all(&mut f, format!("{line}\n").as_bytes()))
.map_err(|e| e.to_string())
}
/// Überschreibt Text, Notiz und Fälligkeit eines ToDos (Stift auf der
/// Kachel). Der Watcher meldet den neuen Stand als `todos-update`.
#[tauri::command]
pub fn todos_update(
project: String,
id: String,
text: String,
note: Option<String>,
due: Option<String>,
) -> Result<(), String> {
let path = crate::domain::paths::todos_file(&project);
let raw = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
let out = replace_todo_line(&raw, &id, &text, note.as_deref(), due.as_deref())?;
std::fs::write(&path, out).map_err(|e| e.to_string())
}
/// Schreibt den Panel-Inhalt (Titel-Edit im Panel). Der Watcher meldet die
/// Änderung als `panel-update` an alle Fenster.
#[tauri::command]
@@ -310,12 +426,6 @@ pub fn panel_set(project: String, text: String) -> Result<(), String> {
std::fs::write(panel_file(&project), text).map_err(|e| e.to_string())
}
/// Letzter Suchlauf (JSON; Erstbefüllung der Treffer-Ansicht).
#[tauri::command]
pub fn search_read(project: String) -> String {
std::fs::read_to_string(search_file(&project)).unwrap_or_default()
}
/// Lädt ein Archiv-Dokument in den Dokument-Puffer (Treffer-Klick in der
/// Suche) — ohne Frontmatter-Block, wie ein frischer Entwurf. Der Watcher
/// meldet den neuen Inhalt als `panel-update`.
@@ -342,12 +452,6 @@ pub fn search_run(project: String, query: String, tag: Option<String>) -> Result
std::fs::write(search_file(&project), payload.to_string()).map_err(|e| e.to_string())
}
/// Aktueller Wiki-Puffer (JSON; Erstbefüllung der Wiki-Ansicht).
#[tauri::command]
pub fn wiki_read(project: String) -> String {
std::fs::read_to_string(wiki_file(&project)).unwrap_or_default()
}
/// Öffnet ein Wiki-Ziel (Klick auf einen `[[…]]`-Link oder Suchtreffer):
/// `tag:x` → Schlagwort-Seite, `tag:` → Archiv-Übersicht, sonst
/// Dokument-Auflösung über den Index. Der `tag:`-Namensraum ist damit dort
@@ -450,3 +554,48 @@ pub fn close(window: &tauri::Window) {
let _ = s.killer.kill();
}
}
#[cfg(test)]
mod tests {
use super::{replace_todo_line, todo_line};
#[test]
fn todo_zeile_baut_und_prueft() {
let line = todo_line("i1", 100, " Aufgabe ", Some("Notiz"), Some("2026-07-24")).unwrap();
let rec: serde_json::Value = serde_json::from_str(&line).unwrap();
assert_eq!(rec["id"], "i1");
assert_eq!(rec["ts"], 100);
assert_eq!(rec["text"], "Aufgabe");
assert_eq!(rec["note"], "Notiz");
assert_eq!(rec["due"], "2026-07-24");
// Ohne note/due fehlen die Felder.
let rec: serde_json::Value =
serde_json::from_str(&todo_line("i1", 100, "x", None, None).unwrap()).unwrap();
assert!(rec.get("note").is_none());
assert!(rec.get("due").is_none());
assert!(todo_line("i1", 100, " ", None, None).is_err());
assert!(todo_line("i1", 100, "x", None, Some("24.07.2026")).is_err());
}
#[test]
fn update_ersetzt_nur_die_passende_zeile() {
let raw = concat!(
r#"{"id":"a","ts":100,"text":"alt","note":"n"}"#, "\n",
r#"{"id":"b","ts":200,"text":"bleibt"}"#, "\n",
);
let out = replace_todo_line(raw, "a", "neu", None, Some("2026-07-24")).unwrap();
let lines: Vec<serde_json::Value> =
out.lines().map(|l| serde_json::from_str(l).unwrap()).collect();
// ID und ts bleiben, note ist weg, due ist neu.
assert_eq!(lines[0]["id"], "a");
assert_eq!(lines[0]["ts"], 100);
assert_eq!(lines[0]["text"], "neu");
assert!(lines[0].get("note").is_none());
assert_eq!(lines[0]["due"], "2026-07-24");
assert_eq!(lines[1]["text"], "bleibt");
assert!(replace_todo_line(raw, "fehlt", "x", None, None).is_err());
}
}