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:
+204
-55
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user