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
+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());
}
}