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

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