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:
+3
-11
@@ -6,14 +6,8 @@
|
||||
</head>
|
||||
<body>
|
||||
<div class="panel-topbar" data-tauri-drag-region>
|
||||
<div class="panel-tabs" id="panel-tabs">
|
||||
<button class="panel-btn" data-mode="commands" data-i18n-title="panel.tabCommandsTitle" data-i18n="panel.tabCommands">Befehle</button>
|
||||
<span class="tab-sep"></span>
|
||||
<button class="panel-btn" data-mode="draft" data-i18n-title="panel.tabDraftTitle" data-i18n="panel.tabDraft">Dokument</button>
|
||||
<button class="panel-btn" data-mode="wiki" data-i18n-title="panel.tabWikiTitle" data-i18n="panel.tabWiki">Wiki</button>
|
||||
<button class="panel-btn" data-mode="search" data-i18n-title="panel.tabSearchTitle" data-i18n="panel.tabSearch">Suche</button>
|
||||
<span class="tab-sep"></span>
|
||||
</div>
|
||||
<!-- Tabs kommen aus der Modul-Registry (panel-wiring). -->
|
||||
<div class="panel-tabs" id="panel-tabs"></div>
|
||||
<button class="winbtn" id="panel-dock" data-i18n-title="panel.dock">
|
||||
<svg width="14" height="14" viewBox="0 0 16 16"><rect x="1.5" y="2.5" width="13" height="11" rx="1.5" /><path d="M9.5 2.5v11" /><path d="M4 8h3.4M6 6.2 7.8 8 6 9.8" /></svg>
|
||||
</button>
|
||||
@@ -57,10 +51,8 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Modul-Container (<mode>-content) reihen sich hier ein. -->
|
||||
<div id="panel-content" class="md"></div>
|
||||
<div id="commands-content" hidden></div>
|
||||
<div id="search-content" hidden></div>
|
||||
<div id="wiki-content" hidden></div>
|
||||
<div id="resize-grips" aria-hidden="true">
|
||||
<div class="grip n" data-dir="North"></div>
|
||||
<div class="grip s" data-dir="South"></div>
|
||||
|
||||
+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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,18 +16,20 @@ function modeSetup() {
|
||||
<div id="sc" hidden></div>
|
||||
<div id="wc" hidden></div>`;
|
||||
const el = (id: string) => document.getElementById(id)!;
|
||||
const tab = (m: string) =>
|
||||
document.querySelector<HTMLElement>(`[data-mode="${m}"]`)!;
|
||||
const flush = vi.fn();
|
||||
const mode = initPanelMode({
|
||||
tabsEl: el("tabs"),
|
||||
tabs: [
|
||||
{ mode: "commands", btn: tab("commands"), content: el("cc"), label: "Befehle" },
|
||||
{ mode: "draft", btn: tab("draft"), content: null, label: "Dokument" },
|
||||
{ mode: "wiki", btn: tab("wiki"), content: el("wc"), label: "Wiki" },
|
||||
{ mode: "search", btn: tab("search"), content: el("sc"), label: "Suche" },
|
||||
],
|
||||
draftEls: [el("draft-el")],
|
||||
commandsContent: el("cc"),
|
||||
searchContent: el("sc"),
|
||||
wikiContent: el("wc"),
|
||||
titleEl: el("title"),
|
||||
flush,
|
||||
});
|
||||
const tab = (m: string) =>
|
||||
document.querySelector<HTMLElement>(`[data-mode="${m}"]`)!;
|
||||
return { mode, flush, el, tab };
|
||||
}
|
||||
|
||||
|
||||
+45
-85
@@ -7,13 +7,7 @@
|
||||
|
||||
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
|
||||
import { storedLocale, t } from "./messages";
|
||||
|
||||
|
||||
/// Entfernt Bidi- und Zero-Width-Steuerzeichen (U+200B–200F, U+202A–202E,
|
||||
/// U+2060–2064, U+2066–2069, U+FEFF) aus Befehlstexten.
|
||||
function stripInvisibles(s: string): string {
|
||||
return s.replace(/[\u200B-\u200F\u202A-\u202E\u2060-\u2064\u2066-\u2069\uFEFF]/g, "");
|
||||
}
|
||||
import { copyAction, deleteAction, flash, renderTile, stripInvisibles } from "./tiles";
|
||||
|
||||
interface CommandEntry {
|
||||
cmd: string;
|
||||
@@ -42,45 +36,6 @@ function fmtTime(ts: number): string {
|
||||
: `${d.toLocaleDateString(loc)} ${hm}`;
|
||||
}
|
||||
|
||||
/// Kurzes visuelles Feedback (copied/error) — der eine Flash-Helper fürs
|
||||
/// ganze Panel.
|
||||
export function flash(el: HTMLElement, cls: string, ms = 1200) {
|
||||
el.classList.add(cls);
|
||||
setTimeout(() => el.classList.remove(cls), ms);
|
||||
}
|
||||
|
||||
/// Sichtbare Fehlermeldung im Panel: kurz eingeblendete Zeile oben rechts.
|
||||
export function panelToast(msg: string) {
|
||||
const t = document.createElement("div");
|
||||
t.className = "panel-toast";
|
||||
t.textContent = msg;
|
||||
document.body.append(t);
|
||||
setTimeout(() => t.remove(), 5000);
|
||||
}
|
||||
|
||||
function copyBtn(text: () => string): HTMLButtonElement {
|
||||
const btn = document.createElement("button");
|
||||
btn.className = "panel-btn cmd-copy";
|
||||
btn.title = t("commands.copyOne");
|
||||
btn.innerHTML =
|
||||
'<svg width="14" height="14" viewBox="0 0 16 16"><rect x="5.5" y="5.5" width="8" height="8" rx="1.5" /><path d="M10.5 3.2V3A1.5 1.5 0 0 0 9 1.5H3A1.5 1.5 0 0 0 1.5 3v6A1.5 1.5 0 0 0 3 10.5h.2" /></svg>';
|
||||
btn.addEventListener("click", async () => {
|
||||
await writeText(text());
|
||||
flash(btn, "copied");
|
||||
});
|
||||
return btn;
|
||||
}
|
||||
|
||||
function deleteBtn(onClick: () => void): HTMLButtonElement {
|
||||
const btn = document.createElement("button");
|
||||
btn.className = "panel-btn cmd-del";
|
||||
btn.title = t("commands.removeOne");
|
||||
btn.innerHTML =
|
||||
'<svg width="14" height="14" viewBox="0 0 16 16"><path d="M2.5 4.5h11" /><path d="M5.5 4.5V3a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1v1.5" /><path d="M4 4.5l.7 8.6a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9l.7-8.6" /></svg>';
|
||||
btn.addEventListener("click", onClick);
|
||||
return btn;
|
||||
}
|
||||
|
||||
export function initCommandsView(
|
||||
container: HTMLElement,
|
||||
onDelete: (id: string) => void,
|
||||
@@ -126,26 +81,23 @@ export function initCommandsView(
|
||||
|
||||
cmds.forEach((entry) => {
|
||||
// Unsichtbare Steuerzeichen (Bidi, Zero-Width) aus Anzeige UND
|
||||
// Kopie halten — sonst sieht der Nutzer einen anderen Befehl, als
|
||||
// die Zwischenablage enthält. Der Lösch-Abgleich läuft weiter über
|
||||
// den Original-String.
|
||||
// Kopie halten (stripInvisibles); der Lösch-Abgleich läuft weiter
|
||||
// über den Original-String.
|
||||
const visible = stripInvisibles(entry.cmd);
|
||||
const tile = document.createElement("div");
|
||||
tile.className = "cmd-tile";
|
||||
const body = document.createElement("div");
|
||||
body.className = "cmd-body";
|
||||
const cmd = document.createElement("div");
|
||||
cmd.className = "cmd-text";
|
||||
cmd.textContent = visible;
|
||||
body.append(cmd);
|
||||
if (entry.note) {
|
||||
const note = document.createElement("div");
|
||||
note.className = "cmd-note";
|
||||
note.textContent = entry.note;
|
||||
body.append(note);
|
||||
}
|
||||
tile.append(body, copyBtn(() => visible), deleteBtn(() => onDelete(entry.id ?? "")));
|
||||
block.append(tile);
|
||||
block.append(
|
||||
renderTile({
|
||||
cls: "cmd-tile",
|
||||
bodyCls: "cmd-body",
|
||||
parts: [
|
||||
{ cls: "cmd-text", text: visible },
|
||||
...(entry.note ? [{ cls: "cmd-note", text: entry.note }] : []),
|
||||
],
|
||||
actions: [
|
||||
copyAction(t("commands.copyOne"), () => visible),
|
||||
deleteAction(t("commands.removeOne"), () => onDelete(entry.id ?? "")),
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
container.append(block);
|
||||
}
|
||||
@@ -165,40 +117,43 @@ export function initCommandsView(
|
||||
};
|
||||
}
|
||||
|
||||
export type PanelMode = "draft" | "commands" | "search" | "wiki";
|
||||
export type PanelMode = string;
|
||||
|
||||
const LABEL: { [m in PanelMode]: string } = {
|
||||
draft: t("panel.tabDraft"),
|
||||
commands: t("panel.tabCommands"),
|
||||
search: t("panel.tabSearch"),
|
||||
wiki: t("panel.tabWiki"),
|
||||
};
|
||||
/// Ein Tab im Modus-Umschalter; kommt aus der Modul-Registry (panel-wiring).
|
||||
export interface ModeTab {
|
||||
mode: PanelMode;
|
||||
btn: HTMLElement;
|
||||
/// Content-Container des Tabs; null für den Entwurfs-Tab, dessen
|
||||
/// Sichtbarkeit über draftEls läuft.
|
||||
content: HTMLElement | null;
|
||||
/// Titelzeilen-Text, wenn der Tab aktiv ist (der Entwurfs-Tab behält
|
||||
/// seinen Dokument-Titel).
|
||||
label: string;
|
||||
onActivate?: () => void;
|
||||
}
|
||||
|
||||
/// Vier Tabs Entwurf / Befehle / Suche / Wiki: blenden Entwurfs-Inhalt samt
|
||||
/// Modus-Umschalter über die Tabs der Registry: blendet Entwurfs-Inhalt samt
|
||||
/// zugehöriger Kopf-Controls gegen die jeweilige Ansicht aus. Ein Wechsel bei
|
||||
/// offener Inhalts-Bearbeitung speichert den Entwurf (flush) statt zu
|
||||
/// blockieren.
|
||||
export function initPanelMode(opts: {
|
||||
tabsEl: HTMLElement;
|
||||
tabs: ModeTab[];
|
||||
draftEls: HTMLElement[];
|
||||
commandsContent: HTMLElement;
|
||||
searchContent: HTMLElement;
|
||||
wikiContent: HTMLElement;
|
||||
titleEl: HTMLElement;
|
||||
flush: () => void;
|
||||
}) {
|
||||
// `null` = kein Tab aktiv (Panel zugeklappt).
|
||||
let mode: PanelMode | null = "draft";
|
||||
let draftTitle = "";
|
||||
const tabs = [...opts.tabsEl.querySelectorAll<HTMLElement>("[data-mode]")];
|
||||
|
||||
function apply() {
|
||||
opts.commandsContent.hidden = mode !== "commands";
|
||||
opts.searchContent.hidden = mode !== "search";
|
||||
opts.wikiContent.hidden = mode !== "wiki";
|
||||
for (const tab of opts.tabs) {
|
||||
if (tab.content) tab.content.hidden = mode !== tab.mode;
|
||||
tab.btn.classList.toggle("active", tab.mode === mode);
|
||||
}
|
||||
for (const el of opts.draftEls) el.hidden = mode !== "draft";
|
||||
for (const t of tabs) t.classList.toggle("active", t.dataset.mode === mode);
|
||||
if (mode && mode !== "draft") opts.titleEl.textContent = LABEL[mode];
|
||||
const active = opts.tabs.find((tab) => tab.mode === mode);
|
||||
if (mode && mode !== "draft" && active) opts.titleEl.textContent = active.label;
|
||||
}
|
||||
|
||||
function to(m: PanelMode) {
|
||||
@@ -215,10 +170,15 @@ export function initPanelMode(opts: {
|
||||
function clear() {
|
||||
if (mode === "draft") draftTitle = opts.titleEl.textContent || t("panel.tabDraft");
|
||||
mode = null;
|
||||
for (const t of tabs) t.classList.remove("active");
|
||||
for (const tab of opts.tabs) tab.btn.classList.remove("active");
|
||||
}
|
||||
|
||||
for (const t of tabs) t.addEventListener("click", () => to(t.dataset.mode as PanelMode));
|
||||
for (const tab of opts.tabs) {
|
||||
tab.btn.addEventListener("click", () => {
|
||||
to(tab.mode);
|
||||
tab.onActivate?.();
|
||||
});
|
||||
}
|
||||
apply();
|
||||
return { to, clear, current: () => mode };
|
||||
}
|
||||
|
||||
@@ -157,6 +157,12 @@ function contractHome(p: string): string {
|
||||
return p.replace(/^\/(?:home|Users)\/[^/]+/, "~");
|
||||
}
|
||||
|
||||
interface ModuleState {
|
||||
id: string;
|
||||
core: boolean;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
interface TerminalSettings {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -164,22 +170,24 @@ interface TerminalSettings {
|
||||
theme: string;
|
||||
icon: string | null;
|
||||
title: string;
|
||||
todo: boolean;
|
||||
workDirs: string[];
|
||||
archiveHome: string | null;
|
||||
modules: ModuleState[];
|
||||
}
|
||||
|
||||
const settings = ref<TerminalSettings | null>(null);
|
||||
|
||||
async function openSettings(p: Project) {
|
||||
try {
|
||||
const todo = await invoke<boolean>("todo_state", { project: p.id });
|
||||
const workDirs = await invoke<string[]>("project_work_dirs", {
|
||||
project: p.id,
|
||||
});
|
||||
const archiveHome = await invoke<string | null>("panel_archive_dir_cmd", {
|
||||
project: p.id,
|
||||
});
|
||||
const modules = await invoke<ModuleState[]>("module_registry", {
|
||||
project: p.id,
|
||||
});
|
||||
settings.value = {
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
@@ -187,15 +195,27 @@ async function openSettings(p: Project) {
|
||||
theme: p.terminal.theme ?? "mocha",
|
||||
icon: p.terminal.icon,
|
||||
title: p.terminal.title ?? "",
|
||||
todo,
|
||||
workDirs,
|
||||
archiveHome,
|
||||
modules,
|
||||
};
|
||||
} catch (e) {
|
||||
error.value = String(e);
|
||||
}
|
||||
}
|
||||
|
||||
// Modul-Schalter schreiben sofort in die Projekt-Config (nur Abweichungen
|
||||
// vom Default); ein Fehler stellt die Checkbox zurück.
|
||||
async function toggleModule(m: ModuleState) {
|
||||
const s = settings.value!;
|
||||
try {
|
||||
await invoke("set_module", { project: s.id, module: m.id, enabled: m.enabled });
|
||||
} catch (e) {
|
||||
m.enabled = !m.enabled;
|
||||
error.value = String(e);
|
||||
}
|
||||
}
|
||||
|
||||
// Projektordner neu zuordnen (Root, in dem die Session startet); verschoben
|
||||
// wird nichts — der gewählte Ordner ist der neue Ort des Projekts.
|
||||
async function changeProjectDir() {
|
||||
@@ -308,7 +328,6 @@ async function saveSettings() {
|
||||
icon: s.icon,
|
||||
title: title === "" ? null : title,
|
||||
});
|
||||
await invoke("set_todo", { project: s.id, enabled: s.todo });
|
||||
settings.value = null;
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
@@ -325,7 +344,6 @@ interface Wizard {
|
||||
workDir: string | null;
|
||||
title: string;
|
||||
theme: string;
|
||||
todo: boolean;
|
||||
}
|
||||
|
||||
const wizard = ref<Wizard | null>(null);
|
||||
@@ -341,7 +359,6 @@ function openWizard() {
|
||||
workDir: null,
|
||||
title: "",
|
||||
theme: "mocha",
|
||||
todo: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -396,7 +413,6 @@ async function createProject() {
|
||||
icon: null,
|
||||
title: title === "" ? null : title,
|
||||
},
|
||||
todo: w.todo,
|
||||
});
|
||||
wizard.value = null;
|
||||
await refresh();
|
||||
@@ -410,7 +426,6 @@ interface DeletePreview {
|
||||
projectDir: string;
|
||||
aiControlDir: boolean;
|
||||
archivePermission: boolean;
|
||||
todoHook: boolean;
|
||||
panelFiles: number;
|
||||
archiveHome: string | null;
|
||||
archiveDocs: number;
|
||||
@@ -619,13 +634,6 @@ onUnmounted(() => window.clearInterval(timer));
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
{{ $t("projects.todo") }}
|
||||
<span class="checkline">
|
||||
<input v-model="wizard.todo" type="checkbox" />
|
||||
{{ $t("projects.todoDesc") }}
|
||||
</span>
|
||||
</label>
|
||||
<p class="hint">{{ $t("projects.wizardHint") }}</p>
|
||||
<div class="actions">
|
||||
<button type="button" @click="wizard = null">
|
||||
@@ -693,7 +701,6 @@ onUnmounted(() => window.clearInterval(timer));
|
||||
<li>{{ $t("projects.artRegistry") }}</li>
|
||||
<li v-if="pendingDelete.preview.aiControlDir">{{ $t("projects.artAiControl") }}</li>
|
||||
<li v-if="pendingDelete.preview.archivePermission">{{ $t("projects.artArchivePerm") }}</li>
|
||||
<li v-if="pendingDelete.preview.todoHook">{{ $t("projects.artTodoHook") }}</li>
|
||||
<li v-if="pendingDelete.preview.panelFiles">
|
||||
{{ $t("projects.artPanelFiles", pendingDelete.preview.panelFiles) }}
|
||||
</li>
|
||||
@@ -801,32 +808,36 @@ onUnmounted(() => window.clearInterval(timer));
|
||||
<button @click="addWorkDir">{{ $t("projects.addWorkDir") }}</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="srow">
|
||||
<span class="slbl">{{ $t("projects.archive") }}</span>
|
||||
<span class="sval">
|
||||
<span v-if="settings.archiveHome" class="spath hover-pop">
|
||||
{{ contractHome(settings.archiveHome) }}
|
||||
<span class="pop pop-path">{{ settings.archiveHome }}</span>
|
||||
</span>
|
||||
<span v-else class="spath muted">{{ $t("projects.archiveNone") }}</span>
|
||||
</span>
|
||||
<span class="sacts">
|
||||
<button @click="chooseArchive">{{ $t("projects.changeDir") }}</button>
|
||||
<button v-if="settings.archiveHome" @click="clearArchive">
|
||||
{{ $t("projects.remove") }}
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<p class="ghint">{{ $t("projects.writesImmediately") }}</p>
|
||||
</section>
|
||||
|
||||
<section class="sgroup">
|
||||
<h4 class="eyebrow">{{ $t("projects.groupSession") }}</h4>
|
||||
<label class="checkline todo-line">
|
||||
<input v-model="settings.todo" type="checkbox" />
|
||||
<span>{{ $t("projects.todo") }} — {{ $t("projects.todoDesc") }}</span>
|
||||
</label>
|
||||
<section class="sgroup instant">
|
||||
<h4 class="eyebrow">{{ $t("projects.groupModules") }}</h4>
|
||||
<template v-for="m in settings.modules.filter((x) => !x.core)" :key="m.id">
|
||||
<label class="checkline">
|
||||
<input v-model="m.enabled" type="checkbox" @change="toggleModule(m)" />
|
||||
<span>{{ $t(`modules.${m.id}`) }} — {{ $t(`modules.${m.id}Desc`) }}</span>
|
||||
</label>
|
||||
<div v-if="m.id === 'archive'" class="srow">
|
||||
<span class="slbl">{{ $t("projects.archive") }}</span>
|
||||
<span class="sval">
|
||||
<span v-if="settings.archiveHome" class="spath hover-pop">
|
||||
{{ contractHome(settings.archiveHome) }}
|
||||
<span class="pop pop-path">{{ settings.archiveHome }}</span>
|
||||
</span>
|
||||
<span v-else class="spath muted">{{ $t("projects.archiveNone") }}</span>
|
||||
</span>
|
||||
<span class="sacts">
|
||||
<button @click="chooseArchive">{{ $t("projects.changeDir") }}</button>
|
||||
<button v-if="settings.archiveHome" @click="clearArchive">
|
||||
{{ $t("projects.remove") }}
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<p class="ghint">{{ $t("projects.writesImmediately") }}</p>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
@@ -1016,12 +1027,6 @@ onUnmounted(() => window.clearInterval(timer));
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* Todo-Zeile: volle Dialogbreite, der Text wird nie gekürzt. */
|
||||
.todo-line {
|
||||
font-size: 0.85rem;
|
||||
color: var(--subtext);
|
||||
}
|
||||
|
||||
.spath {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.75rem;
|
||||
|
||||
@@ -35,6 +35,7 @@ describe("Nachrichtentabellen", () => {
|
||||
"Name",
|
||||
"Theme",
|
||||
"Wiki",
|
||||
"ToDo",
|
||||
"MD",
|
||||
"Autostart",
|
||||
"Input",
|
||||
@@ -44,14 +45,13 @@ describe("Nachrichtentabellen", () => {
|
||||
"Cache ↓",
|
||||
"+ oAuth",
|
||||
"+ apiKey",
|
||||
"+ ToDo",
|
||||
"System",
|
||||
"Terminal",
|
||||
"Terminal — {name}",
|
||||
".desktop-Starter",
|
||||
".ai-control/ (Config + Icon)",
|
||||
"Archiv-Berechtigung in .claude/settings.json",
|
||||
"Todo-Hook in .claude/settings.json",
|
||||
"OFFENE-PUNKTE.md bei jedem Sessionstart einspielen",
|
||||
]);
|
||||
const gleich = keys(messages.de).filter((k) => {
|
||||
const de = pick(messages.de, k);
|
||||
|
||||
+48
-8
@@ -90,14 +90,11 @@ const de = {
|
||||
artRegistry: "Registry-Eintrag",
|
||||
artAiControl: ".ai-control/ (Config + Icon)",
|
||||
artArchivePerm: "Archiv-Berechtigung in .claude/settings.json",
|
||||
artTodoHook: "Todo-Hook in .claude/settings.json",
|
||||
artPanelFiles: "Panel-Kanaldatei | {count} Panel-Kanaldateien",
|
||||
artDesktop: ".desktop-Starter",
|
||||
artArchive: "Archiv {path}",
|
||||
artProjectDir: "Projektordner {path}",
|
||||
artWorkDir: "Arbeitsordner {path}",
|
||||
todo: "Todoliste",
|
||||
todoDesc: "OFFENE-PUNKTE.md bei jedem Sessionstart einspielen",
|
||||
archiveChangeTitle: "Archiv wechseln",
|
||||
archiveChangeText: "Das Archiv wechselt von {old} nach {neu}.",
|
||||
archiveMigrate: "Dokumente ins neue Archiv verschieben",
|
||||
@@ -106,9 +103,30 @@ const de = {
|
||||
archiveChangeConfirm: "Wechseln",
|
||||
groupAppearance: "Darstellung",
|
||||
groupFolders: "Ordner",
|
||||
groupSession: "Session",
|
||||
groupModules: "Module",
|
||||
writesImmediately: "Änderungen hier schreiben sofort — ohne Speichern.",
|
||||
},
|
||||
modules: {
|
||||
commands: "Befehle",
|
||||
commandsDesc: "Befehls-Kacheln im Panel (write_commands)",
|
||||
todo: "ToDo-Liste",
|
||||
todoDesc: "persistente Aufgaben-Kacheln mit Fälligkeits-Ampel; öffnet beim Sessionstart",
|
||||
archive: "Archiv",
|
||||
archiveDesc: "Archivieren, Wiki und Suche",
|
||||
},
|
||||
todos: {
|
||||
copyOne: "Aufgabe kopieren",
|
||||
editOne: "Aufgabe bearbeiten",
|
||||
removeOne: "Aufgabe entfernen",
|
||||
add: "+ ToDo",
|
||||
addTitle: "Neue Aufgabe anlegen",
|
||||
formText: "Aufgabe",
|
||||
formNote: "Notiz",
|
||||
formDue: "Fälligkeit",
|
||||
formCreate: "Anlegen",
|
||||
formSave: "Speichern",
|
||||
formCancel: "Abbrechen",
|
||||
},
|
||||
pools: {
|
||||
pool: "Pool",
|
||||
type: "Typ",
|
||||
@@ -164,6 +182,8 @@ const de = {
|
||||
windowProjects: "Projekte",
|
||||
tabCommands: "Befehle",
|
||||
tabCommandsTitle: "Befehls-History",
|
||||
tabTodo: "ToDo",
|
||||
tabTodoTitle: "ToDo-Liste des Projekts",
|
||||
tabDraft: "Dokument",
|
||||
tabDraftTitle: "Dokument",
|
||||
tabWiki: "Wiki",
|
||||
@@ -315,14 +335,11 @@ const en: typeof de = {
|
||||
artRegistry: "registry entry",
|
||||
artAiControl: ".ai-control/ (config + icon)",
|
||||
artArchivePerm: "archive permission in .claude/settings.json",
|
||||
artTodoHook: "todo hook in .claude/settings.json",
|
||||
artPanelFiles: "panel channel file | {count} panel channel files",
|
||||
artDesktop: ".desktop launcher",
|
||||
artArchive: "archive {path}",
|
||||
artProjectDir: "project folder {path}",
|
||||
artWorkDir: "working folder {path}",
|
||||
todo: "Todo list",
|
||||
todoDesc: "Inject OFFENE-PUNKTE.md at every session start",
|
||||
archiveChangeTitle: "Change archive",
|
||||
archiveChangeText: "The archive changes from {old} to {neu}.",
|
||||
archiveMigrate: "Move documents to the new archive",
|
||||
@@ -331,9 +348,30 @@ const en: typeof de = {
|
||||
archiveChangeConfirm: "Change",
|
||||
groupAppearance: "Appearance",
|
||||
groupFolders: "Folders",
|
||||
groupSession: "Session",
|
||||
groupModules: "Modules",
|
||||
writesImmediately: "Changes here are written immediately — no save step.",
|
||||
},
|
||||
modules: {
|
||||
commands: "Commands",
|
||||
commandsDesc: "command tiles in the panel (write_commands)",
|
||||
todo: "ToDo list",
|
||||
todoDesc: "persistent task tiles with due-date signal; opens at session start",
|
||||
archive: "Archive",
|
||||
archiveDesc: "archiving, wiki and search",
|
||||
},
|
||||
todos: {
|
||||
copyOne: "Copy task",
|
||||
editOne: "Edit task",
|
||||
removeOne: "Remove task",
|
||||
add: "+ ToDo",
|
||||
addTitle: "Add a task",
|
||||
formText: "Task",
|
||||
formNote: "Note",
|
||||
formDue: "Due date",
|
||||
formCreate: "Add",
|
||||
formSave: "Save",
|
||||
formCancel: "Cancel",
|
||||
},
|
||||
pools: {
|
||||
pool: "Pool",
|
||||
type: "Type",
|
||||
@@ -389,6 +427,8 @@ const en: typeof de = {
|
||||
windowProjects: "Projects",
|
||||
tabCommands: "Commands",
|
||||
tabCommandsTitle: "Command history",
|
||||
tabTodo: "ToDo",
|
||||
tabTodoTitle: "Project to-do list",
|
||||
tabDraft: "Document",
|
||||
tabDraftTitle: "Document",
|
||||
tabWiki: "Wiki",
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { initSearchView } from "../search-view";
|
||||
import { initWikiView } from "../wiki-view";
|
||||
import type { PanelTab } from "./index";
|
||||
|
||||
export const wikiTab: PanelTab = {
|
||||
mode: "wiki",
|
||||
module: "archive",
|
||||
buffer: "wiki",
|
||||
labelKey: "panel.tabWiki",
|
||||
titleKey: "panel.tabWikiTitle",
|
||||
init: (container, ctx) => initWikiView(container, ctx.openWiki),
|
||||
// Wiki-Tab bei leerem Puffer (Session-Start): Übersicht direkt laden.
|
||||
onActivate: (view, ctx) => {
|
||||
if (view.empty()) ctx.openWiki("tag:");
|
||||
},
|
||||
};
|
||||
|
||||
export const searchTab: PanelTab = {
|
||||
mode: "search",
|
||||
module: "archive",
|
||||
buffer: "search",
|
||||
labelKey: "panel.tabSearch",
|
||||
titleKey: "panel.tabSearchTitle",
|
||||
sepAfter: true,
|
||||
// Treffer-Klick lädt das Dokument in den Dokument-Tab (dort editier- und
|
||||
// archivierbar); der Sprung ins Wiki geht von dort aus.
|
||||
init: (container, ctx) =>
|
||||
initSearchView(container, ctx.openDoc, (raw) => {
|
||||
// `#tag`-Tokens filtern aufs Schlagwort, der Rest ist die Volltext-Query.
|
||||
const words = raw.split(/\s+/).filter(Boolean);
|
||||
const tag = words.find((w) => w.startsWith("#"))?.slice(1) ?? null;
|
||||
const query = words.filter((w) => !w.startsWith("#")).join(" ");
|
||||
void invoke("search_run", { project: ctx.project, query, tag }).catch((e) =>
|
||||
ctx.toast(`Suche fehlgeschlagen: ${e}`),
|
||||
);
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { initCommandsView } from "../commands-view";
|
||||
import type { PanelTab } from "./index";
|
||||
|
||||
export const commandsTab: PanelTab = {
|
||||
mode: "commands",
|
||||
module: "commands",
|
||||
buffer: "commands",
|
||||
labelKey: "panel.tabCommands",
|
||||
titleKey: "panel.tabCommandsTitle",
|
||||
sepAfter: true,
|
||||
init: (container, ctx) =>
|
||||
initCommandsView(container, (id) =>
|
||||
invoke("commands_delete", { project: ctx.project, id }),
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
/// Modul-Registry des Panels: welche Tabs es gibt, zu welchem Backend-Modul
|
||||
/// sie gehören und wie ihre Ansicht entsteht. panel-wiring baut Tab-Knöpfe
|
||||
/// und Content-Container aus dieser Liste; welche Tabs erscheinen,
|
||||
/// entscheidet `enabled_modules` (Backend-Registry in domain/modules.rs).
|
||||
/// Puffer-Konvention: Erstbefüllung per `buffer_read`, Updates kommen als
|
||||
/// `<buffer>-update`-Event.
|
||||
|
||||
import { commandsTab } from "./commands";
|
||||
import { searchTab, wikiTab } from "./archive";
|
||||
import { todoTab } from "./todo";
|
||||
|
||||
export interface ModuleView {
|
||||
set(text: string): void;
|
||||
empty(): boolean;
|
||||
}
|
||||
|
||||
/// Gemeinsame Dienste für Modul-Ansichten — alles, was mehr als ein Modul
|
||||
/// braucht; Modulspezifisches (eigene Invokes) macht der Deskriptor selbst.
|
||||
export interface ModuleCtx {
|
||||
project: string;
|
||||
toast(msg: string): void;
|
||||
/// Dokument in den Entwurfs-Tab laden (Treffer-Klick).
|
||||
openDoc(path: string): void;
|
||||
/// Wiki-Ziel öffnen (Wikilink, Chip, `tag:`-Namensraum).
|
||||
openWiki(name: string): void;
|
||||
}
|
||||
|
||||
export interface PanelTab {
|
||||
/// data-mode des Tab-Knopfs und ID-Präfix des Containers (`<mode>-content`,
|
||||
/// zugleich CSS-Anker der Ansicht).
|
||||
mode: string;
|
||||
/// Backend-Modul (enabled_modules) — bestimmt, ob der Tab erscheint.
|
||||
module: string;
|
||||
/// Puffer-ID für buffer_read; Updates als `<buffer>-update`.
|
||||
buffer: string;
|
||||
labelKey: string;
|
||||
titleKey: string;
|
||||
/// Visueller Trenner hinter dem Tab.
|
||||
sepAfter?: boolean;
|
||||
/// Baut die Ansicht des Tabs. Ohne init verdrahtet panel-wiring die
|
||||
/// Ansicht selbst (Entwurfs-Tab — Kern mit eigenem Markup).
|
||||
init?(container: HTMLElement, ctx: ModuleCtx): ModuleView;
|
||||
/// Nach Tab-Klick (z. B. Wiki: leere Ansicht lädt die Übersicht).
|
||||
onActivate?(view: ModuleView, ctx: ModuleCtx): void;
|
||||
}
|
||||
|
||||
export const PANEL_TABS: PanelTab[] = [
|
||||
todoTab,
|
||||
commandsTab,
|
||||
{
|
||||
mode: "draft",
|
||||
module: "draft",
|
||||
buffer: "panel",
|
||||
labelKey: "panel.tabDraft",
|
||||
titleKey: "panel.tabDraftTitle",
|
||||
},
|
||||
wikiTab,
|
||||
searchTab,
|
||||
];
|
||||
@@ -0,0 +1,29 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { initTodoView, type TodoEdit } from "../todo-view";
|
||||
import type { PanelTab } from "./index";
|
||||
|
||||
export const todoTab: PanelTab = {
|
||||
mode: "todo",
|
||||
module: "todo",
|
||||
buffer: "todos",
|
||||
labelKey: "panel.tabTodo",
|
||||
titleKey: "panel.tabTodoTitle",
|
||||
init: (container, ctx) =>
|
||||
initTodoView(
|
||||
container,
|
||||
(id) => invoke("todos_delete", { project: ctx.project, id }),
|
||||
(todo: TodoEdit) => {
|
||||
const args = {
|
||||
project: ctx.project,
|
||||
text: todo.text,
|
||||
note: todo.note ?? null,
|
||||
due: todo.due ?? null,
|
||||
};
|
||||
void (
|
||||
todo.id
|
||||
? invoke("todos_update", { ...args, id: todo.id })
|
||||
: invoke("todos_add", args)
|
||||
).catch((e) => ctx.toast(String(e)));
|
||||
},
|
||||
),
|
||||
};
|
||||
@@ -92,6 +92,143 @@
|
||||
system-ui,
|
||||
sans-serif;
|
||||
}
|
||||
/* ToDo-Kacheln: Layout wie Befehls-Kacheln (cmd-tile/cmd-body), eigener
|
||||
Text-Stil und Fälligkeits-Ampel. */
|
||||
#todo-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
#todo-content::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
#todo-content::-webkit-scrollbar-thumb {
|
||||
background: var(--line);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.todo-text {
|
||||
font:
|
||||
400 12px/1.45 -apple-system,
|
||||
system-ui,
|
||||
sans-serif;
|
||||
word-break: break-word;
|
||||
user-select: text;
|
||||
-webkit-user-select: text;
|
||||
cursor: text;
|
||||
}
|
||||
.todo-due {
|
||||
display: inline-block;
|
||||
margin-top: 5px;
|
||||
border-radius: 999px;
|
||||
padding: 2px 8px;
|
||||
font-family: "JetBrains Mono", Menlo, monospace;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.todo-due.overdue {
|
||||
color: var(--err);
|
||||
border: 1px solid var(--err);
|
||||
}
|
||||
.todo-due.soon {
|
||||
color: var(--warn);
|
||||
border: 1px solid var(--warn);
|
||||
}
|
||||
.todo-due.later {
|
||||
color: var(--ok);
|
||||
border: 1px solid var(--ok);
|
||||
}
|
||||
/* Kopfzeile mit Anlegen-Knopf und das ToDo-Formular (Anlegen/Bearbeiten),
|
||||
in-flow über der Liste; Input-Stil wie im Archiv-Formular. */
|
||||
.todo-head {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.todo-add {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font:
|
||||
500 11px/1 -apple-system,
|
||||
system-ui,
|
||||
sans-serif;
|
||||
border-radius: 6px;
|
||||
padding: 4px 6px;
|
||||
}
|
||||
.todo-add:hover {
|
||||
background: var(--line);
|
||||
color: var(--text);
|
||||
}
|
||||
.todo-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
background: var(--tile);
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.todo-form[hidden] {
|
||||
display: none;
|
||||
}
|
||||
.todo-form input {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
padding: 6px 10px;
|
||||
color: var(--text);
|
||||
font:
|
||||
400 12px/1.4 -apple-system,
|
||||
system-ui,
|
||||
sans-serif;
|
||||
outline: none;
|
||||
}
|
||||
.todo-form input:focus {
|
||||
border-color: var(--line-strong);
|
||||
}
|
||||
.todo-form input::placeholder {
|
||||
color: var(--faint);
|
||||
}
|
||||
.todo-form input[type="date"] {
|
||||
color-scheme: dark;
|
||||
}
|
||||
.todo-form-btns {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.todo-form-cancel {
|
||||
border: 1px solid var(--line);
|
||||
background: none;
|
||||
color: var(--text);
|
||||
border-radius: 6px;
|
||||
padding: 6px 10px;
|
||||
font:
|
||||
500 12px/1.4 -apple-system,
|
||||
system-ui,
|
||||
sans-serif;
|
||||
}
|
||||
.todo-form-cancel:hover {
|
||||
background: var(--line);
|
||||
}
|
||||
.todo-form-submit {
|
||||
border: none;
|
||||
background: var(--line);
|
||||
color: var(--text);
|
||||
border-radius: 6px;
|
||||
padding: 6px 10px;
|
||||
font:
|
||||
500 12px/1.4 -apple-system,
|
||||
system-ui,
|
||||
sans-serif;
|
||||
}
|
||||
.todo-form-submit:hover {
|
||||
background: var(--line-strong);
|
||||
}
|
||||
|
||||
#search-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { renderMarkdown } from "./markdown";
|
||||
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
|
||||
import { flash } from "./commands-view";
|
||||
import { flash } from "./tiles";
|
||||
import { t } from "./messages";
|
||||
|
||||
export interface PanelView {
|
||||
|
||||
+85
-84
@@ -1,26 +1,24 @@
|
||||
/// Gemeinsame Panel-Verdrahtung für das angedockte Panel (terminal.ts) und
|
||||
/// das abgelöste Fenster (panel.ts): Entwurfs-Ansicht, Befehls- und
|
||||
/// Such-Kacheln, Modus-Umschalter, Wikilink-Klick und die drei Update-Events.
|
||||
/// Die Element-IDs sind in beiden HTML-Dateien identisch; die Draft-Controls
|
||||
/// trägt das Markup als `.draft-only`.
|
||||
/// das abgelöste Fenster (panel.ts): die Entwurfs-Ansicht als Kern, alle
|
||||
/// weiteren Tabs aus der Modul-Registry (src/modules) — Tab-Knöpfe und
|
||||
/// Content-Container entstehen hier aus der Liste der aktiven Module,
|
||||
/// Erstbefüllung per buffer_read, Updates über `<buffer>-update`-Events.
|
||||
/// Die Element-IDs des Entwurfs-Markups sind in beiden HTML-Dateien
|
||||
/// identisch; die Draft-Controls trägt das Markup als `.draft-only`.
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { initPanelView, type PanelView } from "./panel-view";
|
||||
import {
|
||||
initCommandsView,
|
||||
initPanelMode,
|
||||
panelToast,
|
||||
type CommandsView,
|
||||
type PanelMode,
|
||||
} from "./commands-view";
|
||||
import { initSearchView } from "./search-view";
|
||||
import { initWikiView } from "./wiki-view";
|
||||
import { initPanelMode, type ModeTab, type PanelMode } from "./commands-view";
|
||||
import { panelToast } from "./tiles";
|
||||
import { PANEL_TABS, type ModuleCtx, type ModuleView } from "./modules";
|
||||
import { t } from "./messages";
|
||||
import "./panel-tiles.css";
|
||||
|
||||
export interface PanelWiring {
|
||||
view: PanelView;
|
||||
cmdView: CommandsView;
|
||||
/// Modul-Ansichten nach Tab-Modus; enthält nur Tabs aktiver Module.
|
||||
views: Map<string, ModuleView>;
|
||||
mode: { to(m: PanelMode): void; clear(): void; current(): PanelMode | null };
|
||||
/// Entwurfstext beim Start (für die Anfangs-Modus-Entscheidung).
|
||||
draft: string;
|
||||
@@ -31,24 +29,17 @@ export async function wirePanel(
|
||||
onIncoming?: () => void,
|
||||
): Promise<PanelWiring> {
|
||||
const titleEl = document.querySelector(".panel-title") as HTMLElement;
|
||||
const [defaultLang, draft, cmds, search, wiki, archiveHome] = await Promise.all([
|
||||
const [enabled, defaultLang, draft] = await Promise.all([
|
||||
invoke<string[]>("enabled_modules", { project }),
|
||||
invoke<string>("spellcheck_lang"),
|
||||
invoke<string>("panel_read", { project }),
|
||||
invoke<string>("commands_read", { project }),
|
||||
invoke<string>("search_read", { project }),
|
||||
invoke<string>("wiki_read", { project }),
|
||||
invoke<string | null>("panel_archive_dir_cmd", { project }),
|
||||
invoke<string>("buffer_read", { project, buffer: "panel" }),
|
||||
]);
|
||||
const tabs = PANEL_TABS.filter((tab) => enabled.includes(tab.module));
|
||||
|
||||
// Ohne Archiv (in den Projekt-Einstellungen abgewählt) gibt es nur Befehle
|
||||
// und Dokument — Wiki-/Suche-Tabs und die Archiv-Werkzeuge verschwinden.
|
||||
if (!archiveHome) {
|
||||
for (const sel of [
|
||||
'#panel-tabs [data-mode="wiki"]',
|
||||
'#panel-tabs [data-mode="search"]',
|
||||
"#panel-archive",
|
||||
"#panel-wiki-jump",
|
||||
]) {
|
||||
// Ohne Archiv-Modul (abgewählt oder kein Archiv-Home) verschwinden auch
|
||||
// die Archiv-Werkzeuge des Entwurfs-Tabs.
|
||||
if (!enabled.includes("archive")) {
|
||||
for (const sel of ["#panel-archive", "#panel-wiki-jump"]) {
|
||||
const el = document.querySelector<HTMLElement>(sel)!;
|
||||
el.hidden = true;
|
||||
// Raus aus der draft-only-Menge, sonst blendet der Modus-Umschalter
|
||||
@@ -61,7 +52,15 @@ export async function wirePanel(
|
||||
// den Kern; das Ergebnis kommt über den Wiki-Puffer und `wiki-update` zurück.
|
||||
// Fehler (z. B. Ziel nicht im Archiv) erscheinen als Toast.
|
||||
const openWiki = (name: string) =>
|
||||
invoke("wiki_open", { project, name }).catch((e) => panelToast(String(e)));
|
||||
void invoke("wiki_open", { project, name }).catch((e) => panelToast(String(e)));
|
||||
|
||||
const ctx: ModuleCtx = {
|
||||
project,
|
||||
toast: panelToast,
|
||||
openDoc: (path) =>
|
||||
void invoke("panel_load", { project, path }).catch((e) => panelToast(String(e))),
|
||||
openWiki,
|
||||
};
|
||||
|
||||
const view = initPanelView({
|
||||
content: document.getElementById("panel-content")!,
|
||||
@@ -75,57 +74,61 @@ export async function wirePanel(
|
||||
onCommit: (text) => invoke("panel_set", { project, text }),
|
||||
onWikiLink: openWiki,
|
||||
});
|
||||
const cmdView = initCommandsView(
|
||||
document.getElementById("commands-content")!,
|
||||
(id) => invoke("commands_delete", { project, id }),
|
||||
);
|
||||
// Treffer-Klick lädt das Dokument in den Dokument-Tab (dort editier- und
|
||||
// archivierbar); der Sprung ins Wiki geht von dort aus.
|
||||
const searchView = initSearchView(
|
||||
document.getElementById("search-content")!,
|
||||
(path) =>
|
||||
void invoke("panel_load", { project, path }).catch((e) => panelToast(String(e))),
|
||||
(raw) => {
|
||||
// `#tag`-Tokens filtern aufs Schlagwort, der Rest ist die Volltext-Query.
|
||||
const words = raw.split(/\s+/).filter(Boolean);
|
||||
const tag = words.find((w) => w.startsWith("#"))?.slice(1) ?? null;
|
||||
const query = words.filter((w) => !w.startsWith("#")).join(" ");
|
||||
void invoke("search_run", { project, query, tag }).catch((e) =>
|
||||
panelToast(`Suche fehlgeschlagen: ${e}`),
|
||||
);
|
||||
},
|
||||
);
|
||||
const wikiView = initWikiView(document.getElementById("wiki-content")!, openWiki);
|
||||
|
||||
// Tab-Leiste und Container aus der Registry. Die Container-IDs
|
||||
// (`<mode>-content`) sind zugleich die CSS-Anker der Ansichten; die
|
||||
// Container reihen sich hinter #panel-content ein.
|
||||
const tabsEl = document.getElementById("panel-tabs")!;
|
||||
const views = new Map<string, ModuleView>();
|
||||
const modeTabs: ModeTab[] = [];
|
||||
let anchor = document.getElementById("panel-content")!;
|
||||
for (const tab of tabs) {
|
||||
const btn = document.createElement("button");
|
||||
btn.className = "panel-btn";
|
||||
btn.dataset.mode = tab.mode;
|
||||
btn.textContent = t(tab.labelKey);
|
||||
btn.title = t(tab.titleKey);
|
||||
tabsEl.append(btn);
|
||||
if (tab.sepAfter) {
|
||||
const sep = document.createElement("span");
|
||||
sep.className = "tab-sep";
|
||||
tabsEl.append(sep);
|
||||
}
|
||||
let content: HTMLElement | null = null;
|
||||
if (tab.init) {
|
||||
content = document.createElement("div");
|
||||
content.id = `${tab.mode}-content`;
|
||||
content.hidden = true;
|
||||
anchor.after(content);
|
||||
anchor = content;
|
||||
views.set(tab.mode, tab.init(content, ctx));
|
||||
}
|
||||
modeTabs.push({
|
||||
mode: tab.mode,
|
||||
btn,
|
||||
content,
|
||||
label: t(tab.labelKey),
|
||||
onActivate: tab.onActivate
|
||||
? () => tab.onActivate!(views.get(tab.mode)!, ctx)
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
const mode = initPanelMode({
|
||||
tabsEl: document.getElementById("panel-tabs")!,
|
||||
tabs: modeTabs,
|
||||
draftEls: [
|
||||
document.getElementById("panel-content")!,
|
||||
...document.querySelectorAll<HTMLElement>(".draft-only"),
|
||||
],
|
||||
commandsContent: document.getElementById("commands-content")!,
|
||||
searchContent: document.getElementById("search-content")!,
|
||||
wikiContent: document.getElementById("wiki-content")!,
|
||||
titleEl,
|
||||
flush: () => void view.flush(),
|
||||
});
|
||||
|
||||
view.set(draft);
|
||||
cmdView.set(cmds);
|
||||
searchView.set(search);
|
||||
wikiView.set(wiki);
|
||||
|
||||
// Wiki-Tab bei leerem Puffer (Session-Start): Übersicht direkt laden.
|
||||
document
|
||||
.querySelector<HTMLElement>('#panel-tabs [data-mode="wiki"]')!
|
||||
.addEventListener("click", () => {
|
||||
if (wikiView.empty()) void openWiki("tag:");
|
||||
});
|
||||
|
||||
// Sprung Dokument → Wiki: löst den angezeigten Titel gegen das Archiv auf.
|
||||
document
|
||||
.getElementById("panel-wiki-jump")!
|
||||
.addEventListener("click", () => void openWiki(titleEl.textContent || ""));
|
||||
.addEventListener("click", () => openWiki(titleEl.textContent || ""));
|
||||
|
||||
view.set(draft);
|
||||
await listen<string>("panel-update", (e) => {
|
||||
// Erst umschalten, dann setzen: to("draft") restauriert den gemerkten
|
||||
// Titel — der neue Inhalt (und damit sein Titel) muss danach kommen.
|
||||
@@ -133,21 +136,19 @@ export async function wirePanel(
|
||||
view.set(e.payload);
|
||||
onIncoming?.();
|
||||
});
|
||||
await listen<string>("commands-update", (e) => {
|
||||
cmdView.set(e.payload);
|
||||
mode.to("commands");
|
||||
onIncoming?.();
|
||||
});
|
||||
await listen<string>("search-update", (e) => {
|
||||
searchView.set(e.payload);
|
||||
mode.to("search");
|
||||
onIncoming?.();
|
||||
});
|
||||
await listen<string>("wiki-update", (e) => {
|
||||
wikiView.set(e.payload);
|
||||
mode.to("wiki");
|
||||
onIncoming?.();
|
||||
});
|
||||
await Promise.all(
|
||||
tabs
|
||||
.filter((tab) => tab.init)
|
||||
.map(async (tab) => {
|
||||
const v = views.get(tab.mode)!;
|
||||
v.set(await invoke<string>("buffer_read", { project, buffer: tab.buffer }));
|
||||
await listen<string>(`${tab.buffer}-update`, (e) => {
|
||||
v.set(e.payload);
|
||||
mode.to(tab.mode);
|
||||
onIncoming?.();
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
return { view, cmdView, mode, draft };
|
||||
return { view, views, mode, draft };
|
||||
}
|
||||
|
||||
+10
-3
@@ -6,7 +6,7 @@ import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
|
||||
import { PhysicalPosition, PhysicalSize } from "@tauri-apps/api/dpi";
|
||||
import { emit } from "@tauri-apps/api/event";
|
||||
import { wirePanel } from "./panel-wiring";
|
||||
import { flash, panelToast } from "./commands-view";
|
||||
import { flash, panelToast } from "./tiles";
|
||||
import { initArchiveForm } from "./archive-form";
|
||||
import { applyTheme, THEMES } from "./themes";
|
||||
import { applyI18n, t } from "./messages";
|
||||
@@ -99,8 +99,15 @@ applyTheme(picked);
|
||||
document.documentElement.style.background = picked.header;
|
||||
document.body.style.background = picked.header;
|
||||
|
||||
const { view, cmdView, mode, draft } = await wirePanel(project);
|
||||
if (!draft.trim() && !cmdView.empty()) mode.to("commands");
|
||||
const { view, views, mode, draft } = await wirePanel(project);
|
||||
// Ohne Entwurf startet das Fenster in der ToDo-Liste (falls gewählt und
|
||||
// nicht leer), sonst in den Befehlen.
|
||||
const todos = views.get("todo");
|
||||
const cmds = views.get("commands");
|
||||
if (!draft.trim()) {
|
||||
if (todos && !todos.empty()) mode.to("todo");
|
||||
else if (cmds && !cmds.empty()) mode.to("commands");
|
||||
}
|
||||
|
||||
// Archivieren: wie im angedockten Panel — Formular aufklappen, Abschicken
|
||||
// wählt notfalls erst das Archiv-Home per Dialog.
|
||||
|
||||
+12
-11
@@ -5,6 +5,7 @@
|
||||
/// Split in <mark>-Elemente übersetzt.
|
||||
|
||||
import { t } from "./messages";
|
||||
import { renderTile } from "./tiles";
|
||||
|
||||
interface Hit {
|
||||
relpath: string;
|
||||
@@ -99,17 +100,17 @@ export function initSearchView(
|
||||
: t("search.noHits", { scope });
|
||||
results.append(head);
|
||||
for (const hit of run.hits) {
|
||||
const tile = document.createElement("div");
|
||||
tile.className = "hit-tile";
|
||||
const title = document.createElement("div");
|
||||
title.className = "hit-title";
|
||||
title.textContent = hit.title;
|
||||
const path = document.createElement("div");
|
||||
path.className = "hit-path";
|
||||
path.textContent = hit.relpath;
|
||||
tile.append(title, snippetEl(hit.snippet), path);
|
||||
tile.addEventListener("click", () => onOpen(`${run.home}/${hit.relpath}`));
|
||||
results.append(tile);
|
||||
results.append(
|
||||
renderTile({
|
||||
cls: "hit-tile",
|
||||
parts: [
|
||||
{ cls: "hit-title", text: hit.title },
|
||||
snippetEl(hit.snippet),
|
||||
{ cls: "hit-path", text: hit.relpath },
|
||||
],
|
||||
onClick: () => onOpen(`${run.home}/${hit.relpath}`),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+12
-4
@@ -15,7 +15,7 @@ import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { wirePanel } from "./panel-wiring";
|
||||
import { applyTheme, THEMES } from "./themes";
|
||||
import { flash, panelToast } from "./commands-view";
|
||||
import { flash, panelToast } from "./tiles";
|
||||
import { initArchiveForm } from "./archive-form";
|
||||
import { applyI18n, t } from "./messages";
|
||||
|
||||
@@ -212,12 +212,20 @@ function hidePanel() {
|
||||
|
||||
// Gemeinsame Verdrahtung (Views, Modi, Update-Events); jedes eingehende
|
||||
// Update blendet das angedockte Panel ein, solange es nicht abgelöst ist.
|
||||
const { view, mode } = await wirePanel(project, () => {
|
||||
const { view, views, mode } = await wirePanel(project, () => {
|
||||
hasContent = true;
|
||||
if (!detached) showPanel();
|
||||
});
|
||||
// Panel startet zugeklappt — kein Tab aktiv, bis eine Ansicht geöffnet wird.
|
||||
mode.clear();
|
||||
// Panel startet zugeklappt — außer das ToDo-Modul ist gewählt und die Liste
|
||||
// nicht leer: dann öffnet das Panel beim Start mit der ToDo-Ansicht.
|
||||
const todos = views.get("todo");
|
||||
if (todos && !todos.empty()) {
|
||||
hasContent = true;
|
||||
showPanel();
|
||||
mode.to("todo");
|
||||
} else {
|
||||
mode.clear();
|
||||
}
|
||||
|
||||
// Tab-Klick im Header öffnet das (angedockte) Panel, falls es zu ist.
|
||||
const headerTabs = document.getElementById("panel-tabs")!;
|
||||
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
/// Gemeinsamer Kachel-Baustein der Panel-Ansichten (Befehle, Suchtreffer,
|
||||
/// künftig ToDo): deklarativer Tile-Renderer plus die geteilten Aktions-
|
||||
/// Knöpfe und Feedback-Helfer. DOM entsteht per createElement — Inhalte sind
|
||||
/// Fremdtext und gehen nie durch innerHTML (die SVG-Icons der Aktionen sind
|
||||
/// eigene Literale).
|
||||
|
||||
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
|
||||
|
||||
/// Entfernt Bidi- und Zero-Width-Steuerzeichen (U+200B–200F, U+202A–202E,
|
||||
/// U+2060–2064, U+2066–2069, U+FEFF) aus Fremdtexten — sonst sieht der
|
||||
/// Nutzer einen anderen Text, als die Zwischenablage enthält.
|
||||
export function stripInvisibles(s: string): string {
|
||||
return s.replace(/[\u200B-\u200F\u202A-\u202E\u2060-\u2064\u2066-\u2069\uFEFF]/g, "");
|
||||
}
|
||||
|
||||
/// Kurzes visuelles Feedback (copied/error) — der eine Flash-Helper fürs
|
||||
/// ganze Panel.
|
||||
export function flash(el: HTMLElement, cls: string, ms = 1200) {
|
||||
el.classList.add(cls);
|
||||
setTimeout(() => el.classList.remove(cls), ms);
|
||||
}
|
||||
|
||||
/// Sichtbare Fehlermeldung im Panel: kurz eingeblendete Zeile oben rechts.
|
||||
export function panelToast(msg: string) {
|
||||
const t = document.createElement("div");
|
||||
t.className = "panel-toast";
|
||||
t.textContent = msg;
|
||||
document.body.append(t);
|
||||
setTimeout(() => t.remove(), 5000);
|
||||
}
|
||||
|
||||
/// Kopier-Knopf: legt `text()` in die Zwischenablage, quittiert mit Flash.
|
||||
export function copyAction(title: string, text: () => string): HTMLButtonElement {
|
||||
const btn = document.createElement("button");
|
||||
btn.className = "panel-btn cmd-copy";
|
||||
btn.title = title;
|
||||
btn.innerHTML =
|
||||
'<svg width="14" height="14" viewBox="0 0 16 16"><rect x="5.5" y="5.5" width="8" height="8" rx="1.5" /><path d="M10.5 3.2V3A1.5 1.5 0 0 0 9 1.5H3A1.5 1.5 0 0 0 1.5 3v6A1.5 1.5 0 0 0 3 10.5h.2" /></svg>';
|
||||
btn.addEventListener("click", async () => {
|
||||
await writeText(text());
|
||||
flash(btn, "copied");
|
||||
});
|
||||
return btn;
|
||||
}
|
||||
|
||||
export function editAction(title: string, onClick: () => void): HTMLButtonElement {
|
||||
const btn = document.createElement("button");
|
||||
btn.className = "panel-btn cmd-edit";
|
||||
btn.title = title;
|
||||
btn.innerHTML =
|
||||
'<svg width="14" height="14" viewBox="0 0 16 16"><path d="M11.1 2.4a1.4 1.4 0 0 1 2 2l-8 8-2.9.9.9-2.9z" /><path d="M9.9 3.6l2 2" /></svg>';
|
||||
btn.addEventListener("click", onClick);
|
||||
return btn;
|
||||
}
|
||||
|
||||
export function deleteAction(title: string, onClick: () => void): HTMLButtonElement {
|
||||
const btn = document.createElement("button");
|
||||
btn.className = "panel-btn cmd-del";
|
||||
btn.title = title;
|
||||
btn.innerHTML =
|
||||
'<svg width="14" height="14" viewBox="0 0 16 16"><path d="M2.5 4.5h11" /><path d="M5.5 4.5V3a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1v1.5" /><path d="M4 4.5l.7 8.6a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9l.7-8.6" /></svg>';
|
||||
btn.addEventListener("click", onClick);
|
||||
return btn;
|
||||
}
|
||||
|
||||
export interface TileSpec {
|
||||
cls: string;
|
||||
/// Inhalts-Zeilen in Reihenfolge; fertige Elemente (z. B. Snippet mit
|
||||
/// <mark>-Hervorhebung) gehen als Element durch.
|
||||
parts: (HTMLElement | { cls: string; text: string })[];
|
||||
/// Umschließt die parts als eigener Container (Layout neben den actions),
|
||||
/// z. B. "cmd-body".
|
||||
bodyCls?: string;
|
||||
/// Aktions-Knöpfe hinter dem Body (copyAction/deleteAction).
|
||||
actions?: HTMLElement[];
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export function renderTile(spec: TileSpec): HTMLElement {
|
||||
const tile = document.createElement("div");
|
||||
tile.className = spec.cls;
|
||||
let host = tile;
|
||||
if (spec.bodyCls) {
|
||||
host = document.createElement("div");
|
||||
host.className = spec.bodyCls;
|
||||
tile.append(host);
|
||||
}
|
||||
for (const part of spec.parts) {
|
||||
if (part instanceof HTMLElement) {
|
||||
host.append(part);
|
||||
continue;
|
||||
}
|
||||
const div = document.createElement("div");
|
||||
div.className = part.cls;
|
||||
div.textContent = part.text;
|
||||
host.append(div);
|
||||
}
|
||||
if (spec.actions) tile.append(...spec.actions);
|
||||
if (spec.onClick) tile.addEventListener("click", spec.onClick);
|
||||
return tile;
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
// Kachel-Ansicht der ToDo-Liste: Sortierung, Ampel-Badge, Löschen per ID,
|
||||
// Formular für Anlegen und Bearbeiten.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { initTodoView } from "./todo-view";
|
||||
|
||||
function iso(offsetDays: number): string {
|
||||
const d = new Date(2026, 6, 22 + offsetDays); // Basis 2026-07-22 (fake time)
|
||||
const p = (n: number) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
|
||||
}
|
||||
|
||||
describe("initTodoView", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date(2026, 6, 22, 12, 0, 0));
|
||||
document.body.innerHTML = `<div id="c"></div>`;
|
||||
});
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
const jsonl = [
|
||||
JSON.stringify({ id: "a", ts: 100, text: "ohne Datum alt" }),
|
||||
JSON.stringify({ id: "b", ts: 200, text: "ohne Datum neu" }),
|
||||
JSON.stringify({ id: "c", ts: 150, text: "überfällig", due: iso(-1) }),
|
||||
JSON.stringify({ id: "d", ts: 150, text: "bald", note: "mit Notiz", due: iso(2) }),
|
||||
JSON.stringify({ id: "e", ts: 150, text: "später", due: iso(10) }),
|
||||
].join("\n");
|
||||
|
||||
it("sortiert fällige zuerst und hängt Ampel-Badges an", () => {
|
||||
const view = initTodoView(document.getElementById("c")!, () => {}, () => {});
|
||||
view.set(jsonl);
|
||||
expect(view.empty()).toBe(false);
|
||||
const texts = [...document.querySelectorAll(".todo-text")].map(
|
||||
(el) => el.textContent,
|
||||
);
|
||||
// due aufsteigend, danach ohne Datum neueste oben.
|
||||
expect(texts).toEqual([
|
||||
"überfällig",
|
||||
"bald",
|
||||
"später",
|
||||
"ohne Datum neu",
|
||||
"ohne Datum alt",
|
||||
]);
|
||||
const badges = [...document.querySelectorAll(".todo-due")];
|
||||
expect(badges.map((b) => b.className)).toEqual([
|
||||
"todo-due overdue",
|
||||
"todo-due soon",
|
||||
"todo-due later",
|
||||
]);
|
||||
// Datum nach Locale (Testsprache de).
|
||||
expect(badges[0].textContent).toBe(new Date(2026, 6, 21).toLocaleDateString("de"));
|
||||
expect(document.querySelector(".cmd-note")!.textContent).toBe("mit Notiz");
|
||||
});
|
||||
|
||||
it("meldet Löschen mit der Eintrags-ID", () => {
|
||||
const onDelete = vi.fn();
|
||||
const view = initTodoView(document.getElementById("c")!, onDelete, () => {});
|
||||
view.set(jsonl);
|
||||
const dels = document.querySelectorAll<HTMLElement>(".cmd-del");
|
||||
dels[0].click(); // erste Kachel = überfälliges ToDo (id "c")
|
||||
expect(onDelete).toHaveBeenCalledWith("c");
|
||||
});
|
||||
|
||||
it("leerer Puffer meldet empty", () => {
|
||||
const view = initTodoView(document.getElementById("c")!, () => {}, () => {});
|
||||
view.set("");
|
||||
expect(view.empty()).toBe(true);
|
||||
expect(document.querySelectorAll(".cmd-tile").length).toBe(0);
|
||||
});
|
||||
|
||||
it("Plus-Knopf öffnet das leere Formular und meldet Anlegen ohne ID", () => {
|
||||
const onSave = vi.fn();
|
||||
initTodoView(document.getElementById("c")!, () => {}, onSave);
|
||||
const form = document.querySelector<HTMLElement>(".todo-form")!;
|
||||
expect(form.hidden).toBe(true);
|
||||
document.querySelector<HTMLElement>(".todo-add")!.click();
|
||||
expect(form.hidden).toBe(false);
|
||||
const [text, note, due] = form.querySelectorAll("input");
|
||||
text.value = " neu ";
|
||||
note.value = "";
|
||||
due.value = iso(1);
|
||||
form.querySelector<HTMLElement>(".todo-form-submit")!.click();
|
||||
// Text geht ungetrimmt raus (trimmt das Backend), leere Notiz entfällt.
|
||||
expect(onSave).toHaveBeenCalledWith({
|
||||
id: undefined,
|
||||
text: " neu ",
|
||||
note: undefined,
|
||||
due: iso(1),
|
||||
});
|
||||
expect(form.hidden).toBe(true);
|
||||
});
|
||||
|
||||
it("Stift öffnet das Formular vorbefüllt und meldet Speichern mit ID", () => {
|
||||
const onSave = vi.fn();
|
||||
const view = initTodoView(document.getElementById("c")!, () => {}, onSave);
|
||||
view.set(jsonl);
|
||||
const form = document.querySelector<HTMLElement>(".todo-form")!;
|
||||
// zweite Kachel = "bald" (id "d") mit Notiz und Fälligkeit.
|
||||
document.querySelectorAll<HTMLElement>(".cmd-edit")[1].click();
|
||||
const [text, note, due] = form.querySelectorAll("input");
|
||||
expect([text.value, note.value, due.value]).toEqual([
|
||||
"bald",
|
||||
"mit Notiz",
|
||||
iso(2),
|
||||
]);
|
||||
text.value = "bald geändert";
|
||||
form.querySelector<HTMLElement>(".todo-form-submit")!.click();
|
||||
expect(onSave).toHaveBeenCalledWith({
|
||||
id: "d",
|
||||
text: "bald geändert",
|
||||
note: "mit Notiz",
|
||||
due: iso(2),
|
||||
});
|
||||
});
|
||||
|
||||
it("Abbrechen schließt das Formular ohne zu melden", () => {
|
||||
const onSave = vi.fn();
|
||||
initTodoView(document.getElementById("c")!, () => {}, onSave);
|
||||
document.querySelector<HTMLElement>(".todo-add")!.click();
|
||||
const form = document.querySelector<HTMLElement>(".todo-form")!;
|
||||
form.querySelector<HTMLElement>(".todo-form-cancel")!.click();
|
||||
expect(form.hidden).toBe(true);
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("Escape schließt das Formular ohne zu melden", () => {
|
||||
const onSave = vi.fn();
|
||||
initTodoView(document.getElementById("c")!, () => {}, onSave);
|
||||
document.querySelector<HTMLElement>(".todo-add")!.click();
|
||||
const form = document.querySelector<HTMLElement>(".todo-form")!;
|
||||
form.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
|
||||
expect(form.hidden).toBe(true);
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
/// Kachel-Ansicht der persistenten ToDo-Liste: rendert die JSONL-Datei
|
||||
/// (write_todos im MCP-Server) als Kacheln mit Kopier-, Bearbeitungs- und
|
||||
/// Löschfunktion — Löschen geht als onDelete(id), Anlegen und Bearbeiten als
|
||||
/// onSave(todo) an den Aufrufer, der neue Stand kommt über den Watcher
|
||||
/// zurück. Der Plus-Knopf über der Liste öffnet das Formular leer, der Stift
|
||||
/// auf der Kachel vorbefüllt (mit ID). Fälligkeit als Ampel-Badge (überfällig
|
||||
/// rot, in den nächsten zwei Tagen gelb, sonst grün), Datum formatiert nach
|
||||
/// Locale. Sortierung: fällige ToDos zuerst (aufsteigend), der Rest neueste
|
||||
/// oben.
|
||||
|
||||
import { storedLocale, t } from "./messages";
|
||||
import {
|
||||
copyAction,
|
||||
deleteAction,
|
||||
editAction,
|
||||
renderTile,
|
||||
stripInvisibles,
|
||||
} from "./tiles";
|
||||
|
||||
interface Todo {
|
||||
id?: string;
|
||||
ts: number;
|
||||
text: string;
|
||||
note?: string;
|
||||
/// Fälligkeit als YYYY-MM-DD (lokales Datum).
|
||||
due?: string;
|
||||
}
|
||||
|
||||
/// Formular-Ergebnis: mit ID bearbeiten (todos_update), ohne ID anlegen
|
||||
/// (todos_add).
|
||||
export interface TodoEdit {
|
||||
id?: string;
|
||||
text: string;
|
||||
note?: string;
|
||||
due?: string;
|
||||
}
|
||||
|
||||
export interface TodoView {
|
||||
set(text: string): void;
|
||||
empty(): boolean;
|
||||
}
|
||||
|
||||
function dueDate(due: string): Date {
|
||||
const [y, m, d] = due.split("-").map(Number);
|
||||
return new Date(y, m - 1, d);
|
||||
}
|
||||
|
||||
/// Ampel-Klasse zur Fälligkeit.
|
||||
function dueClass(due: string): string {
|
||||
const now = new Date();
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const days = Math.round((dueDate(due).getTime() - today.getTime()) / 86_400_000);
|
||||
return days < 0 ? "overdue" : days <= 2 ? "soon" : "later";
|
||||
}
|
||||
|
||||
export function initTodoView(
|
||||
container: HTMLElement,
|
||||
onDelete: (id: string) => void,
|
||||
onSave: (todo: TodoEdit) => void,
|
||||
): TodoView {
|
||||
let count = 0;
|
||||
|
||||
const head = document.createElement("div");
|
||||
head.className = "todo-head";
|
||||
const addBtn = document.createElement("button");
|
||||
addBtn.className = "todo-add";
|
||||
addBtn.textContent = t("todos.add");
|
||||
addBtn.title = t("todos.addTitle");
|
||||
head.append(addBtn);
|
||||
|
||||
const form = document.createElement("div");
|
||||
form.className = "todo-form";
|
||||
form.hidden = true;
|
||||
const text = document.createElement("input");
|
||||
text.type = "text";
|
||||
text.placeholder = t("todos.formText");
|
||||
const note = document.createElement("input");
|
||||
note.type = "text";
|
||||
note.placeholder = t("todos.formNote");
|
||||
const due = document.createElement("input");
|
||||
due.type = "date";
|
||||
due.title = t("todos.formDue");
|
||||
const cancel = document.createElement("button");
|
||||
cancel.className = "todo-form-cancel";
|
||||
cancel.textContent = t("todos.formCancel");
|
||||
const submit = document.createElement("button");
|
||||
submit.className = "todo-form-submit";
|
||||
const btns = document.createElement("div");
|
||||
btns.className = "todo-form-btns";
|
||||
btns.append(cancel, submit);
|
||||
form.append(text, note, due, btns);
|
||||
|
||||
const list = document.createElement("div");
|
||||
container.append(head, form, list);
|
||||
|
||||
/// ID des ToDos im Formular; undefined = Anlegen.
|
||||
let editing: string | undefined;
|
||||
const close = () => {
|
||||
form.hidden = true;
|
||||
};
|
||||
function open(todo?: Todo) {
|
||||
editing = todo?.id;
|
||||
text.value = todo?.text ?? "";
|
||||
note.value = todo?.note ?? "";
|
||||
due.value = todo?.due ?? "";
|
||||
submit.textContent = t(todo ? "todos.formSave" : "todos.formCreate");
|
||||
form.hidden = false;
|
||||
text.focus();
|
||||
}
|
||||
const fire = () => {
|
||||
onSave({
|
||||
id: editing,
|
||||
text: text.value,
|
||||
note: note.value.trim() || undefined,
|
||||
due: due.value || undefined,
|
||||
});
|
||||
close();
|
||||
};
|
||||
submit.addEventListener("click", fire);
|
||||
cancel.addEventListener("click", close);
|
||||
form.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
fire();
|
||||
} else if (e.key === "Escape") {
|
||||
close();
|
||||
}
|
||||
});
|
||||
addBtn.addEventListener("click", () => (form.hidden ? open() : close()));
|
||||
|
||||
function render(todos: Todo[]) {
|
||||
list.textContent = "";
|
||||
const sorted = [...todos].sort((a, b) => {
|
||||
if (a.due && b.due)
|
||||
return a.due < b.due ? -1 : a.due > b.due ? 1 : b.ts - a.ts;
|
||||
if (a.due || b.due) return a.due ? -1 : 1;
|
||||
return b.ts - a.ts;
|
||||
});
|
||||
for (const todo of sorted) {
|
||||
const visible = stripInvisibles(todo.text);
|
||||
list.append(
|
||||
renderTile({
|
||||
cls: "cmd-tile",
|
||||
bodyCls: "cmd-body",
|
||||
parts: [
|
||||
{ cls: "todo-text", text: visible },
|
||||
...(todo.note ? [{ cls: "cmd-note", text: todo.note }] : []),
|
||||
...(todo.due
|
||||
? [
|
||||
{
|
||||
cls: `todo-due ${dueClass(todo.due)}`,
|
||||
text: dueDate(todo.due).toLocaleDateString(storedLocale()),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
actions: [
|
||||
copyAction(t("todos.copyOne"), () => visible),
|
||||
editAction(t("todos.editOne"), () => open(todo)),
|
||||
deleteAction(t("todos.removeOne"), () => onDelete(todo.id ?? "")),
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
set(text: string) {
|
||||
const todos: Todo[] = [];
|
||||
for (const line of text.split("\n")) {
|
||||
if (!line.trim()) continue;
|
||||
todos.push(JSON.parse(line));
|
||||
}
|
||||
count = todos.length;
|
||||
render(todos);
|
||||
},
|
||||
empty: () => count === 0,
|
||||
};
|
||||
}
|
||||
+3
-11
@@ -8,14 +8,8 @@
|
||||
<header data-tauri-drag-region>
|
||||
<img id="project-icon" hidden data-tauri-drag-region />
|
||||
<span id="project-name" data-tauri-drag-region></span>
|
||||
<div class="panel-tabs" id="panel-tabs">
|
||||
<button class="panel-btn" data-mode="commands" data-i18n-title="panel.tabCommandsTitle" data-i18n="panel.tabCommands">Befehle</button>
|
||||
<span class="tab-sep"></span>
|
||||
<button class="panel-btn" data-mode="draft" data-i18n-title="panel.tabDraftTitle" data-i18n="panel.tabDraft">Dokument</button>
|
||||
<button class="panel-btn" data-mode="wiki" data-i18n-title="panel.tabWikiTitle" data-i18n="panel.tabWiki">Wiki</button>
|
||||
<button class="panel-btn" data-mode="search" data-i18n-title="panel.tabSearchTitle" data-i18n="panel.tabSearch">Suche</button>
|
||||
<span class="tab-sep"></span>
|
||||
</div>
|
||||
<!-- Tabs kommen aus der Modul-Registry (panel-wiring). -->
|
||||
<div class="panel-tabs" id="panel-tabs"></div>
|
||||
<span id="pool"></span>
|
||||
<div id="winbtns">
|
||||
<button class="winbtn" id="win-min" data-i18n-aria="panel.minimize">
|
||||
@@ -69,10 +63,8 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Modul-Container (<mode>-content) reihen sich hier ein. -->
|
||||
<div id="panel-content" class="md"></div>
|
||||
<div id="commands-content" hidden></div>
|
||||
<div id="search-content" hidden></div>
|
||||
<div id="wiki-content" hidden></div>
|
||||
</aside>
|
||||
</div>
|
||||
<div id="resize-grips" aria-hidden="true">
|
||||
|
||||
Reference in New Issue
Block a user