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:
+7
-6
@@ -27,8 +27,6 @@ fn main() {
|
||||
"set_terminal_config",
|
||||
"project_icon",
|
||||
"pool_label",
|
||||
"todo_state",
|
||||
"set_todo",
|
||||
"usage_stats",
|
||||
"stop_project",
|
||||
"restart_project",
|
||||
@@ -56,14 +54,17 @@ fn main() {
|
||||
"term_log",
|
||||
"term_write",
|
||||
"term_resize",
|
||||
"panel_read",
|
||||
"commands_read",
|
||||
"buffer_read",
|
||||
"enabled_modules",
|
||||
"module_registry",
|
||||
"set_module",
|
||||
"commands_delete",
|
||||
"todos_delete",
|
||||
"todos_add",
|
||||
"todos_update",
|
||||
"panel_set",
|
||||
"search_read",
|
||||
"search_run",
|
||||
"panel_load",
|
||||
"wiki_read",
|
||||
"wiki_open",
|
||||
"open_panel_window",
|
||||
])),
|
||||
|
||||
@@ -38,8 +38,6 @@
|
||||
"allow-unassign-pool",
|
||||
"allow-set-terminal-config",
|
||||
"allow-project-icon",
|
||||
"allow-todo-state",
|
||||
"allow-set-todo",
|
||||
"allow-usage-stats",
|
||||
"allow-stop-project",
|
||||
"allow-restart-project",
|
||||
@@ -56,6 +54,8 @@
|
||||
"allow-archive-docs-cmd",
|
||||
"allow-delete-preview",
|
||||
"allow-delete-project-scoped",
|
||||
"allow-change-archive-home-cmd"
|
||||
"allow-change-archive-home-cmd",
|
||||
"allow-module-registry",
|
||||
"allow-set-module"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -20,20 +20,21 @@
|
||||
"autostart:default",
|
||||
"dialog:default",
|
||||
"clipboard-manager:allow-write-text",
|
||||
"allow-buffer-read",
|
||||
"allow-commands-delete",
|
||||
"allow-commands-read",
|
||||
"allow-enabled-modules",
|
||||
"allow-list-projects",
|
||||
"allow-panel-archive-cmd",
|
||||
"allow-panel-archive-dir-cmd",
|
||||
"allow-panel-load",
|
||||
"allow-panel-read",
|
||||
"allow-panel-set",
|
||||
"allow-reveal-path-cmd",
|
||||
"allow-search-read",
|
||||
"allow-search-run",
|
||||
"allow-spellcheck-lang",
|
||||
"allow-term-log",
|
||||
"allow-wiki-open",
|
||||
"allow-wiki-read"
|
||||
"allow-todos-add",
|
||||
"allow-todos-delete",
|
||||
"allow-todos-update",
|
||||
"allow-wiki-open"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -20,19 +20,18 @@
|
||||
"autostart:default",
|
||||
"dialog:default",
|
||||
"clipboard-manager:allow-write-text",
|
||||
"allow-buffer-read",
|
||||
"allow-commands-delete",
|
||||
"allow-commands-read",
|
||||
"allow-enabled-modules",
|
||||
"allow-list-projects",
|
||||
"allow-open-panel-window",
|
||||
"allow-panel-archive-cmd",
|
||||
"allow-panel-archive-dir-cmd",
|
||||
"allow-panel-load",
|
||||
"allow-panel-read",
|
||||
"allow-panel-set",
|
||||
"allow-pool-label",
|
||||
"allow-project-icon",
|
||||
"allow-reveal-path-cmd",
|
||||
"allow-search-read",
|
||||
"allow-search-run",
|
||||
"allow-set-terminal-font-size",
|
||||
"allow-spellcheck-lang",
|
||||
@@ -41,7 +40,9 @@
|
||||
"allow-term-start",
|
||||
"allow-term-write",
|
||||
"allow-terminal-font-size",
|
||||
"allow-wiki-open",
|
||||
"allow-wiki-read"
|
||||
"allow-todos-add",
|
||||
"allow-todos-delete",
|
||||
"allow-todos-update",
|
||||
"allow-wiki-open"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-buffer-read"
|
||||
description = "Enables the buffer_read command without any pre-configured scope."
|
||||
commands.allow = ["buffer_read"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-buffer-read"
|
||||
description = "Denies the buffer_read command without any pre-configured scope."
|
||||
commands.deny = ["buffer_read"]
|
||||
@@ -0,0 +1,11 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-enabled-modules"
|
||||
description = "Enables the enabled_modules command without any pre-configured scope."
|
||||
commands.allow = ["enabled_modules"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-enabled-modules"
|
||||
description = "Denies the enabled_modules command without any pre-configured scope."
|
||||
commands.deny = ["enabled_modules"]
|
||||
@@ -0,0 +1,11 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-module-registry"
|
||||
description = "Enables the module_registry command without any pre-configured scope."
|
||||
commands.allow = ["module_registry"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-module-registry"
|
||||
description = "Denies the module_registry command without any pre-configured scope."
|
||||
commands.deny = ["module_registry"]
|
||||
@@ -0,0 +1,11 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-set-module"
|
||||
description = "Enables the set_module command without any pre-configured scope."
|
||||
commands.allow = ["set_module"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-set-module"
|
||||
description = "Denies the set_module command without any pre-configured scope."
|
||||
commands.deny = ["set_module"]
|
||||
@@ -0,0 +1,11 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-todos-add"
|
||||
description = "Enables the todos_add command without any pre-configured scope."
|
||||
commands.allow = ["todos_add"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-todos-add"
|
||||
description = "Denies the todos_add command without any pre-configured scope."
|
||||
commands.deny = ["todos_add"]
|
||||
@@ -0,0 +1,11 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-todos-delete"
|
||||
description = "Enables the todos_delete command without any pre-configured scope."
|
||||
commands.allow = ["todos_delete"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-todos-delete"
|
||||
description = "Denies the todos_delete command without any pre-configured scope."
|
||||
commands.deny = ["todos_delete"]
|
||||
@@ -0,0 +1,11 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-todos-update"
|
||||
description = "Enables the todos_update command without any pre-configured scope."
|
||||
commands.allow = ["todos_update"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-todos-update"
|
||||
description = "Denies the todos_update command without any pre-configured scope."
|
||||
commands.deny = ["todos_update"]
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 == "~" {
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
@@ -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 01–12, Tag 01–31.
|
||||
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
@@ -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