Modul-System (Registry Backend+Frontend, generierte Tabs, Settings-Sektion); ToDo-Modul: persistente Liste mit Anlegen/Bearbeiten im Panel (todos_add/todos_update, Formular mit Abbrechen); alte Hook-Todoliste (OFFENE-PUNKTE.md) komplett entfernt
This commit is contained in:
+3
-11
@@ -6,14 +6,8 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="panel-topbar" data-tauri-drag-region>
|
<div class="panel-topbar" data-tauri-drag-region>
|
||||||
<div class="panel-tabs" id="panel-tabs">
|
<!-- Tabs kommen aus der Modul-Registry (panel-wiring). -->
|
||||||
<button class="panel-btn" data-mode="commands" data-i18n-title="panel.tabCommandsTitle" data-i18n="panel.tabCommands">Befehle</button>
|
<div class="panel-tabs" id="panel-tabs"></div>
|
||||||
<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>
|
|
||||||
<button class="winbtn" id="panel-dock" data-i18n-title="panel.dock">
|
<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>
|
<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>
|
</button>
|
||||||
@@ -57,10 +51,8 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Modul-Container (<mode>-content) reihen sich hier ein. -->
|
||||||
<div id="panel-content" class="md"></div>
|
<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 id="resize-grips" aria-hidden="true">
|
||||||
<div class="grip n" data-dir="North"></div>
|
<div class="grip n" data-dir="North"></div>
|
||||||
<div class="grip s" data-dir="South"></div>
|
<div class="grip s" data-dir="South"></div>
|
||||||
|
|||||||
+7
-6
@@ -27,8 +27,6 @@ fn main() {
|
|||||||
"set_terminal_config",
|
"set_terminal_config",
|
||||||
"project_icon",
|
"project_icon",
|
||||||
"pool_label",
|
"pool_label",
|
||||||
"todo_state",
|
|
||||||
"set_todo",
|
|
||||||
"usage_stats",
|
"usage_stats",
|
||||||
"stop_project",
|
"stop_project",
|
||||||
"restart_project",
|
"restart_project",
|
||||||
@@ -56,14 +54,17 @@ fn main() {
|
|||||||
"term_log",
|
"term_log",
|
||||||
"term_write",
|
"term_write",
|
||||||
"term_resize",
|
"term_resize",
|
||||||
"panel_read",
|
"buffer_read",
|
||||||
"commands_read",
|
"enabled_modules",
|
||||||
|
"module_registry",
|
||||||
|
"set_module",
|
||||||
"commands_delete",
|
"commands_delete",
|
||||||
|
"todos_delete",
|
||||||
|
"todos_add",
|
||||||
|
"todos_update",
|
||||||
"panel_set",
|
"panel_set",
|
||||||
"search_read",
|
|
||||||
"search_run",
|
"search_run",
|
||||||
"panel_load",
|
"panel_load",
|
||||||
"wiki_read",
|
|
||||||
"wiki_open",
|
"wiki_open",
|
||||||
"open_panel_window",
|
"open_panel_window",
|
||||||
])),
|
])),
|
||||||
|
|||||||
@@ -38,8 +38,6 @@
|
|||||||
"allow-unassign-pool",
|
"allow-unassign-pool",
|
||||||
"allow-set-terminal-config",
|
"allow-set-terminal-config",
|
||||||
"allow-project-icon",
|
"allow-project-icon",
|
||||||
"allow-todo-state",
|
|
||||||
"allow-set-todo",
|
|
||||||
"allow-usage-stats",
|
"allow-usage-stats",
|
||||||
"allow-stop-project",
|
"allow-stop-project",
|
||||||
"allow-restart-project",
|
"allow-restart-project",
|
||||||
@@ -56,6 +54,8 @@
|
|||||||
"allow-archive-docs-cmd",
|
"allow-archive-docs-cmd",
|
||||||
"allow-delete-preview",
|
"allow-delete-preview",
|
||||||
"allow-delete-project-scoped",
|
"allow-delete-project-scoped",
|
||||||
"allow-change-archive-home-cmd"
|
"allow-change-archive-home-cmd",
|
||||||
|
"allow-module-registry",
|
||||||
|
"allow-set-module"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,20 +20,21 @@
|
|||||||
"autostart:default",
|
"autostart:default",
|
||||||
"dialog:default",
|
"dialog:default",
|
||||||
"clipboard-manager:allow-write-text",
|
"clipboard-manager:allow-write-text",
|
||||||
|
"allow-buffer-read",
|
||||||
"allow-commands-delete",
|
"allow-commands-delete",
|
||||||
"allow-commands-read",
|
"allow-enabled-modules",
|
||||||
"allow-list-projects",
|
"allow-list-projects",
|
||||||
"allow-panel-archive-cmd",
|
"allow-panel-archive-cmd",
|
||||||
"allow-panel-archive-dir-cmd",
|
"allow-panel-archive-dir-cmd",
|
||||||
"allow-panel-load",
|
"allow-panel-load",
|
||||||
"allow-panel-read",
|
|
||||||
"allow-panel-set",
|
"allow-panel-set",
|
||||||
"allow-reveal-path-cmd",
|
"allow-reveal-path-cmd",
|
||||||
"allow-search-read",
|
|
||||||
"allow-search-run",
|
"allow-search-run",
|
||||||
"allow-spellcheck-lang",
|
"allow-spellcheck-lang",
|
||||||
"allow-term-log",
|
"allow-term-log",
|
||||||
"allow-wiki-open",
|
"allow-todos-add",
|
||||||
"allow-wiki-read"
|
"allow-todos-delete",
|
||||||
|
"allow-todos-update",
|
||||||
|
"allow-wiki-open"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,19 +20,18 @@
|
|||||||
"autostart:default",
|
"autostart:default",
|
||||||
"dialog:default",
|
"dialog:default",
|
||||||
"clipboard-manager:allow-write-text",
|
"clipboard-manager:allow-write-text",
|
||||||
|
"allow-buffer-read",
|
||||||
"allow-commands-delete",
|
"allow-commands-delete",
|
||||||
"allow-commands-read",
|
"allow-enabled-modules",
|
||||||
"allow-list-projects",
|
"allow-list-projects",
|
||||||
"allow-open-panel-window",
|
"allow-open-panel-window",
|
||||||
"allow-panel-archive-cmd",
|
"allow-panel-archive-cmd",
|
||||||
"allow-panel-archive-dir-cmd",
|
"allow-panel-archive-dir-cmd",
|
||||||
"allow-panel-load",
|
"allow-panel-load",
|
||||||
"allow-panel-read",
|
|
||||||
"allow-panel-set",
|
"allow-panel-set",
|
||||||
"allow-pool-label",
|
"allow-pool-label",
|
||||||
"allow-project-icon",
|
"allow-project-icon",
|
||||||
"allow-reveal-path-cmd",
|
"allow-reveal-path-cmd",
|
||||||
"allow-search-read",
|
|
||||||
"allow-search-run",
|
"allow-search-run",
|
||||||
"allow-set-terminal-font-size",
|
"allow-set-terminal-font-size",
|
||||||
"allow-spellcheck-lang",
|
"allow-spellcheck-lang",
|
||||||
@@ -41,7 +40,9 @@
|
|||||||
"allow-term-start",
|
"allow-term-start",
|
||||||
"allow-term-write",
|
"allow-term-write",
|
||||||
"allow-terminal-font-size",
|
"allow-terminal-font-size",
|
||||||
"allow-wiki-open",
|
"allow-todos-add",
|
||||||
"allow-wiki-read"
|
"allow-todos-delete",
|
||||||
|
"allow-todos-update",
|
||||||
|
"allow-wiki-open"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Automatically generated - DO NOT EDIT!
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "allow-buffer-read"
|
||||||
|
description = "Enables the buffer_read command without any pre-configured scope."
|
||||||
|
commands.allow = ["buffer_read"]
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "deny-buffer-read"
|
||||||
|
description = "Denies the buffer_read command without any pre-configured scope."
|
||||||
|
commands.deny = ["buffer_read"]
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Automatically generated - DO NOT EDIT!
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "allow-enabled-modules"
|
||||||
|
description = "Enables the enabled_modules command without any pre-configured scope."
|
||||||
|
commands.allow = ["enabled_modules"]
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "deny-enabled-modules"
|
||||||
|
description = "Denies the enabled_modules command without any pre-configured scope."
|
||||||
|
commands.deny = ["enabled_modules"]
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Automatically generated - DO NOT EDIT!
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "allow-module-registry"
|
||||||
|
description = "Enables the module_registry command without any pre-configured scope."
|
||||||
|
commands.allow = ["module_registry"]
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "deny-module-registry"
|
||||||
|
description = "Denies the module_registry command without any pre-configured scope."
|
||||||
|
commands.deny = ["module_registry"]
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Automatically generated - DO NOT EDIT!
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "allow-set-module"
|
||||||
|
description = "Enables the set_module command without any pre-configured scope."
|
||||||
|
commands.allow = ["set_module"]
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "deny-set-module"
|
||||||
|
description = "Denies the set_module command without any pre-configured scope."
|
||||||
|
commands.deny = ["set_module"]
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Automatically generated - DO NOT EDIT!
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "allow-todos-add"
|
||||||
|
description = "Enables the todos_add command without any pre-configured scope."
|
||||||
|
commands.allow = ["todos_add"]
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "deny-todos-add"
|
||||||
|
description = "Denies the todos_add command without any pre-configured scope."
|
||||||
|
commands.deny = ["todos_add"]
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Automatically generated - DO NOT EDIT!
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "allow-todos-delete"
|
||||||
|
description = "Enables the todos_delete command without any pre-configured scope."
|
||||||
|
commands.allow = ["todos_delete"]
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "deny-todos-delete"
|
||||||
|
description = "Denies the todos_delete command without any pre-configured scope."
|
||||||
|
commands.deny = ["todos_delete"]
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Automatically generated - DO NOT EDIT!
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "allow-todos-update"
|
||||||
|
description = "Enables the todos_update command without any pre-configured scope."
|
||||||
|
commands.allow = ["todos_update"]
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "deny-todos-update"
|
||||||
|
description = "Denies the todos_update command without any pre-configured scope."
|
||||||
|
commands.deny = ["todos_update"]
|
||||||
@@ -272,8 +272,6 @@ fn invoke_handlers() -> impl Fn(tauri::ipc::Invoke<tauri::Wry>) -> bool + Send +
|
|||||||
commands::set_terminal_config,
|
commands::set_terminal_config,
|
||||||
commands::project_icon,
|
commands::project_icon,
|
||||||
commands::pool_label,
|
commands::pool_label,
|
||||||
commands::todo_state,
|
|
||||||
commands::set_todo,
|
|
||||||
commands::usage_stats,
|
commands::usage_stats,
|
||||||
commands::stop_project,
|
commands::stop_project,
|
||||||
commands::restart_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::archive_docs_cmd,
|
||||||
commands::reveal_path_cmd,
|
commands::reveal_path_cmd,
|
||||||
commands::spellcheck_lang,
|
commands::spellcheck_lang,
|
||||||
|
commands::enabled_modules,
|
||||||
|
commands::module_registry,
|
||||||
|
commands::set_module,
|
||||||
terminal::open_terminal,
|
terminal::open_terminal,
|
||||||
terminal::term_start,
|
terminal::term_start,
|
||||||
terminal::term_log,
|
terminal::term_log,
|
||||||
terminal::term_write,
|
terminal::term_write,
|
||||||
terminal::term_resize,
|
terminal::term_resize,
|
||||||
terminal::panel_read,
|
terminal::buffer_read,
|
||||||
terminal::commands_read,
|
|
||||||
terminal::commands_delete,
|
terminal::commands_delete,
|
||||||
|
terminal::todos_delete,
|
||||||
|
terminal::todos_add,
|
||||||
|
terminal::todos_update,
|
||||||
terminal::panel_set,
|
terminal::panel_set,
|
||||||
terminal::search_read,
|
|
||||||
terminal::search_run,
|
terminal::search_run,
|
||||||
terminal::panel_load,
|
terminal::panel_load,
|
||||||
terminal::wiki_read,
|
|
||||||
terminal::wiki_open,
|
terminal::wiki_open,
|
||||||
terminal::open_panel_window
|
terminal::open_panel_window
|
||||||
]
|
]
|
||||||
|
|||||||
+28
-13
@@ -21,7 +21,6 @@ use crate::domain::project::{
|
|||||||
DeletePreview, Project, TerminalConfig,
|
DeletePreview, Project, TerminalConfig,
|
||||||
};
|
};
|
||||||
use crate::domain::settings;
|
use crate::domain::settings;
|
||||||
use crate::domain::todo::{set_todo_in, todo_state_in};
|
|
||||||
use crate::domain::usage::{usage_stats_in, UsageRow};
|
use crate::domain::usage::{usage_stats_in, UsageRow};
|
||||||
use crate::platform::KeychainStore;
|
use crate::platform::KeychainStore;
|
||||||
use crate::terminal;
|
use crate::terminal;
|
||||||
@@ -41,7 +40,6 @@ pub(crate) fn create_project_full(
|
|||||||
work_dir: Option<String>,
|
work_dir: Option<String>,
|
||||||
create_work_dir: bool,
|
create_work_dir: bool,
|
||||||
terminal: TerminalConfig,
|
terminal: TerminalConfig,
|
||||||
todo: bool,
|
|
||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
create_project_full_in(
|
create_project_full_in(
|
||||||
&Paths::real(),
|
&Paths::real(),
|
||||||
@@ -51,7 +49,6 @@ pub(crate) fn create_project_full(
|
|||||||
work_dir.as_deref(),
|
work_dir.as_deref(),
|
||||||
create_work_dir,
|
create_work_dir,
|
||||||
terminal,
|
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)
|
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]
|
#[tauri::command]
|
||||||
pub(crate) fn set_terminal_config(
|
pub(crate) fn set_terminal_config(
|
||||||
project: String,
|
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())
|
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
|
/// Setzt das Archiv-Home eines Projekts (Einstellungsdialog) — inklusive
|
||||||
/// Permissions-Eintrag in der Projekt-settings.json.
|
/// Permissions-Eintrag in der Projekt-settings.json.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
|||||||
@@ -6,12 +6,12 @@ pub(crate) mod archive;
|
|||||||
pub(crate) mod archive_index;
|
pub(crate) mod archive_index;
|
||||||
pub(crate) mod archive_search;
|
pub(crate) mod archive_search;
|
||||||
pub(crate) mod credentials;
|
pub(crate) mod credentials;
|
||||||
|
pub(crate) mod modules;
|
||||||
pub(crate) mod paths;
|
pub(crate) mod paths;
|
||||||
pub(crate) mod pool;
|
pub(crate) mod pool;
|
||||||
pub(crate) mod project;
|
pub(crate) mod project;
|
||||||
pub(crate) mod registry;
|
pub(crate) mod registry;
|
||||||
pub(crate) mod settings;
|
pub(crate) mod settings;
|
||||||
pub(crate) mod todo;
|
|
||||||
pub(crate) mod usage;
|
pub(crate) mod usage;
|
||||||
pub(crate) mod watcher;
|
pub(crate) mod watcher;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,294 @@
|
|||||||
|
//! Modul-Registry: benennt die Funktionsblöcke der App (Entwurf, Befehle,
|
||||||
|
//! Archiv, …) mit ihren Beiträgen — MCP-Tools und Puffer-Kanäle. Die Tabelle
|
||||||
|
//! ist die eine Quelle für „was ist in diesem Projekt an?": Das Frontend baut
|
||||||
|
//! seine Tabs daraus (`enabled_modules`), der MCP-Server seine Tool-Liste
|
||||||
|
//! (tools/list) und den Guard vor dem Dispatch (tools/call).
|
||||||
|
//!
|
||||||
|
//! Die Abwahl eines Moduls ist Konfiguration, keine Sicherheitsgrenze —
|
||||||
|
//! Commands bleiben registriert, die Capability-Manifeste unverändert;
|
||||||
|
//! dieselbe Linie wie bei den Pools (Konfiguration trennen, nicht Zugriff).
|
||||||
|
|
||||||
|
use crate::domain::paths::Paths;
|
||||||
|
|
||||||
|
/// Ein Puffer-Kanal eines Moduls: MCP-Server oder Command schreibt die Datei,
|
||||||
|
/// der Watcher im Terminal-Prozess meldet neuen Inhalt als Event an die
|
||||||
|
/// Panel-Fenster.
|
||||||
|
pub(crate) struct BufferDesc {
|
||||||
|
/// Schlüssel für `buffer_read` (Erstbefüllung der Ansicht).
|
||||||
|
pub(crate) id: &'static str,
|
||||||
|
/// Env-Variable, unter der die PTY den Dateipfad an claudes MCP-Kinder gibt.
|
||||||
|
pub(crate) env: &'static str,
|
||||||
|
/// Event an die Panel-Fenster bei Dateiänderung.
|
||||||
|
pub(crate) event: &'static str,
|
||||||
|
/// Pufferdatei des Projekts.
|
||||||
|
pub(crate) file: fn(&str) -> std::path::PathBuf,
|
||||||
|
/// Dateiname-Suffix (`<projekt>.<suffix>`) — muss zu `file` passen
|
||||||
|
/// (Test buffer_datei_passt_zum_suffix); Grundlage der Löschvorschau.
|
||||||
|
pub(crate) suffix: &'static str,
|
||||||
|
/// Persistente Puffer überleben Sessions — der Session-Start legt sie nur
|
||||||
|
/// an, statt sie zu leeren.
|
||||||
|
pub(crate) persistent: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct ModuleDesc {
|
||||||
|
pub(crate) id: &'static str,
|
||||||
|
/// Kern-Module sind nicht abschaltbar; ein Config-Eintrag wird ignoriert.
|
||||||
|
pub(crate) core: bool,
|
||||||
|
/// Aktiv ohne Eintrag in der Projekt-Config.
|
||||||
|
pub(crate) default_enabled: bool,
|
||||||
|
/// Modul setzt ein konfiguriertes Archiv-Home voraus — ohne Home fallen
|
||||||
|
/// seine Tabs im Frontend weg. Seine MCP-Tools bleiben absichtlich
|
||||||
|
/// gelistet: Das Home kann mitten in der Session gesetzt werden, und die
|
||||||
|
/// Tools melden das fehlende Home selbst verständlich zurück.
|
||||||
|
pub(crate) requires_archive: bool,
|
||||||
|
pub(crate) mcp_tools: &'static [&'static str],
|
||||||
|
pub(crate) buffers: &'static [BufferDesc],
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) const MODULES: &[ModuleDesc] = &[
|
||||||
|
ModuleDesc {
|
||||||
|
id: "draft",
|
||||||
|
core: true,
|
||||||
|
default_enabled: true,
|
||||||
|
requires_archive: false,
|
||||||
|
mcp_tools: &["write_panel"],
|
||||||
|
buffers: &[BufferDesc {
|
||||||
|
id: "panel",
|
||||||
|
env: "AI_CONTROL_PANEL",
|
||||||
|
event: "panel-update",
|
||||||
|
file: crate::domain::paths::panel_file,
|
||||||
|
suffix: "md",
|
||||||
|
persistent: false,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
ModuleDesc {
|
||||||
|
id: "commands",
|
||||||
|
core: false,
|
||||||
|
default_enabled: true,
|
||||||
|
requires_archive: false,
|
||||||
|
mcp_tools: &["write_commands", "show_commands"],
|
||||||
|
buffers: &[BufferDesc {
|
||||||
|
id: "commands",
|
||||||
|
env: "AI_CONTROL_COMMANDS",
|
||||||
|
event: "commands-update",
|
||||||
|
file: crate::domain::paths::commands_file,
|
||||||
|
suffix: "commands.jsonl",
|
||||||
|
persistent: false,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
ModuleDesc {
|
||||||
|
id: "todo",
|
||||||
|
core: false,
|
||||||
|
// Opt-in: der Tab erscheint erst, wenn das Modul im Projekt gewählt ist.
|
||||||
|
default_enabled: false,
|
||||||
|
requires_archive: false,
|
||||||
|
mcp_tools: &["write_todos", "show_todos"],
|
||||||
|
buffers: &[BufferDesc {
|
||||||
|
id: "todos",
|
||||||
|
env: "AI_CONTROL_TODOS",
|
||||||
|
event: "todos-update",
|
||||||
|
file: crate::domain::paths::todos_file,
|
||||||
|
suffix: "todos.jsonl",
|
||||||
|
persistent: true,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
ModuleDesc {
|
||||||
|
id: "archive",
|
||||||
|
core: false,
|
||||||
|
default_enabled: true,
|
||||||
|
requires_archive: true,
|
||||||
|
mcp_tools: &["archive_panel", "show_archive", "search_archive"],
|
||||||
|
buffers: &[
|
||||||
|
BufferDesc {
|
||||||
|
id: "search",
|
||||||
|
env: "AI_CONTROL_SEARCH",
|
||||||
|
event: "search-update",
|
||||||
|
file: crate::domain::paths::search_file,
|
||||||
|
suffix: "search.json",
|
||||||
|
persistent: false,
|
||||||
|
},
|
||||||
|
BufferDesc {
|
||||||
|
id: "wiki",
|
||||||
|
env: "AI_CONTROL_WIKI",
|
||||||
|
event: "wiki-update",
|
||||||
|
file: crate::domain::paths::wiki_file,
|
||||||
|
suffix: "wiki.json",
|
||||||
|
persistent: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Aktive Module des Projekts: MODULES, gefiltert durch die `modules`-
|
||||||
|
/// Abweichungen der Projekt-Config. Kern-Module sind immer dabei.
|
||||||
|
pub(crate) fn active_in(
|
||||||
|
paths: &Paths,
|
||||||
|
project: &str,
|
||||||
|
) -> Result<Vec<&'static ModuleDesc>, String> {
|
||||||
|
let cfg = crate::domain::project::read_project_config_in(paths, project)?;
|
||||||
|
Ok(
|
||||||
|
MODULES
|
||||||
|
.iter()
|
||||||
|
.filter(|m| {
|
||||||
|
m.core || cfg.modules.get(m.id).copied().unwrap_or(m.default_enabled)
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Modul, das dieses MCP-Tool beiträgt.
|
||||||
|
pub(crate) fn by_tool(tool: &str) -> Option<&'static ModuleDesc> {
|
||||||
|
MODULES.iter().find(|m| m.mcp_tools.contains(&tool))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Registry-Zeile für den Settings-Dialog: `enabled` ist der effektive
|
||||||
|
/// Config-Schalter (ohne `requires_archive` — die Pfadfrage zeigt der
|
||||||
|
/// Dialog daneben an).
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
pub(crate) struct ModuleInfo {
|
||||||
|
pub(crate) id: &'static str,
|
||||||
|
pub(crate) core: bool,
|
||||||
|
pub(crate) enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn module_infos_in(
|
||||||
|
paths: &Paths,
|
||||||
|
project: &str,
|
||||||
|
) -> Result<Vec<ModuleInfo>, String> {
|
||||||
|
let cfg = crate::domain::project::read_project_config_in(paths, project)?;
|
||||||
|
Ok(
|
||||||
|
MODULES
|
||||||
|
.iter()
|
||||||
|
.map(|m| ModuleInfo {
|
||||||
|
id: m.id,
|
||||||
|
core: m.core,
|
||||||
|
enabled: m.core || cfg.modules.get(m.id).copied().unwrap_or(m.default_enabled),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Schreibt die Modul-Abweichung in die Projekt-Config; der Default-Wert
|
||||||
|
/// löscht den Eintrag (nur Abweichungen werden gespeichert).
|
||||||
|
pub(crate) fn set_module_in(
|
||||||
|
paths: &Paths,
|
||||||
|
project: &str,
|
||||||
|
module: &str,
|
||||||
|
enabled: bool,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let m = MODULES
|
||||||
|
.iter()
|
||||||
|
.find(|m| m.id == module)
|
||||||
|
.ok_or_else(|| format!("unbekanntes Modul: {module}"))?;
|
||||||
|
if m.core {
|
||||||
|
return Err(format!("Modul {module} ist nicht abschaltbar"));
|
||||||
|
}
|
||||||
|
let mut cfg = crate::domain::project::read_project_config_in(paths, project)?;
|
||||||
|
if enabled == m.default_enabled {
|
||||||
|
cfg.modules.remove(module);
|
||||||
|
} else {
|
||||||
|
cfg.modules.insert(module.to_string(), enabled);
|
||||||
|
}
|
||||||
|
crate::domain::project::write_project_config_in(paths, project, &cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::domain::testutil::{create_project, tmp_paths};
|
||||||
|
|
||||||
|
fn ids(mods: &[&'static ModuleDesc]) -> Vec<&'static str> {
|
||||||
|
mods.iter().map(|m| m.id).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn defaults_alle_aktiv() {
|
||||||
|
let p = tmp_paths();
|
||||||
|
create_project(&p, "proj").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
ids(&active_in(&p, "proj").unwrap()),
|
||||||
|
vec!["draft", "commands", "archive"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn config_schaltet_ab_kern_bleibt() {
|
||||||
|
let p = tmp_paths();
|
||||||
|
create_project(&p, "proj").unwrap();
|
||||||
|
let cfg = p
|
||||||
|
.projects_dir()
|
||||||
|
.join("proj")
|
||||||
|
.join(".ai-control")
|
||||||
|
.join("config.json");
|
||||||
|
std::fs::write(
|
||||||
|
&cfg,
|
||||||
|
r#"{"id": "proj", "name": "proj", "modules": {"commands": false, "draft": false}}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
// commands ist ab; draft ignoriert den Eintrag (Kern-Modul).
|
||||||
|
assert_eq!(ids(&active_in(&p, "proj").unwrap()), vec!["draft", "archive"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unbekanntes_projekt_scheitert() {
|
||||||
|
let p = tmp_paths();
|
||||||
|
assert!(active_in(&p, "fehlt").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn todo_ist_opt_in() {
|
||||||
|
let p = tmp_paths();
|
||||||
|
create_project(&p, "proj").unwrap();
|
||||||
|
set_module_in(&p, "proj", "todo", true).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
ids(&active_in(&p, "proj").unwrap()),
|
||||||
|
vec!["draft", "commands", "todo", "archive"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn buffer_datei_passt_zum_suffix() {
|
||||||
|
for b in MODULES.iter().flat_map(|m| m.buffers) {
|
||||||
|
let name = (b.file)("proj");
|
||||||
|
let name = name.file_name().unwrap().to_str().unwrap();
|
||||||
|
assert_eq!(name, format!("proj.{}", b.suffix));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_module_roundtrip() {
|
||||||
|
let p = tmp_paths();
|
||||||
|
create_project(&p, "proj").unwrap();
|
||||||
|
set_module_in(&p, "proj", "commands", false).unwrap();
|
||||||
|
assert_eq!(ids(&active_in(&p, "proj").unwrap()), vec!["draft", "archive"]);
|
||||||
|
assert!(!module_infos_in(&p, "proj").unwrap()[1].enabled);
|
||||||
|
// Zurück auf den Default löscht den Eintrag statt `true` zu speichern.
|
||||||
|
set_module_in(&p, "proj", "commands", true).unwrap();
|
||||||
|
let cfg = crate::domain::project::read_project_config_in(&p, "proj").unwrap();
|
||||||
|
assert!(cfg.modules.is_empty());
|
||||||
|
// Kern-Modul und unbekannte ID scheitern laut.
|
||||||
|
assert!(set_module_in(&p, "proj", "draft", false).is_err());
|
||||||
|
assert!(set_module_in(&p, "proj", "gibtsnicht", true).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tool_zuordnung() {
|
||||||
|
assert_eq!(by_tool("write_panel").unwrap().id, "draft");
|
||||||
|
assert_eq!(by_tool("search_archive").unwrap().id, "archive");
|
||||||
|
assert!(by_tool("unbekannt").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn buffer_und_tool_ids_eindeutig() {
|
||||||
|
let mut tools: Vec<&str> = MODULES.iter().flat_map(|m| m.mcp_tools).copied().collect();
|
||||||
|
let mut buffers: Vec<&str> =
|
||||||
|
MODULES.iter().flat_map(|m| m.buffers).map(|b| b.id).collect();
|
||||||
|
let (t, b) = (tools.len(), buffers.len());
|
||||||
|
tools.sort();
|
||||||
|
tools.dedup();
|
||||||
|
buffers.sort();
|
||||||
|
buffers.dedup();
|
||||||
|
assert_eq!(tools.len(), t);
|
||||||
|
assert_eq!(buffers.len(), b);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -98,6 +98,15 @@ pub(crate) fn wiki_file(project: &str) -> PathBuf {
|
|||||||
.join(format!("{}.wiki.json", checked(project)))
|
.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.
|
/// "~" bzw. "~/x" relativ zum Home auflösen; alles andere unverändert.
|
||||||
pub(crate) fn expand_home(paths: &Paths, p: &str) -> PathBuf {
|
pub(crate) fn expand_home(paths: &Paths, p: &str) -> PathBuf {
|
||||||
if p == "~" {
|
if p == "~" {
|
||||||
|
|||||||
@@ -61,6 +61,11 @@ pub(crate) struct ProjectConfig {
|
|||||||
skip_serializing_if = "Option::is_none"
|
skip_serializing_if = "Option::is_none"
|
||||||
)]
|
)]
|
||||||
pub(crate) archive_home: Option<String>,
|
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.
|
/// Alle Keys, die dieser Build nicht kennt — unverändert durchgereicht.
|
||||||
/// Ohne das verliert jeder read-modify-write (Pool-Zuweisung, Terminal-
|
/// Ohne das verliert jeder read-modify-write (Pool-Zuweisung, Terminal-
|
||||||
/// Einstellungen) still die Felder neuerer Versionen: serde verwirft
|
/// 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
|
/// Berechtigungen), Registry-Eintrag mit Pool, .ai-control/config.json mit
|
||||||
/// ID, Name und Terminal-Config. Ohne Zielordner landet das Projekt unter
|
/// ID, Name und Terminal-Config. Ohne Zielordner landet das Projekt unter
|
||||||
/// ~/claude-projects/<name>. Liefert die neue Projekt-ID.
|
/// ~/claude-projects/<name>. Liefert die neue Projekt-ID.
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
pub(crate) fn create_project_full_in(
|
pub(crate) fn create_project_full_in(
|
||||||
paths: &Paths,
|
paths: &Paths,
|
||||||
name: &str,
|
name: &str,
|
||||||
@@ -250,7 +254,6 @@ pub(crate) fn create_project_full_in(
|
|||||||
work_dir: Option<&str>,
|
work_dir: Option<&str>,
|
||||||
create_work_dir: bool,
|
create_work_dir: bool,
|
||||||
terminal: TerminalConfig,
|
terminal: TerminalConfig,
|
||||||
todo: bool,
|
|
||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
check_name(name)?;
|
check_name(name)?;
|
||||||
let dir = match dir {
|
let dir = match dir {
|
||||||
@@ -312,12 +315,10 @@ pub(crate) fn create_project_full_in(
|
|||||||
name: Some(name.to_string()),
|
name: Some(name.to_string()),
|
||||||
terminal,
|
terminal,
|
||||||
archive_home: None,
|
archive_home: None,
|
||||||
|
modules: Default::default(),
|
||||||
rest: Default::default(),
|
rest: Default::default(),
|
||||||
};
|
};
|
||||||
write_project_config_in(paths, &id, &cfg)?;
|
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);
|
crate::platform::write_terminal_desktop(paths, &id, &cfg);
|
||||||
Ok(id)
|
Ok(id)
|
||||||
}
|
}
|
||||||
@@ -484,7 +485,6 @@ pub(crate) struct DeletePreview {
|
|||||||
pub(crate) project_dir: String,
|
pub(crate) project_dir: String,
|
||||||
pub(crate) ai_control_dir: bool,
|
pub(crate) ai_control_dir: bool,
|
||||||
pub(crate) archive_permission: bool,
|
pub(crate) archive_permission: bool,
|
||||||
pub(crate) todo_hook: bool,
|
|
||||||
pub(crate) panel_files: usize,
|
pub(crate) panel_files: usize,
|
||||||
pub(crate) archive_home: Option<String>,
|
pub(crate) archive_home: Option<String>,
|
||||||
pub(crate) archive_docs: usize,
|
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),
|
project_dir: contract_home(paths, &dir),
|
||||||
ai_control_dir: dir.join(PROJECT_CONFIG_DIR).is_dir(),
|
ai_control_dir: dir.join(PROJECT_CONFIG_DIR).is_dir(),
|
||||||
archive_permission: cfg.archive_home.is_some(),
|
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(),
|
panel_files: session_files(paths, project).iter().filter(|f| f.is_file()).count(),
|
||||||
archive_home: cfg.archive_home.clone(),
|
archive_home: cfg.archive_home.clone(),
|
||||||
archive_docs,
|
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.
|
/// 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");
|
let panels = paths.config_dir().join("panels");
|
||||||
[
|
crate::domain::modules::MODULES
|
||||||
panels.join(format!("{project}.md")),
|
.iter()
|
||||||
panels.join(format!("{project}.commands.jsonl")),
|
.flat_map(|m| m.buffers)
|
||||||
panels.join(format!("{project}.search.json")),
|
.map(|b| panels.join(format!("{project}.{}", b.suffix)))
|
||||||
panels.join(format!("{project}.wiki.json")),
|
.collect()
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Löscht ein Projekt in drei Stufen (Eskalationsleiter, jede schließt die
|
/// Löscht ein Projekt in drei Stufen (Eskalationsleiter, jede schließt die
|
||||||
/// vorige ein):
|
/// vorige ein):
|
||||||
/// - "integration": nur die ai-control-Spuren — Registry, .ai-control/,
|
/// - "integration": nur die ai-control-Spuren — Registry, .ai-control/,
|
||||||
/// Archiv-Rechte und Todo-Hook in der settings.json, Panel-Dateien,
|
/// Archiv-Rechte in der settings.json, Panel-Dateien, .desktop. Ordner,
|
||||||
/// .desktop. Ordner, memory/ und das Claude-Code-Grundgerüst der
|
/// memory/ und das Claude-Code-Grundgerüst der settings.json bleiben.
|
||||||
/// settings.json bleiben.
|
|
||||||
/// - "archive": zusätzlich der Archiv-Ordner samt Dokumenten.
|
/// - "archive": zusätzlich der Archiv-Ordner samt Dokumenten.
|
||||||
/// - "full": zusätzlich der Projektordner; Arbeitsordner per Flag.
|
/// - "full": zusätzlich der Projektordner; Arbeitsordner per Flag.
|
||||||
pub(crate) fn delete_project_scoped_in(
|
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() {
|
if let Some(a) = cfg.archive_home.as_deref() {
|
||||||
crate::domain::archive::remove_archive_permission(paths, project, a)?;
|
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);
|
let ac = dir.join(PROJECT_CONFIG_DIR);
|
||||||
if ac.is_dir() {
|
if ac.is_dir() {
|
||||||
fs::remove_dir_all(&ac).map_err(|e| format!("{}: {e}", ac.display()))?;
|
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 {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::domain::testutil::{create_project, make_apikey_pool, make_oauth_pool, map_store, tmp_paths};
|
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> {
|
fn project_config_path(paths: &Paths, project: &str) -> Result<PathBuf, String> {
|
||||||
Ok(project_dir(paths, project)?.join(PROJECT_CONFIG_DIR).join(PROJECT_FILE))
|
Ok(project_dir(paths, project)?.join(PROJECT_CONFIG_DIR).join(PROJECT_FILE))
|
||||||
@@ -841,7 +836,6 @@ mod tests {
|
|||||||
title: Some("Neu".into()),
|
title: Some("Neu".into()),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
true,
|
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
@@ -876,21 +870,14 @@ mod tests {
|
|||||||
"Edit(~/claude-projects/neu/**)"
|
"Edit(~/claude-projects/neu/**)"
|
||||||
);
|
);
|
||||||
assert_eq!(settings["permissions"]["additionalDirectories"][0], "~/projects/neu");
|
assert_eq!(settings["permissions"]["additionalDirectories"][0], "~/projects/neu");
|
||||||
// todo=true: einziger SessionStart-Hook (kein pool-guard mehr), Datei da
|
assert!(settings.get("hooks").is_none());
|
||||||
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));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn projekt_wizard_minimal_ohne_pool_und_workdir() {
|
fn projekt_wizard_minimal_ohne_pool_und_workdir() {
|
||||||
let p = tmp_paths();
|
let p = tmp_paths();
|
||||||
let id =
|
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();
|
.unwrap();
|
||||||
let dir = p.projects_dir().join("neu");
|
let dir = p.projects_dir().join("neu");
|
||||||
assert!(dir.join(".claude").join("settings.json").is_file());
|
assert!(dir.join(".claude").join("settings.json").is_file());
|
||||||
@@ -915,7 +902,7 @@ mod tests {
|
|||||||
let p = tmp_paths();
|
let p = tmp_paths();
|
||||||
create_project(&p, "neu").unwrap();
|
create_project(&p, "neu").unwrap();
|
||||||
let err =
|
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();
|
.unwrap_err();
|
||||||
assert!(err.contains("existiert bereits"));
|
assert!(err.contains("existiert bereits"));
|
||||||
}
|
}
|
||||||
@@ -931,7 +918,6 @@ mod tests {
|
|||||||
Some("~/projects/gibtsnicht"),
|
Some("~/projects/gibtsnicht"),
|
||||||
false,
|
false,
|
||||||
TerminalConfig::default(),
|
TerminalConfig::default(),
|
||||||
false,
|
|
||||||
)
|
)
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert!(err.contains("Arbeitsverzeichnis fehlt"));
|
assert!(err.contains("Arbeitsverzeichnis fehlt"));
|
||||||
@@ -1225,7 +1211,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn projekt_loeschen_laesst_arbeitsordner() {
|
fn projekt_loeschen_laesst_arbeitsordner() {
|
||||||
let p = tmp_paths();
|
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();
|
.unwrap();
|
||||||
delete_project_scoped_in(&p, &id, "full", false).unwrap();
|
delete_project_scoped_in(&p, &id, "full", false).unwrap();
|
||||||
assert!(!p.projects_dir().join("proj").exists());
|
assert!(!p.projects_dir().join("proj").exists());
|
||||||
@@ -1235,7 +1221,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn projekt_loeschen_mit_arbeitsordner() {
|
fn projekt_loeschen_mit_arbeitsordner() {
|
||||||
let p = tmp_paths();
|
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();
|
.unwrap();
|
||||||
delete_project_scoped_in(&p, &id, "full", true).unwrap();
|
delete_project_scoped_in(&p, &id, "full", true).unwrap();
|
||||||
assert!(!p.projects_dir().join("proj").exists());
|
assert!(!p.projects_dir().join("proj").exists());
|
||||||
@@ -1245,7 +1231,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn projekt_loeschen_fehlender_arbeitsordner_scheitert() {
|
fn projekt_loeschen_fehlender_arbeitsordner_scheitert() {
|
||||||
let p = tmp_paths();
|
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();
|
.unwrap();
|
||||||
fs::remove_dir_all(p.home.join("projects").join("proj")).unwrap();
|
fs::remove_dir_all(p.home.join("projects").join("proj")).unwrap();
|
||||||
assert!(delete_project_scoped_in(&p, &id, "full", true).is_err());
|
assert!(delete_project_scoped_in(&p, &id, "full", true).is_err());
|
||||||
@@ -1254,7 +1240,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn projekt_arbeitsordner_auslesen() {
|
fn projekt_arbeitsordner_auslesen() {
|
||||||
let p = tmp_paths();
|
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();
|
.unwrap();
|
||||||
assert_eq!(project_work_dirs_in(&p, &id).unwrap(), vec!["~/projects/proj"]);
|
assert_eq!(project_work_dirs_in(&p, &id).unwrap(), vec!["~/projects/proj"]);
|
||||||
// Projekt ohne settings.json → leer
|
// Projekt ohne settings.json → leer
|
||||||
@@ -1272,7 +1258,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn arbeitsordner_nachtraeglich_erfassen_und_entfernen() {
|
fn arbeitsordner_nachtraeglich_erfassen_und_entfernen() {
|
||||||
let p = tmp_paths();
|
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();
|
.unwrap();
|
||||||
fs::create_dir_all(p.home.join("projects/extra")).unwrap();
|
fs::create_dir_all(p.home.join("projects/extra")).unwrap();
|
||||||
let picked = p.home.join("projects/extra").to_string_lossy().into_owned();
|
let picked = p.home.join("projects/extra").to_string_lossy().into_owned();
|
||||||
@@ -1294,7 +1280,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn projektordner_verlegen() {
|
fn projektordner_verlegen() {
|
||||||
let p = tmp_paths();
|
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();
|
.unwrap();
|
||||||
// Nutzer hat den Ordner selbst verschoben; die App ordnet nur neu zu.
|
// Nutzer hat den Ordner selbst verschoben; die App ordnet nur neu zu.
|
||||||
let new_dir = p.home.join("elsewhere").join("proj");
|
let new_dir = p.home.join("elsewhere").join("proj");
|
||||||
@@ -1350,7 +1336,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn loeschen_nur_integration() {
|
fn loeschen_nur_integration() {
|
||||||
let p = tmp_paths();
|
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();
|
.unwrap();
|
||||||
let dir = p.projects_dir().join("proj");
|
let dir = p.projects_dir().join("proj");
|
||||||
// Archiv-Home samt Permission wie über die UI gesetzt
|
// 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();
|
fs::write(p.config_dir().join("panels").join(format!("{id}.md")), "x").unwrap();
|
||||||
|
|
||||||
let preview = delete_preview_in(&p, &id).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.panel_files, 1);
|
||||||
assert_eq!(preview.archive_docs, 1);
|
assert_eq!(preview.archive_docs, 1);
|
||||||
|
|
||||||
@@ -1375,9 +1361,7 @@ mod tests {
|
|||||||
assert!(!dir.join(PROJECT_CONFIG_DIR).exists());
|
assert!(!dir.join(PROJECT_CONFIG_DIR).exists());
|
||||||
let sp = fs::read_to_string(settings_path(&dir)).unwrap();
|
let sp = fs::read_to_string(settings_path(&dir)).unwrap();
|
||||||
assert!(!sp.contains("archiv/proj"));
|
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!(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.home.join("archiv/proj/doc.md").is_file()); // Archiv bleibt
|
||||||
assert!(!p.config_dir().join("panels").join(format!("{id}.md")).exists());
|
assert!(!p.config_dir().join("panels").join(format!("{id}.md")).exists());
|
||||||
assert!(load_registry(&p).unwrap().is_empty());
|
assert!(load_registry(&p).unwrap().is_empty());
|
||||||
@@ -1387,7 +1371,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn loeschen_integration_und_archiv() {
|
fn loeschen_integration_und_archiv() {
|
||||||
let p = tmp_paths();
|
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();
|
.unwrap();
|
||||||
let mut cfg = read_project_config_in(&p, &id).unwrap();
|
let mut cfg = read_project_config_in(&p, &id).unwrap();
|
||||||
cfg.archive_home = Some("~/archiv/proj".into());
|
cfg.archive_home = Some("~/archiv/proj".into());
|
||||||
|
|||||||
@@ -1,166 +0,0 @@
|
|||||||
//! Feature: Todoliste. Muster robotunits: Datei im Projekt-Root, per
|
|
||||||
//! SessionStart-Hook (jq) als additionalContext in jede Session injiziert.
|
|
||||||
|
|
||||||
use std::fs;
|
|
||||||
|
|
||||||
use crate::domain::check_name;
|
|
||||||
use crate::domain::paths::Paths;
|
|
||||||
use crate::domain::project::settings_path;
|
|
||||||
use crate::domain::registry::project_dir;
|
|
||||||
|
|
||||||
pub(crate) const TODO_FILE: &str = "OFFENE-PUNKTE.md";
|
|
||||||
const TODO_SKELETON: &str = "# Offene Punkte — bei jedem Start prüfen und abhaken\n\nKeine offenen Punkte.\n";
|
|
||||||
|
|
||||||
/// Der Hook landet in der `settings.json` des Projekts und wird von Claude Code
|
|
||||||
/// bei jedem Sessionstart über die Shell ausgeführt. Der Pfad muss darum
|
|
||||||
/// gequotet werden: Er stammt aus dem Ordnernamen, den der Nutzer im Dialog
|
|
||||||
/// wählt — bei einem geklonten Fremd-Repo also von außen. Unquotiert genügte
|
|
||||||
/// ein Ordner `repo$(…)` für dauerhafte Codeausführung, und schon ein
|
|
||||||
/// Leerzeichen im Pfad hätte den Hook still zerbrochen.
|
|
||||||
fn todo_hook_command(dir: &std::path::Path) -> String {
|
|
||||||
format!(
|
|
||||||
"jq -Rs '{{systemMessage: ., hookSpecificOutput:{{hookEventName:\"SessionStart\", additionalContext: .}}}}' {}",
|
|
||||||
shell_quote(&dir.join(TODO_FILE).to_string_lossy())
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Ein Argument für `sh -c` in einfache Anführungszeichen setzen. Innerhalb
|
|
||||||
/// davon ist jedes Zeichen literal; einzig das Apostroph selbst muss die
|
|
||||||
/// Quotierung verlassen und wieder betreten (`'\''`).
|
|
||||||
fn shell_quote(s: &str) -> String {
|
|
||||||
format!("'{}'", s.replace('\'', r"'\''"))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn hook_is_todo(group: &serde_json::Value) -> bool {
|
|
||||||
group["hooks"]
|
|
||||||
.as_array()
|
|
||||||
.map(|hs| {
|
|
||||||
hs.iter().any(|h| {
|
|
||||||
h["command"]
|
|
||||||
.as_str()
|
|
||||||
.map_or(false, |c| c.contains(TODO_FILE))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.unwrap_or(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn todo_state_in(paths: &Paths, name: &str) -> Result<bool, String> {
|
|
||||||
check_name(name)?;
|
|
||||||
let sp = settings_path(&project_dir(paths, name)?);
|
|
||||||
if !sp.is_file() {
|
|
||||||
return Ok(false);
|
|
||||||
}
|
|
||||||
let raw = fs::read_to_string(&sp).map_err(|e| format!("{}: {e}", sp.display()))?;
|
|
||||||
let v: serde_json::Value =
|
|
||||||
serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", sp.display()))?;
|
|
||||||
Ok(
|
|
||||||
v["hooks"]["SessionStart"]
|
|
||||||
.as_array()
|
|
||||||
.map(|a| a.iter().any(hook_is_todo))
|
|
||||||
.unwrap_or(false),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn set_todo_in(paths: &Paths, name: &str, enabled: bool) -> Result<(), String> {
|
|
||||||
check_name(name)?;
|
|
||||||
let dir = project_dir(paths, name)?;
|
|
||||||
let sp = settings_path(&dir);
|
|
||||||
if !sp.is_file() {
|
|
||||||
return Err(format!("settings.json fehlt: {}", sp.display()));
|
|
||||||
}
|
|
||||||
let raw = fs::read_to_string(&sp).map_err(|e| format!("{}: {e}", sp.display()))?;
|
|
||||||
let mut v: serde_json::Value =
|
|
||||||
serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", sp.display()))?;
|
|
||||||
|
|
||||||
let root = v.as_object_mut().ok_or("settings.json ist kein Objekt")?;
|
|
||||||
let hooks = root
|
|
||||||
.entry("hooks")
|
|
||||||
.or_insert_with(|| serde_json::json!({}))
|
|
||||||
.as_object_mut()
|
|
||||||
.ok_or("hooks ist kein Objekt")?;
|
|
||||||
let session_start = hooks
|
|
||||||
.entry("SessionStart")
|
|
||||||
.or_insert_with(|| serde_json::json!([]))
|
|
||||||
.as_array_mut()
|
|
||||||
.ok_or("SessionStart ist kein Array")?;
|
|
||||||
|
|
||||||
session_start.retain(|g| !hook_is_todo(g));
|
|
||||||
if enabled {
|
|
||||||
session_start.insert(
|
|
||||||
0,
|
|
||||||
serde_json::json!({
|
|
||||||
"hooks": [
|
|
||||||
{ "type": "command", "command": todo_hook_command(&dir) }
|
|
||||||
]
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
let todo_path = dir.join(TODO_FILE);
|
|
||||||
if !todo_path.is_file() {
|
|
||||||
fs::write(&todo_path, TODO_SKELETON)
|
|
||||||
.map_err(|e| format!("{}: {e}", todo_path.display()))?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let raw = serde_json::to_string_pretty(&v).map_err(|e| e.to_string())?;
|
|
||||||
crate::domain::write_atomic(&sp, &(raw + "\n"))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::domain::project::{create_project_full_in, TerminalConfig};
|
|
||||||
use crate::domain::testutil::{create_project, tmp_paths};
|
|
||||||
|
|
||||||
/// Der Hook-Befehl geht durch die Shell; ein Pfad mit Metazeichen darf dort
|
|
||||||
/// nichts ausführen. Ordnernamen sind bei geklonten Repos Fremdeingabe.
|
|
||||||
#[test]
|
|
||||||
fn hook_befehl_quotet_den_pfad() {
|
|
||||||
let cmd = todo_hook_command(std::path::Path::new("/tmp/repo$(touch /tmp/pwned)"));
|
|
||||||
assert!(cmd.ends_with("'/tmp/repo$(touch /tmp/pwned)/OFFENE-PUNKTE.md'"), "{cmd}");
|
|
||||||
|
|
||||||
// Ein Apostroph im Pfad darf die Quotierung nicht aufbrechen.
|
|
||||||
let cmd = todo_hook_command(std::path::Path::new("/tmp/o'brien"));
|
|
||||||
assert!(cmd.ends_with(r"'/tmp/o'\''brien/OFFENE-PUNKTE.md'"), "{cmd}");
|
|
||||||
// Nach dem Zerlegen an den Quotes bleibt kein unquotierter Bereich übrig,
|
|
||||||
// in dem eine Shell noch etwas zu interpretieren hätte.
|
|
||||||
assert!(!cmd.contains("$("));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn todo_zuschalten_und_abschalten() {
|
|
||||||
let p = tmp_paths();
|
|
||||||
let id =
|
|
||||||
create_project_full_in(&p, "proj", None, None, None, false, TerminalConfig::default(), false)
|
|
||||||
.unwrap();
|
|
||||||
assert!(!todo_state_in(&p, &id).unwrap());
|
|
||||||
|
|
||||||
set_todo_in(&p, &id, true).unwrap();
|
|
||||||
assert!(todo_state_in(&p, &id).unwrap());
|
|
||||||
let todo_path = p.projects_dir().join("proj").join(TODO_FILE);
|
|
||||||
assert_eq!(fs::read_to_string(&todo_path).unwrap(), TODO_SKELETON);
|
|
||||||
|
|
||||||
// doppelt aktivieren erzeugt keinen zweiten Hook
|
|
||||||
set_todo_in(&p, &id, true).unwrap();
|
|
||||||
let settings: serde_json::Value = serde_json::from_str(
|
|
||||||
&fs::read_to_string(settings_path(&p.projects_dir().join("proj"))).unwrap(),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let groups = settings["hooks"]["SessionStart"].as_array().unwrap();
|
|
||||||
assert_eq!(groups.iter().filter(|g| hook_is_todo(g)).count(), 1);
|
|
||||||
|
|
||||||
// Abschalten: Hook weg, Datei (mit Inhalt) bleibt
|
|
||||||
fs::write(&todo_path, "# Offene Punkte\n\n- [ ] wichtig\n").unwrap();
|
|
||||||
set_todo_in(&p, &id, false).unwrap();
|
|
||||||
assert!(!todo_state_in(&p, &id).unwrap());
|
|
||||||
assert!(todo_path.is_file());
|
|
||||||
assert!(fs::read_to_string(&todo_path).unwrap().contains("wichtig"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn todo_ohne_settings_scheitert() {
|
|
||||||
let p = tmp_paths();
|
|
||||||
create_project(&p, "alt").unwrap();
|
|
||||||
assert!(set_todo_in(&p, "alt", true).is_err());
|
|
||||||
assert!(!todo_state_in(&p, "alt").unwrap());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+190
-25
@@ -57,9 +57,43 @@ fn handle(method: &str, req: &Value) -> Option<Value> {
|
|||||||
"serverInfo": { "name": "text-panel", "version": env!("CARGO_PKG_VERSION") },
|
"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/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",
|
"name": "write_panel",
|
||||||
"description":
|
"description":
|
||||||
"Legt einen längeren Entwurf (ADR, E-Mail, Dokument, Spezifikation, \
|
"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",
|
"name": "write_commands",
|
||||||
"description":
|
"description":
|
||||||
"Listet Shell-Befehle, die der Nutzer ausführen soll, als kopierbare \
|
"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"],
|
"required": ["commands"],
|
||||||
},
|
},
|
||||||
},
|
}),
|
||||||
{
|
"show_commands" => json!({
|
||||||
"name": "show_commands",
|
"name": "show_commands",
|
||||||
"description":
|
"description":
|
||||||
"Zeigt die Befehls-History im ai-control-Panel (Kachel-Ansicht mit \
|
"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“, \
|
wenn der Nutzer die Befehlsliste sehen will („zeig die Befehle“, \
|
||||||
„zeige die Befehlsliste“).",
|
„zeige die Befehlsliste“).",
|
||||||
"inputSchema": { "type": "object", "properties": {} },
|
"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",
|
"name": "archive_panel",
|
||||||
"description":
|
"description":
|
||||||
"Archiviert den aktuell im Panel liegenden Entwurf dauerhaft als \
|
"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",
|
"name": "show_archive",
|
||||||
"description":
|
"description":
|
||||||
"Zeigt die Archiv-Übersicht des Projekts als Wiki-Seite im Panel: \
|
"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",
|
"name": "search_archive",
|
||||||
"description":
|
"description":
|
||||||
"Volltext-Suche über das Panel-Archiv des Projekts (FTS5-Syntax: \
|
"Volltext-Suche über das Panel-Archiv des Projekts (FTS5-Syntax: \
|
||||||
@@ -200,20 +280,26 @@ fn handle(method: &str, req: &Value) -> Option<Value> {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
}),
|
||||||
],
|
other => unreachable!("tool_def ohne Definition: {other}"),
|
||||||
})),
|
|
||||||
"tools/call" => Some(call_tool(req)),
|
|
||||||
"ping" => Some(json!({})),
|
|
||||||
_ => None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn call_tool(req: &Value) -> Value {
|
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_panel" => call_write(req),
|
||||||
"write_commands" => call_write_commands(req),
|
"write_commands" => call_write_commands(req),
|
||||||
"show_commands" => call_show_commands(),
|
"show_commands" => call_show_commands(),
|
||||||
|
"write_todos" => call_write_todos(req),
|
||||||
|
"show_todos" => call_show_todos(),
|
||||||
"archive_panel" => call_archive(req),
|
"archive_panel" => call_archive(req),
|
||||||
"search_archive" => call_search(req),
|
"search_archive" => call_search(req),
|
||||||
"show_archive" => call_show_archive(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
|
/// Öffnet eine Kachel-Ansicht, ohne der Datei etwas hinzuzufügen: mtime
|
||||||
/// History-Datei anfassen genügt — der Watcher im Terminal-Prozess meldet sie
|
/// anfassen genügt — der Watcher im Terminal-Prozess meldet die Datei als
|
||||||
/// als `commands-update`, das Panel schaltet auf „Befehle“ um.
|
/// Update, das Panel schaltet auf den zugehörigen Tab um.
|
||||||
fn call_show_commands() -> Value {
|
fn touch_buffer(env: &str, ok_msg: &str, err_ctx: &str) -> Value {
|
||||||
let path = match env_path("AI_CONTROL_COMMANDS") {
|
let path = match env_path(env) {
|
||||||
Ok(path) => path,
|
Ok(path) => path,
|
||||||
Err(e) => return e,
|
Err(e) => return e,
|
||||||
};
|
};
|
||||||
@@ -330,8 +416,87 @@ fn call_show_commands() -> Value {
|
|||||||
.open(&path)
|
.open(&path)
|
||||||
.and_then(|f| f.set_modified(std::time::SystemTime::now()));
|
.and_then(|f| f.set_modified(std::time::SystemTime::now()));
|
||||||
match res {
|
match res {
|
||||||
Ok(()) => ok("Befehls-History im Panel geöffnet.".into()),
|
Ok(()) => ok(ok_msg.into()),
|
||||||
Err(e) => err(format!("Command-History nicht erreichbar: {e}")),
|
Err(e) => err(format!("{err_ctx}: {e}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn call_show_commands() -> Value {
|
||||||
|
touch_buffer(
|
||||||
|
"AI_CONTROL_COMMANDS",
|
||||||
|
"Befehls-History im Panel geöffnet.",
|
||||||
|
"Command-History nicht erreichbar",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn call_show_todos() -> Value {
|
||||||
|
touch_buffer(
|
||||||
|
"AI_CONTROL_TODOS",
|
||||||
|
"ToDo-Liste im Panel geöffnet.",
|
||||||
|
"ToDo-Liste nicht erreichbar",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prüft ein Fälligkeitsdatum: YYYY-MM-DD, Monat 01–12, Tag 01–31.
|
||||||
|
pub(crate) fn valid_due(due: &str) -> bool {
|
||||||
|
let b = due.as_bytes();
|
||||||
|
if b.len() != 10 || b[4] != b'-' || b[7] != b'-' {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let (y, m, d) = (
|
||||||
|
due[0..4].parse::<u16>(),
|
||||||
|
due[5..7].parse::<u8>(),
|
||||||
|
due[8..10].parse::<u8>(),
|
||||||
|
);
|
||||||
|
matches!((y, m, d), (Ok(_), Ok(m), Ok(d)) if (1..=12).contains(&m) && (1..=31).contains(&d))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hängt ToDos an die persistente Liste (JSONL, ein ToDo pro Zeile); jedes
|
||||||
|
/// bekommt eine stabile ID fürs Kachel-Löschen. Der Watcher zieht den neuen
|
||||||
|
/// Stand als `todos-update` ins Panel.
|
||||||
|
fn call_write_todos(req: &Value) -> Value {
|
||||||
|
let path = match env_path("AI_CONTROL_TODOS") {
|
||||||
|
Ok(path) => path,
|
||||||
|
Err(e) => return e,
|
||||||
|
};
|
||||||
|
let Some(todos) = req["params"]["arguments"]["todos"].as_array() else {
|
||||||
|
return err("todos fehlt".into());
|
||||||
|
};
|
||||||
|
let ts = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_secs())
|
||||||
|
.unwrap_or(0);
|
||||||
|
let mut out = String::new();
|
||||||
|
for todo in todos {
|
||||||
|
let text = todo["text"].as_str().unwrap_or("").trim();
|
||||||
|
if text.is_empty() {
|
||||||
|
return err("ToDo ohne text".into());
|
||||||
|
}
|
||||||
|
let mut rec = json!({
|
||||||
|
"id": uuid::Uuid::new_v4().to_string(),
|
||||||
|
"ts": ts,
|
||||||
|
"text": text,
|
||||||
|
});
|
||||||
|
if let Some(note) = todo["note"].as_str() {
|
||||||
|
rec["note"] = json!(note);
|
||||||
|
}
|
||||||
|
if let Some(due) = todo["due"].as_str() {
|
||||||
|
if !valid_due(due) {
|
||||||
|
return err(format!("ungültiges due-Datum: {due} (erwartet YYYY-MM-DD)"));
|
||||||
|
}
|
||||||
|
rec["due"] = json!(due);
|
||||||
|
}
|
||||||
|
out.push_str(&rec.to_string());
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
let res = std::fs::OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.append(true)
|
||||||
|
.open(&path)
|
||||||
|
.and_then(|mut f| std::io::Write::write_all(&mut f, out.as_bytes()));
|
||||||
|
match res {
|
||||||
|
Ok(()) => ok(format!("{} ToDo(s) auf der Liste.", todos.len())),
|
||||||
|
Err(e) => err(format!("ToDo-Liste nicht schreibbar: {e}")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+203
-54
@@ -126,29 +126,25 @@ pub fn term_start(
|
|||||||
.openpty(PtySize { rows, cols, pixel_width: 0, pixel_height: 0 })
|
.openpty(PtySize { rows, cols, pixel_width: 0, pixel_height: 0 })
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
// Panel-Kanal: leere Datei anlegen (definierter Startzustand für den
|
// Puffer-Kanäle aller Module (Modul-Registry): flüchtige Puffer leeren
|
||||||
// Watcher) und ihren Pfad als AI_CONTROL_PANEL in die PTY geben — der Skill
|
// (Inhalte gelten pro Session), persistente (ToDo) nur anlegen; die Pfade
|
||||||
// schreibt seinen Entwurf dorthin.
|
// für Env und Watcher vormerken. Bewusst alle Module, nicht nur aktive:
|
||||||
let panel_path = panel_file(&project);
|
// Die Kanäle sind billig, und ein mitten in der Session zugeschaltetes
|
||||||
if let Some(parent) = panel_path.parent() {
|
// 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);
|
let _ = std::fs::create_dir_all(parent);
|
||||||
}
|
}
|
||||||
let _ = std::fs::write(&panel_path, "");
|
if !b.persistent || !path.is_file() {
|
||||||
|
let _ = std::fs::write(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));
|
let mut cmd = crate::platform::shell_command(&claude_command(&paths));
|
||||||
cmd.cwd(&cwd);
|
cmd.cwd(&cwd);
|
||||||
@@ -158,10 +154,9 @@ pub fn term_start(
|
|||||||
cmd.env_remove("ANTHROPIC_API_KEY");
|
cmd.env_remove("ANTHROPIC_API_KEY");
|
||||||
cmd.env_remove("ANTHROPIC_AUTH_TOKEN");
|
cmd.env_remove("ANTHROPIC_AUTH_TOKEN");
|
||||||
cmd.env("TERM", "xterm-256color");
|
cmd.env("TERM", "xterm-256color");
|
||||||
cmd.env("AI_CONTROL_PANEL", &panel_path);
|
for (b, path) in &buffers {
|
||||||
cmd.env("AI_CONTROL_COMMANDS", &commands_path);
|
cmd.env(b.env, path);
|
||||||
cmd.env("AI_CONTROL_SEARCH", &search_path);
|
}
|
||||||
cmd.env("AI_CONTROL_WIKI", &wiki_path);
|
|
||||||
cmd.env("AI_CONTROL_PROJECT", &project);
|
cmd.env("AI_CONTROL_PROJECT", &project);
|
||||||
if let Some(pool_dir) = project_pool_dir(&project)? {
|
if let Some(pool_dir) = project_pool_dir(&project)? {
|
||||||
cmd.env("CLAUDE_CONFIG_DIR", pool_dir);
|
cmd.env("CLAUDE_CONFIG_DIR", pool_dir);
|
||||||
@@ -219,14 +214,9 @@ pub fn term_start(
|
|||||||
let _ = app.emit_to(&label, "pty-exit", ());
|
let _ = app.emit_to(&label, "pty-exit", ());
|
||||||
});
|
});
|
||||||
|
|
||||||
spawn_file_watcher(window.app_handle().clone(), panel_path, "panel-update");
|
for (b, path) in buffers {
|
||||||
spawn_file_watcher(
|
spawn_file_watcher(window.app_handle().clone(), path, b.event);
|
||||||
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");
|
|
||||||
|
|
||||||
terminals.0.lock().unwrap().insert(
|
terminals.0.lock().unwrap().insert(
|
||||||
window.label().to_string(),
|
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]
|
#[tauri::command]
|
||||||
pub fn panel_read(project: String) -> String {
|
pub fn buffer_read(project: String, buffer: String) -> Result<String, String> {
|
||||||
std::fs::read_to_string(panel_file(&project)).unwrap_or_default()
|
let b = crate::domain::modules::MODULES
|
||||||
}
|
.iter()
|
||||||
|
.flat_map(|m| m.buffers)
|
||||||
/// Aktuelle Command-History (JSONL; Erstbefüllung der Kachel-Ansicht).
|
.find(|b| b.id == buffer)
|
||||||
#[tauri::command]
|
.ok_or_else(|| format!("unbekannter Puffer: {buffer}"))?;
|
||||||
pub fn commands_read(project: String) -> String {
|
Ok(std::fs::read_to_string((b.file)(&project)).unwrap_or_default())
|
||||||
std::fs::read_to_string(commands_file(&project)).unwrap_or_default()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Entfernt einen Befehl aus der Command-History (Löschen einer Kachel im
|
/// 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())
|
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
|
/// Schreibt den Panel-Inhalt (Titel-Edit im Panel). Der Watcher meldet die
|
||||||
/// Änderung als `panel-update` an alle Fenster.
|
/// Änderung als `panel-update` an alle Fenster.
|
||||||
#[tauri::command]
|
#[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())
|
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
|
/// Lädt ein Archiv-Dokument in den Dokument-Puffer (Treffer-Klick in der
|
||||||
/// Suche) — ohne Frontmatter-Block, wie ein frischer Entwurf. Der Watcher
|
/// Suche) — ohne Frontmatter-Block, wie ein frischer Entwurf. Der Watcher
|
||||||
/// meldet den neuen Inhalt als `panel-update`.
|
/// 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())
|
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):
|
/// Öffnet ein Wiki-Ziel (Klick auf einen `[[…]]`-Link oder Suchtreffer):
|
||||||
/// `tag:x` → Schlagwort-Seite, `tag:` → Archiv-Übersicht, sonst
|
/// `tag:x` → Schlagwort-Seite, `tag:` → Archiv-Übersicht, sonst
|
||||||
/// Dokument-Auflösung über den Index. Der `tag:`-Namensraum ist damit dort
|
/// 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();
|
let _ = s.killer.kill();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{replace_todo_line, todo_line};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn todo_zeile_baut_und_prueft() {
|
||||||
|
let line = todo_line("i1", 100, " Aufgabe ", Some("Notiz"), Some("2026-07-24")).unwrap();
|
||||||
|
let rec: serde_json::Value = serde_json::from_str(&line).unwrap();
|
||||||
|
assert_eq!(rec["id"], "i1");
|
||||||
|
assert_eq!(rec["ts"], 100);
|
||||||
|
assert_eq!(rec["text"], "Aufgabe");
|
||||||
|
assert_eq!(rec["note"], "Notiz");
|
||||||
|
assert_eq!(rec["due"], "2026-07-24");
|
||||||
|
|
||||||
|
// Ohne note/due fehlen die Felder.
|
||||||
|
let rec: serde_json::Value =
|
||||||
|
serde_json::from_str(&todo_line("i1", 100, "x", None, None).unwrap()).unwrap();
|
||||||
|
assert!(rec.get("note").is_none());
|
||||||
|
assert!(rec.get("due").is_none());
|
||||||
|
|
||||||
|
assert!(todo_line("i1", 100, " ", None, None).is_err());
|
||||||
|
assert!(todo_line("i1", 100, "x", None, Some("24.07.2026")).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn update_ersetzt_nur_die_passende_zeile() {
|
||||||
|
let raw = concat!(
|
||||||
|
r#"{"id":"a","ts":100,"text":"alt","note":"n"}"#, "\n",
|
||||||
|
r#"{"id":"b","ts":200,"text":"bleibt"}"#, "\n",
|
||||||
|
);
|
||||||
|
let out = replace_todo_line(raw, "a", "neu", None, Some("2026-07-24")).unwrap();
|
||||||
|
let lines: Vec<serde_json::Value> =
|
||||||
|
out.lines().map(|l| serde_json::from_str(l).unwrap()).collect();
|
||||||
|
// ID und ts bleiben, note ist weg, due ist neu.
|
||||||
|
assert_eq!(lines[0]["id"], "a");
|
||||||
|
assert_eq!(lines[0]["ts"], 100);
|
||||||
|
assert_eq!(lines[0]["text"], "neu");
|
||||||
|
assert!(lines[0].get("note").is_none());
|
||||||
|
assert_eq!(lines[0]["due"], "2026-07-24");
|
||||||
|
assert_eq!(lines[1]["text"], "bleibt");
|
||||||
|
|
||||||
|
assert!(replace_todo_line(raw, "fehlt", "x", None, None).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,18 +16,20 @@ function modeSetup() {
|
|||||||
<div id="sc" hidden></div>
|
<div id="sc" hidden></div>
|
||||||
<div id="wc" hidden></div>`;
|
<div id="wc" hidden></div>`;
|
||||||
const el = (id: string) => document.getElementById(id)!;
|
const el = (id: string) => document.getElementById(id)!;
|
||||||
|
const tab = (m: string) =>
|
||||||
|
document.querySelector<HTMLElement>(`[data-mode="${m}"]`)!;
|
||||||
const flush = vi.fn();
|
const flush = vi.fn();
|
||||||
const mode = initPanelMode({
|
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")],
|
draftEls: [el("draft-el")],
|
||||||
commandsContent: el("cc"),
|
|
||||||
searchContent: el("sc"),
|
|
||||||
wikiContent: el("wc"),
|
|
||||||
titleEl: el("title"),
|
titleEl: el("title"),
|
||||||
flush,
|
flush,
|
||||||
});
|
});
|
||||||
const tab = (m: string) =>
|
|
||||||
document.querySelector<HTMLElement>(`[data-mode="${m}"]`)!;
|
|
||||||
return { mode, flush, el, tab };
|
return { mode, flush, el, tab };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+45
-85
@@ -7,13 +7,7 @@
|
|||||||
|
|
||||||
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
|
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
|
||||||
import { storedLocale, t } from "./messages";
|
import { storedLocale, t } from "./messages";
|
||||||
|
import { copyAction, deleteAction, flash, renderTile, stripInvisibles } from "./tiles";
|
||||||
|
|
||||||
/// Entfernt Bidi- und Zero-Width-Steuerzeichen (U+200B–200F, U+202A–202E,
|
|
||||||
/// U+2060–2064, U+2066–2069, U+FEFF) aus Befehlstexten.
|
|
||||||
function stripInvisibles(s: string): string {
|
|
||||||
return s.replace(/[\u200B-\u200F\u202A-\u202E\u2060-\u2064\u2066-\u2069\uFEFF]/g, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CommandEntry {
|
interface CommandEntry {
|
||||||
cmd: string;
|
cmd: string;
|
||||||
@@ -42,45 +36,6 @@ function fmtTime(ts: number): string {
|
|||||||
: `${d.toLocaleDateString(loc)} ${hm}`;
|
: `${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(
|
export function initCommandsView(
|
||||||
container: HTMLElement,
|
container: HTMLElement,
|
||||||
onDelete: (id: string) => void,
|
onDelete: (id: string) => void,
|
||||||
@@ -126,26 +81,23 @@ export function initCommandsView(
|
|||||||
|
|
||||||
cmds.forEach((entry) => {
|
cmds.forEach((entry) => {
|
||||||
// Unsichtbare Steuerzeichen (Bidi, Zero-Width) aus Anzeige UND
|
// Unsichtbare Steuerzeichen (Bidi, Zero-Width) aus Anzeige UND
|
||||||
// Kopie halten — sonst sieht der Nutzer einen anderen Befehl, als
|
// Kopie halten (stripInvisibles); der Lösch-Abgleich läuft weiter
|
||||||
// die Zwischenablage enthält. Der Lösch-Abgleich läuft weiter über
|
// über den Original-String.
|
||||||
// den Original-String.
|
|
||||||
const visible = stripInvisibles(entry.cmd);
|
const visible = stripInvisibles(entry.cmd);
|
||||||
const tile = document.createElement("div");
|
block.append(
|
||||||
tile.className = "cmd-tile";
|
renderTile({
|
||||||
const body = document.createElement("div");
|
cls: "cmd-tile",
|
||||||
body.className = "cmd-body";
|
bodyCls: "cmd-body",
|
||||||
const cmd = document.createElement("div");
|
parts: [
|
||||||
cmd.className = "cmd-text";
|
{ cls: "cmd-text", text: visible },
|
||||||
cmd.textContent = visible;
|
...(entry.note ? [{ cls: "cmd-note", text: entry.note }] : []),
|
||||||
body.append(cmd);
|
],
|
||||||
if (entry.note) {
|
actions: [
|
||||||
const note = document.createElement("div");
|
copyAction(t("commands.copyOne"), () => visible),
|
||||||
note.className = "cmd-note";
|
deleteAction(t("commands.removeOne"), () => onDelete(entry.id ?? "")),
|
||||||
note.textContent = entry.note;
|
],
|
||||||
body.append(note);
|
}),
|
||||||
}
|
);
|
||||||
tile.append(body, copyBtn(() => visible), deleteBtn(() => onDelete(entry.id ?? "")));
|
|
||||||
block.append(tile);
|
|
||||||
});
|
});
|
||||||
container.append(block);
|
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 } = {
|
/// Ein Tab im Modus-Umschalter; kommt aus der Modul-Registry (panel-wiring).
|
||||||
draft: t("panel.tabDraft"),
|
export interface ModeTab {
|
||||||
commands: t("panel.tabCommands"),
|
mode: PanelMode;
|
||||||
search: t("panel.tabSearch"),
|
btn: HTMLElement;
|
||||||
wiki: t("panel.tabWiki"),
|
/// 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
|
/// zugehöriger Kopf-Controls gegen die jeweilige Ansicht aus. Ein Wechsel bei
|
||||||
/// offener Inhalts-Bearbeitung speichert den Entwurf (flush) statt zu
|
/// offener Inhalts-Bearbeitung speichert den Entwurf (flush) statt zu
|
||||||
/// blockieren.
|
/// blockieren.
|
||||||
export function initPanelMode(opts: {
|
export function initPanelMode(opts: {
|
||||||
tabsEl: HTMLElement;
|
tabs: ModeTab[];
|
||||||
draftEls: HTMLElement[];
|
draftEls: HTMLElement[];
|
||||||
commandsContent: HTMLElement;
|
|
||||||
searchContent: HTMLElement;
|
|
||||||
wikiContent: HTMLElement;
|
|
||||||
titleEl: HTMLElement;
|
titleEl: HTMLElement;
|
||||||
flush: () => void;
|
flush: () => void;
|
||||||
}) {
|
}) {
|
||||||
// `null` = kein Tab aktiv (Panel zugeklappt).
|
// `null` = kein Tab aktiv (Panel zugeklappt).
|
||||||
let mode: PanelMode | null = "draft";
|
let mode: PanelMode | null = "draft";
|
||||||
let draftTitle = "";
|
let draftTitle = "";
|
||||||
const tabs = [...opts.tabsEl.querySelectorAll<HTMLElement>("[data-mode]")];
|
|
||||||
|
|
||||||
function apply() {
|
function apply() {
|
||||||
opts.commandsContent.hidden = mode !== "commands";
|
for (const tab of opts.tabs) {
|
||||||
opts.searchContent.hidden = mode !== "search";
|
if (tab.content) tab.content.hidden = mode !== tab.mode;
|
||||||
opts.wikiContent.hidden = mode !== "wiki";
|
tab.btn.classList.toggle("active", tab.mode === mode);
|
||||||
|
}
|
||||||
for (const el of opts.draftEls) el.hidden = mode !== "draft";
|
for (const el of opts.draftEls) el.hidden = mode !== "draft";
|
||||||
for (const t of tabs) t.classList.toggle("active", t.dataset.mode === mode);
|
const active = opts.tabs.find((tab) => tab.mode === mode);
|
||||||
if (mode && mode !== "draft") opts.titleEl.textContent = LABEL[mode];
|
if (mode && mode !== "draft" && active) opts.titleEl.textContent = active.label;
|
||||||
}
|
}
|
||||||
|
|
||||||
function to(m: PanelMode) {
|
function to(m: PanelMode) {
|
||||||
@@ -215,10 +170,15 @@ export function initPanelMode(opts: {
|
|||||||
function clear() {
|
function clear() {
|
||||||
if (mode === "draft") draftTitle = opts.titleEl.textContent || t("panel.tabDraft");
|
if (mode === "draft") draftTitle = opts.titleEl.textContent || t("panel.tabDraft");
|
||||||
mode = null;
|
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();
|
apply();
|
||||||
return { to, clear, current: () => mode };
|
return { to, clear, current: () => mode };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -157,6 +157,12 @@ function contractHome(p: string): string {
|
|||||||
return p.replace(/^\/(?:home|Users)\/[^/]+/, "~");
|
return p.replace(/^\/(?:home|Users)\/[^/]+/, "~");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ModuleState {
|
||||||
|
id: string;
|
||||||
|
core: boolean;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
interface TerminalSettings {
|
interface TerminalSettings {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -164,22 +170,24 @@ interface TerminalSettings {
|
|||||||
theme: string;
|
theme: string;
|
||||||
icon: string | null;
|
icon: string | null;
|
||||||
title: string;
|
title: string;
|
||||||
todo: boolean;
|
|
||||||
workDirs: string[];
|
workDirs: string[];
|
||||||
archiveHome: string | null;
|
archiveHome: string | null;
|
||||||
|
modules: ModuleState[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const settings = ref<TerminalSettings | null>(null);
|
const settings = ref<TerminalSettings | null>(null);
|
||||||
|
|
||||||
async function openSettings(p: Project) {
|
async function openSettings(p: Project) {
|
||||||
try {
|
try {
|
||||||
const todo = await invoke<boolean>("todo_state", { project: p.id });
|
|
||||||
const workDirs = await invoke<string[]>("project_work_dirs", {
|
const workDirs = await invoke<string[]>("project_work_dirs", {
|
||||||
project: p.id,
|
project: p.id,
|
||||||
});
|
});
|
||||||
const archiveHome = await invoke<string | null>("panel_archive_dir_cmd", {
|
const archiveHome = await invoke<string | null>("panel_archive_dir_cmd", {
|
||||||
project: p.id,
|
project: p.id,
|
||||||
});
|
});
|
||||||
|
const modules = await invoke<ModuleState[]>("module_registry", {
|
||||||
|
project: p.id,
|
||||||
|
});
|
||||||
settings.value = {
|
settings.value = {
|
||||||
id: p.id,
|
id: p.id,
|
||||||
name: p.name,
|
name: p.name,
|
||||||
@@ -187,15 +195,27 @@ async function openSettings(p: Project) {
|
|||||||
theme: p.terminal.theme ?? "mocha",
|
theme: p.terminal.theme ?? "mocha",
|
||||||
icon: p.terminal.icon,
|
icon: p.terminal.icon,
|
||||||
title: p.terminal.title ?? "",
|
title: p.terminal.title ?? "",
|
||||||
todo,
|
|
||||||
workDirs,
|
workDirs,
|
||||||
archiveHome,
|
archiveHome,
|
||||||
|
modules,
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = String(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
|
// Projektordner neu zuordnen (Root, in dem die Session startet); verschoben
|
||||||
// wird nichts — der gewählte Ordner ist der neue Ort des Projekts.
|
// wird nichts — der gewählte Ordner ist der neue Ort des Projekts.
|
||||||
async function changeProjectDir() {
|
async function changeProjectDir() {
|
||||||
@@ -308,7 +328,6 @@ async function saveSettings() {
|
|||||||
icon: s.icon,
|
icon: s.icon,
|
||||||
title: title === "" ? null : title,
|
title: title === "" ? null : title,
|
||||||
});
|
});
|
||||||
await invoke("set_todo", { project: s.id, enabled: s.todo });
|
|
||||||
settings.value = null;
|
settings.value = null;
|
||||||
await refresh();
|
await refresh();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -325,7 +344,6 @@ interface Wizard {
|
|||||||
workDir: string | null;
|
workDir: string | null;
|
||||||
title: string;
|
title: string;
|
||||||
theme: string;
|
theme: string;
|
||||||
todo: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const wizard = ref<Wizard | null>(null);
|
const wizard = ref<Wizard | null>(null);
|
||||||
@@ -341,7 +359,6 @@ function openWizard() {
|
|||||||
workDir: null,
|
workDir: null,
|
||||||
title: "",
|
title: "",
|
||||||
theme: "mocha",
|
theme: "mocha",
|
||||||
todo: false,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -396,7 +413,6 @@ async function createProject() {
|
|||||||
icon: null,
|
icon: null,
|
||||||
title: title === "" ? null : title,
|
title: title === "" ? null : title,
|
||||||
},
|
},
|
||||||
todo: w.todo,
|
|
||||||
});
|
});
|
||||||
wizard.value = null;
|
wizard.value = null;
|
||||||
await refresh();
|
await refresh();
|
||||||
@@ -410,7 +426,6 @@ interface DeletePreview {
|
|||||||
projectDir: string;
|
projectDir: string;
|
||||||
aiControlDir: boolean;
|
aiControlDir: boolean;
|
||||||
archivePermission: boolean;
|
archivePermission: boolean;
|
||||||
todoHook: boolean;
|
|
||||||
panelFiles: number;
|
panelFiles: number;
|
||||||
archiveHome: string | null;
|
archiveHome: string | null;
|
||||||
archiveDocs: number;
|
archiveDocs: number;
|
||||||
@@ -619,13 +634,6 @@ onUnmounted(() => window.clearInterval(timer));
|
|||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</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>
|
<p class="hint">{{ $t("projects.wizardHint") }}</p>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button type="button" @click="wizard = null">
|
<button type="button" @click="wizard = null">
|
||||||
@@ -693,7 +701,6 @@ onUnmounted(() => window.clearInterval(timer));
|
|||||||
<li>{{ $t("projects.artRegistry") }}</li>
|
<li>{{ $t("projects.artRegistry") }}</li>
|
||||||
<li v-if="pendingDelete.preview.aiControlDir">{{ $t("projects.artAiControl") }}</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.archivePermission">{{ $t("projects.artArchivePerm") }}</li>
|
||||||
<li v-if="pendingDelete.preview.todoHook">{{ $t("projects.artTodoHook") }}</li>
|
|
||||||
<li v-if="pendingDelete.preview.panelFiles">
|
<li v-if="pendingDelete.preview.panelFiles">
|
||||||
{{ $t("projects.artPanelFiles", pendingDelete.preview.panelFiles) }}
|
{{ $t("projects.artPanelFiles", pendingDelete.preview.panelFiles) }}
|
||||||
</li>
|
</li>
|
||||||
@@ -801,7 +808,17 @@ onUnmounted(() => window.clearInterval(timer));
|
|||||||
<button @click="addWorkDir">{{ $t("projects.addWorkDir") }}</button>
|
<button @click="addWorkDir">{{ $t("projects.addWorkDir") }}</button>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="srow">
|
<p class="ghint">{{ $t("projects.writesImmediately") }}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<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="slbl">{{ $t("projects.archive") }}</span>
|
||||||
<span class="sval">
|
<span class="sval">
|
||||||
<span v-if="settings.archiveHome" class="spath hover-pop">
|
<span v-if="settings.archiveHome" class="spath hover-pop">
|
||||||
@@ -817,16 +834,10 @@ onUnmounted(() => window.clearInterval(timer));
|
|||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
<p class="ghint">{{ $t("projects.writesImmediately") }}</p>
|
<p class="ghint">{{ $t("projects.writesImmediately") }}</p>
|
||||||
</section>
|
</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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
@@ -1016,12 +1027,6 @@ onUnmounted(() => window.clearInterval(timer));
|
|||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Todo-Zeile: volle Dialogbreite, der Text wird nie gekürzt. */
|
|
||||||
.todo-line {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: var(--subtext);
|
|
||||||
}
|
|
||||||
|
|
||||||
.spath {
|
.spath {
|
||||||
font-family: var(--mono);
|
font-family: var(--mono);
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ describe("Nachrichtentabellen", () => {
|
|||||||
"Name",
|
"Name",
|
||||||
"Theme",
|
"Theme",
|
||||||
"Wiki",
|
"Wiki",
|
||||||
|
"ToDo",
|
||||||
"MD",
|
"MD",
|
||||||
"Autostart",
|
"Autostart",
|
||||||
"Input",
|
"Input",
|
||||||
@@ -44,14 +45,13 @@ describe("Nachrichtentabellen", () => {
|
|||||||
"Cache ↓",
|
"Cache ↓",
|
||||||
"+ oAuth",
|
"+ oAuth",
|
||||||
"+ apiKey",
|
"+ apiKey",
|
||||||
|
"+ ToDo",
|
||||||
"System",
|
"System",
|
||||||
"Terminal",
|
"Terminal",
|
||||||
"Terminal — {name}",
|
"Terminal — {name}",
|
||||||
".desktop-Starter",
|
".desktop-Starter",
|
||||||
".ai-control/ (Config + Icon)",
|
".ai-control/ (Config + Icon)",
|
||||||
"Archiv-Berechtigung in .claude/settings.json",
|
"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 gleich = keys(messages.de).filter((k) => {
|
||||||
const de = pick(messages.de, k);
|
const de = pick(messages.de, k);
|
||||||
|
|||||||
+48
-8
@@ -90,14 +90,11 @@ const de = {
|
|||||||
artRegistry: "Registry-Eintrag",
|
artRegistry: "Registry-Eintrag",
|
||||||
artAiControl: ".ai-control/ (Config + Icon)",
|
artAiControl: ".ai-control/ (Config + Icon)",
|
||||||
artArchivePerm: "Archiv-Berechtigung in .claude/settings.json",
|
artArchivePerm: "Archiv-Berechtigung in .claude/settings.json",
|
||||||
artTodoHook: "Todo-Hook in .claude/settings.json",
|
|
||||||
artPanelFiles: "Panel-Kanaldatei | {count} Panel-Kanaldateien",
|
artPanelFiles: "Panel-Kanaldatei | {count} Panel-Kanaldateien",
|
||||||
artDesktop: ".desktop-Starter",
|
artDesktop: ".desktop-Starter",
|
||||||
artArchive: "Archiv {path}",
|
artArchive: "Archiv {path}",
|
||||||
artProjectDir: "Projektordner {path}",
|
artProjectDir: "Projektordner {path}",
|
||||||
artWorkDir: "Arbeitsordner {path}",
|
artWorkDir: "Arbeitsordner {path}",
|
||||||
todo: "Todoliste",
|
|
||||||
todoDesc: "OFFENE-PUNKTE.md bei jedem Sessionstart einspielen",
|
|
||||||
archiveChangeTitle: "Archiv wechseln",
|
archiveChangeTitle: "Archiv wechseln",
|
||||||
archiveChangeText: "Das Archiv wechselt von {old} nach {neu}.",
|
archiveChangeText: "Das Archiv wechselt von {old} nach {neu}.",
|
||||||
archiveMigrate: "Dokumente ins neue Archiv verschieben",
|
archiveMigrate: "Dokumente ins neue Archiv verschieben",
|
||||||
@@ -106,9 +103,30 @@ const de = {
|
|||||||
archiveChangeConfirm: "Wechseln",
|
archiveChangeConfirm: "Wechseln",
|
||||||
groupAppearance: "Darstellung",
|
groupAppearance: "Darstellung",
|
||||||
groupFolders: "Ordner",
|
groupFolders: "Ordner",
|
||||||
groupSession: "Session",
|
groupModules: "Module",
|
||||||
writesImmediately: "Änderungen hier schreiben sofort — ohne Speichern.",
|
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: {
|
pools: {
|
||||||
pool: "Pool",
|
pool: "Pool",
|
||||||
type: "Typ",
|
type: "Typ",
|
||||||
@@ -164,6 +182,8 @@ const de = {
|
|||||||
windowProjects: "Projekte",
|
windowProjects: "Projekte",
|
||||||
tabCommands: "Befehle",
|
tabCommands: "Befehle",
|
||||||
tabCommandsTitle: "Befehls-History",
|
tabCommandsTitle: "Befehls-History",
|
||||||
|
tabTodo: "ToDo",
|
||||||
|
tabTodoTitle: "ToDo-Liste des Projekts",
|
||||||
tabDraft: "Dokument",
|
tabDraft: "Dokument",
|
||||||
tabDraftTitle: "Dokument",
|
tabDraftTitle: "Dokument",
|
||||||
tabWiki: "Wiki",
|
tabWiki: "Wiki",
|
||||||
@@ -315,14 +335,11 @@ const en: typeof de = {
|
|||||||
artRegistry: "registry entry",
|
artRegistry: "registry entry",
|
||||||
artAiControl: ".ai-control/ (config + icon)",
|
artAiControl: ".ai-control/ (config + icon)",
|
||||||
artArchivePerm: "archive permission in .claude/settings.json",
|
artArchivePerm: "archive permission in .claude/settings.json",
|
||||||
artTodoHook: "todo hook in .claude/settings.json",
|
|
||||||
artPanelFiles: "panel channel file | {count} panel channel files",
|
artPanelFiles: "panel channel file | {count} panel channel files",
|
||||||
artDesktop: ".desktop launcher",
|
artDesktop: ".desktop launcher",
|
||||||
artArchive: "archive {path}",
|
artArchive: "archive {path}",
|
||||||
artProjectDir: "project folder {path}",
|
artProjectDir: "project folder {path}",
|
||||||
artWorkDir: "working folder {path}",
|
artWorkDir: "working folder {path}",
|
||||||
todo: "Todo list",
|
|
||||||
todoDesc: "Inject OFFENE-PUNKTE.md at every session start",
|
|
||||||
archiveChangeTitle: "Change archive",
|
archiveChangeTitle: "Change archive",
|
||||||
archiveChangeText: "The archive changes from {old} to {neu}.",
|
archiveChangeText: "The archive changes from {old} to {neu}.",
|
||||||
archiveMigrate: "Move documents to the new archive",
|
archiveMigrate: "Move documents to the new archive",
|
||||||
@@ -331,9 +348,30 @@ const en: typeof de = {
|
|||||||
archiveChangeConfirm: "Change",
|
archiveChangeConfirm: "Change",
|
||||||
groupAppearance: "Appearance",
|
groupAppearance: "Appearance",
|
||||||
groupFolders: "Folders",
|
groupFolders: "Folders",
|
||||||
groupSession: "Session",
|
groupModules: "Modules",
|
||||||
writesImmediately: "Changes here are written immediately — no save step.",
|
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: {
|
pools: {
|
||||||
pool: "Pool",
|
pool: "Pool",
|
||||||
type: "Type",
|
type: "Type",
|
||||||
@@ -389,6 +427,8 @@ const en: typeof de = {
|
|||||||
windowProjects: "Projects",
|
windowProjects: "Projects",
|
||||||
tabCommands: "Commands",
|
tabCommands: "Commands",
|
||||||
tabCommandsTitle: "Command history",
|
tabCommandsTitle: "Command history",
|
||||||
|
tabTodo: "ToDo",
|
||||||
|
tabTodoTitle: "Project to-do list",
|
||||||
tabDraft: "Document",
|
tabDraft: "Document",
|
||||||
tabDraftTitle: "Document",
|
tabDraftTitle: "Document",
|
||||||
tabWiki: "Wiki",
|
tabWiki: "Wiki",
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { initSearchView } from "../search-view";
|
||||||
|
import { initWikiView } from "../wiki-view";
|
||||||
|
import type { PanelTab } from "./index";
|
||||||
|
|
||||||
|
export const wikiTab: PanelTab = {
|
||||||
|
mode: "wiki",
|
||||||
|
module: "archive",
|
||||||
|
buffer: "wiki",
|
||||||
|
labelKey: "panel.tabWiki",
|
||||||
|
titleKey: "panel.tabWikiTitle",
|
||||||
|
init: (container, ctx) => initWikiView(container, ctx.openWiki),
|
||||||
|
// Wiki-Tab bei leerem Puffer (Session-Start): Übersicht direkt laden.
|
||||||
|
onActivate: (view, ctx) => {
|
||||||
|
if (view.empty()) ctx.openWiki("tag:");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const searchTab: PanelTab = {
|
||||||
|
mode: "search",
|
||||||
|
module: "archive",
|
||||||
|
buffer: "search",
|
||||||
|
labelKey: "panel.tabSearch",
|
||||||
|
titleKey: "panel.tabSearchTitle",
|
||||||
|
sepAfter: true,
|
||||||
|
// Treffer-Klick lädt das Dokument in den Dokument-Tab (dort editier- und
|
||||||
|
// archivierbar); der Sprung ins Wiki geht von dort aus.
|
||||||
|
init: (container, ctx) =>
|
||||||
|
initSearchView(container, ctx.openDoc, (raw) => {
|
||||||
|
// `#tag`-Tokens filtern aufs Schlagwort, der Rest ist die Volltext-Query.
|
||||||
|
const words = raw.split(/\s+/).filter(Boolean);
|
||||||
|
const tag = words.find((w) => w.startsWith("#"))?.slice(1) ?? null;
|
||||||
|
const query = words.filter((w) => !w.startsWith("#")).join(" ");
|
||||||
|
void invoke("search_run", { project: ctx.project, query, tag }).catch((e) =>
|
||||||
|
ctx.toast(`Suche fehlgeschlagen: ${e}`),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
};
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { initCommandsView } from "../commands-view";
|
||||||
|
import type { PanelTab } from "./index";
|
||||||
|
|
||||||
|
export const commandsTab: PanelTab = {
|
||||||
|
mode: "commands",
|
||||||
|
module: "commands",
|
||||||
|
buffer: "commands",
|
||||||
|
labelKey: "panel.tabCommands",
|
||||||
|
titleKey: "panel.tabCommandsTitle",
|
||||||
|
sepAfter: true,
|
||||||
|
init: (container, ctx) =>
|
||||||
|
initCommandsView(container, (id) =>
|
||||||
|
invoke("commands_delete", { project: ctx.project, id }),
|
||||||
|
),
|
||||||
|
};
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
/// Modul-Registry des Panels: welche Tabs es gibt, zu welchem Backend-Modul
|
||||||
|
/// sie gehören und wie ihre Ansicht entsteht. panel-wiring baut Tab-Knöpfe
|
||||||
|
/// und Content-Container aus dieser Liste; welche Tabs erscheinen,
|
||||||
|
/// entscheidet `enabled_modules` (Backend-Registry in domain/modules.rs).
|
||||||
|
/// Puffer-Konvention: Erstbefüllung per `buffer_read`, Updates kommen als
|
||||||
|
/// `<buffer>-update`-Event.
|
||||||
|
|
||||||
|
import { commandsTab } from "./commands";
|
||||||
|
import { searchTab, wikiTab } from "./archive";
|
||||||
|
import { todoTab } from "./todo";
|
||||||
|
|
||||||
|
export interface ModuleView {
|
||||||
|
set(text: string): void;
|
||||||
|
empty(): boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gemeinsame Dienste für Modul-Ansichten — alles, was mehr als ein Modul
|
||||||
|
/// braucht; Modulspezifisches (eigene Invokes) macht der Deskriptor selbst.
|
||||||
|
export interface ModuleCtx {
|
||||||
|
project: string;
|
||||||
|
toast(msg: string): void;
|
||||||
|
/// Dokument in den Entwurfs-Tab laden (Treffer-Klick).
|
||||||
|
openDoc(path: string): void;
|
||||||
|
/// Wiki-Ziel öffnen (Wikilink, Chip, `tag:`-Namensraum).
|
||||||
|
openWiki(name: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PanelTab {
|
||||||
|
/// data-mode des Tab-Knopfs und ID-Präfix des Containers (`<mode>-content`,
|
||||||
|
/// zugleich CSS-Anker der Ansicht).
|
||||||
|
mode: string;
|
||||||
|
/// Backend-Modul (enabled_modules) — bestimmt, ob der Tab erscheint.
|
||||||
|
module: string;
|
||||||
|
/// Puffer-ID für buffer_read; Updates als `<buffer>-update`.
|
||||||
|
buffer: string;
|
||||||
|
labelKey: string;
|
||||||
|
titleKey: string;
|
||||||
|
/// Visueller Trenner hinter dem Tab.
|
||||||
|
sepAfter?: boolean;
|
||||||
|
/// Baut die Ansicht des Tabs. Ohne init verdrahtet panel-wiring die
|
||||||
|
/// Ansicht selbst (Entwurfs-Tab — Kern mit eigenem Markup).
|
||||||
|
init?(container: HTMLElement, ctx: ModuleCtx): ModuleView;
|
||||||
|
/// Nach Tab-Klick (z. B. Wiki: leere Ansicht lädt die Übersicht).
|
||||||
|
onActivate?(view: ModuleView, ctx: ModuleCtx): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PANEL_TABS: PanelTab[] = [
|
||||||
|
todoTab,
|
||||||
|
commandsTab,
|
||||||
|
{
|
||||||
|
mode: "draft",
|
||||||
|
module: "draft",
|
||||||
|
buffer: "panel",
|
||||||
|
labelKey: "panel.tabDraft",
|
||||||
|
titleKey: "panel.tabDraftTitle",
|
||||||
|
},
|
||||||
|
wikiTab,
|
||||||
|
searchTab,
|
||||||
|
];
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { initTodoView, type TodoEdit } from "../todo-view";
|
||||||
|
import type { PanelTab } from "./index";
|
||||||
|
|
||||||
|
export const todoTab: PanelTab = {
|
||||||
|
mode: "todo",
|
||||||
|
module: "todo",
|
||||||
|
buffer: "todos",
|
||||||
|
labelKey: "panel.tabTodo",
|
||||||
|
titleKey: "panel.tabTodoTitle",
|
||||||
|
init: (container, ctx) =>
|
||||||
|
initTodoView(
|
||||||
|
container,
|
||||||
|
(id) => invoke("todos_delete", { project: ctx.project, id }),
|
||||||
|
(todo: TodoEdit) => {
|
||||||
|
const args = {
|
||||||
|
project: ctx.project,
|
||||||
|
text: todo.text,
|
||||||
|
note: todo.note ?? null,
|
||||||
|
due: todo.due ?? null,
|
||||||
|
};
|
||||||
|
void (
|
||||||
|
todo.id
|
||||||
|
? invoke("todos_update", { ...args, id: todo.id })
|
||||||
|
: invoke("todos_add", args)
|
||||||
|
).catch((e) => ctx.toast(String(e)));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
};
|
||||||
@@ -92,6 +92,143 @@
|
|||||||
system-ui,
|
system-ui,
|
||||||
sans-serif;
|
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 {
|
#search-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
import { renderMarkdown } from "./markdown";
|
import { renderMarkdown } from "./markdown";
|
||||||
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
|
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
|
||||||
import { flash } from "./commands-view";
|
import { flash } from "./tiles";
|
||||||
import { t } from "./messages";
|
import { t } from "./messages";
|
||||||
|
|
||||||
export interface PanelView {
|
export interface PanelView {
|
||||||
|
|||||||
+83
-82
@@ -1,26 +1,24 @@
|
|||||||
/// Gemeinsame Panel-Verdrahtung für das angedockte Panel (terminal.ts) und
|
/// Gemeinsame Panel-Verdrahtung für das angedockte Panel (terminal.ts) und
|
||||||
/// das abgelöste Fenster (panel.ts): Entwurfs-Ansicht, Befehls- und
|
/// das abgelöste Fenster (panel.ts): die Entwurfs-Ansicht als Kern, alle
|
||||||
/// Such-Kacheln, Modus-Umschalter, Wikilink-Klick und die drei Update-Events.
|
/// weiteren Tabs aus der Modul-Registry (src/modules) — Tab-Knöpfe und
|
||||||
/// Die Element-IDs sind in beiden HTML-Dateien identisch; die Draft-Controls
|
/// Content-Container entstehen hier aus der Liste der aktiven Module,
|
||||||
/// trägt das Markup als `.draft-only`.
|
/// 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 { invoke } from "@tauri-apps/api/core";
|
||||||
import { listen } from "@tauri-apps/api/event";
|
import { listen } from "@tauri-apps/api/event";
|
||||||
import { initPanelView, type PanelView } from "./panel-view";
|
import { initPanelView, type PanelView } from "./panel-view";
|
||||||
import {
|
import { initPanelMode, type ModeTab, type PanelMode } from "./commands-view";
|
||||||
initCommandsView,
|
import { panelToast } from "./tiles";
|
||||||
initPanelMode,
|
import { PANEL_TABS, type ModuleCtx, type ModuleView } from "./modules";
|
||||||
panelToast,
|
import { t } from "./messages";
|
||||||
type CommandsView,
|
|
||||||
type PanelMode,
|
|
||||||
} from "./commands-view";
|
|
||||||
import { initSearchView } from "./search-view";
|
|
||||||
import { initWikiView } from "./wiki-view";
|
|
||||||
import "./panel-tiles.css";
|
import "./panel-tiles.css";
|
||||||
|
|
||||||
export interface PanelWiring {
|
export interface PanelWiring {
|
||||||
view: PanelView;
|
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 };
|
mode: { to(m: PanelMode): void; clear(): void; current(): PanelMode | null };
|
||||||
/// Entwurfstext beim Start (für die Anfangs-Modus-Entscheidung).
|
/// Entwurfstext beim Start (für die Anfangs-Modus-Entscheidung).
|
||||||
draft: string;
|
draft: string;
|
||||||
@@ -31,24 +29,17 @@ export async function wirePanel(
|
|||||||
onIncoming?: () => void,
|
onIncoming?: () => void,
|
||||||
): Promise<PanelWiring> {
|
): Promise<PanelWiring> {
|
||||||
const titleEl = document.querySelector(".panel-title") as HTMLElement;
|
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>("spellcheck_lang"),
|
||||||
invoke<string>("panel_read", { project }),
|
invoke<string>("buffer_read", { project, buffer: "panel" }),
|
||||||
invoke<string>("commands_read", { project }),
|
|
||||||
invoke<string>("search_read", { project }),
|
|
||||||
invoke<string>("wiki_read", { project }),
|
|
||||||
invoke<string | null>("panel_archive_dir_cmd", { project }),
|
|
||||||
]);
|
]);
|
||||||
|
const tabs = PANEL_TABS.filter((tab) => enabled.includes(tab.module));
|
||||||
|
|
||||||
// Ohne Archiv (in den Projekt-Einstellungen abgewählt) gibt es nur Befehle
|
// Ohne Archiv-Modul (abgewählt oder kein Archiv-Home) verschwinden auch
|
||||||
// und Dokument — Wiki-/Suche-Tabs und die Archiv-Werkzeuge verschwinden.
|
// die Archiv-Werkzeuge des Entwurfs-Tabs.
|
||||||
if (!archiveHome) {
|
if (!enabled.includes("archive")) {
|
||||||
for (const sel of [
|
for (const sel of ["#panel-archive", "#panel-wiki-jump"]) {
|
||||||
'#panel-tabs [data-mode="wiki"]',
|
|
||||||
'#panel-tabs [data-mode="search"]',
|
|
||||||
"#panel-archive",
|
|
||||||
"#panel-wiki-jump",
|
|
||||||
]) {
|
|
||||||
const el = document.querySelector<HTMLElement>(sel)!;
|
const el = document.querySelector<HTMLElement>(sel)!;
|
||||||
el.hidden = true;
|
el.hidden = true;
|
||||||
// Raus aus der draft-only-Menge, sonst blendet der Modus-Umschalter
|
// 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.
|
// den Kern; das Ergebnis kommt über den Wiki-Puffer und `wiki-update` zurück.
|
||||||
// Fehler (z. B. Ziel nicht im Archiv) erscheinen als Toast.
|
// Fehler (z. B. Ziel nicht im Archiv) erscheinen als Toast.
|
||||||
const openWiki = (name: string) =>
|
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({
|
const view = initPanelView({
|
||||||
content: document.getElementById("panel-content")!,
|
content: document.getElementById("panel-content")!,
|
||||||
@@ -75,57 +74,61 @@ export async function wirePanel(
|
|||||||
onCommit: (text) => invoke("panel_set", { project, text }),
|
onCommit: (text) => invoke("panel_set", { project, text }),
|
||||||
onWikiLink: openWiki,
|
onWikiLink: openWiki,
|
||||||
});
|
});
|
||||||
const cmdView = initCommandsView(
|
|
||||||
document.getElementById("commands-content")!,
|
// Tab-Leiste und Container aus der Registry. Die Container-IDs
|
||||||
(id) => invoke("commands_delete", { project, id }),
|
// (`<mode>-content`) sind zugleich die CSS-Anker der Ansichten; die
|
||||||
);
|
// Container reihen sich hinter #panel-content ein.
|
||||||
// Treffer-Klick lädt das Dokument in den Dokument-Tab (dort editier- und
|
const tabsEl = document.getElementById("panel-tabs")!;
|
||||||
// archivierbar); der Sprung ins Wiki geht von dort aus.
|
const views = new Map<string, ModuleView>();
|
||||||
const searchView = initSearchView(
|
const modeTabs: ModeTab[] = [];
|
||||||
document.getElementById("search-content")!,
|
let anchor = document.getElementById("panel-content")!;
|
||||||
(path) =>
|
for (const tab of tabs) {
|
||||||
void invoke("panel_load", { project, path }).catch((e) => panelToast(String(e))),
|
const btn = document.createElement("button");
|
||||||
(raw) => {
|
btn.className = "panel-btn";
|
||||||
// `#tag`-Tokens filtern aufs Schlagwort, der Rest ist die Volltext-Query.
|
btn.dataset.mode = tab.mode;
|
||||||
const words = raw.split(/\s+/).filter(Boolean);
|
btn.textContent = t(tab.labelKey);
|
||||||
const tag = words.find((w) => w.startsWith("#"))?.slice(1) ?? null;
|
btn.title = t(tab.titleKey);
|
||||||
const query = words.filter((w) => !w.startsWith("#")).join(" ");
|
tabsEl.append(btn);
|
||||||
void invoke("search_run", { project, query, tag }).catch((e) =>
|
if (tab.sepAfter) {
|
||||||
panelToast(`Suche fehlgeschlagen: ${e}`),
|
const sep = document.createElement("span");
|
||||||
);
|
sep.className = "tab-sep";
|
||||||
},
|
tabsEl.append(sep);
|
||||||
);
|
}
|
||||||
const wikiView = initWikiView(document.getElementById("wiki-content")!, openWiki);
|
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({
|
const mode = initPanelMode({
|
||||||
tabsEl: document.getElementById("panel-tabs")!,
|
tabs: modeTabs,
|
||||||
draftEls: [
|
draftEls: [
|
||||||
document.getElementById("panel-content")!,
|
document.getElementById("panel-content")!,
|
||||||
...document.querySelectorAll<HTMLElement>(".draft-only"),
|
...document.querySelectorAll<HTMLElement>(".draft-only"),
|
||||||
],
|
],
|
||||||
commandsContent: document.getElementById("commands-content")!,
|
|
||||||
searchContent: document.getElementById("search-content")!,
|
|
||||||
wikiContent: document.getElementById("wiki-content")!,
|
|
||||||
titleEl,
|
titleEl,
|
||||||
flush: () => void view.flush(),
|
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.
|
// Sprung Dokument → Wiki: löst den angezeigten Titel gegen das Archiv auf.
|
||||||
document
|
document
|
||||||
.getElementById("panel-wiki-jump")!
|
.getElementById("panel-wiki-jump")!
|
||||||
.addEventListener("click", () => void openWiki(titleEl.textContent || ""));
|
.addEventListener("click", () => openWiki(titleEl.textContent || ""));
|
||||||
|
|
||||||
|
view.set(draft);
|
||||||
await listen<string>("panel-update", (e) => {
|
await listen<string>("panel-update", (e) => {
|
||||||
// Erst umschalten, dann setzen: to("draft") restauriert den gemerkten
|
// Erst umschalten, dann setzen: to("draft") restauriert den gemerkten
|
||||||
// Titel — der neue Inhalt (und damit sein Titel) muss danach kommen.
|
// Titel — der neue Inhalt (und damit sein Titel) muss danach kommen.
|
||||||
@@ -133,21 +136,19 @@ export async function wirePanel(
|
|||||||
view.set(e.payload);
|
view.set(e.payload);
|
||||||
onIncoming?.();
|
onIncoming?.();
|
||||||
});
|
});
|
||||||
await listen<string>("commands-update", (e) => {
|
await Promise.all(
|
||||||
cmdView.set(e.payload);
|
tabs
|
||||||
mode.to("commands");
|
.filter((tab) => tab.init)
|
||||||
onIncoming?.();
|
.map(async (tab) => {
|
||||||
});
|
const v = views.get(tab.mode)!;
|
||||||
await listen<string>("search-update", (e) => {
|
v.set(await invoke<string>("buffer_read", { project, buffer: tab.buffer }));
|
||||||
searchView.set(e.payload);
|
await listen<string>(`${tab.buffer}-update`, (e) => {
|
||||||
mode.to("search");
|
v.set(e.payload);
|
||||||
onIncoming?.();
|
mode.to(tab.mode);
|
||||||
});
|
|
||||||
await listen<string>("wiki-update", (e) => {
|
|
||||||
wikiView.set(e.payload);
|
|
||||||
mode.to("wiki");
|
|
||||||
onIncoming?.();
|
onIncoming?.();
|
||||||
});
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
return { view, cmdView, mode, draft };
|
return { view, views, mode, draft };
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-3
@@ -6,7 +6,7 @@ import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
|
|||||||
import { PhysicalPosition, PhysicalSize } from "@tauri-apps/api/dpi";
|
import { PhysicalPosition, PhysicalSize } from "@tauri-apps/api/dpi";
|
||||||
import { emit } from "@tauri-apps/api/event";
|
import { emit } from "@tauri-apps/api/event";
|
||||||
import { wirePanel } from "./panel-wiring";
|
import { wirePanel } from "./panel-wiring";
|
||||||
import { flash, panelToast } from "./commands-view";
|
import { flash, panelToast } from "./tiles";
|
||||||
import { initArchiveForm } from "./archive-form";
|
import { initArchiveForm } from "./archive-form";
|
||||||
import { applyTheme, THEMES } from "./themes";
|
import { applyTheme, THEMES } from "./themes";
|
||||||
import { applyI18n, t } from "./messages";
|
import { applyI18n, t } from "./messages";
|
||||||
@@ -99,8 +99,15 @@ applyTheme(picked);
|
|||||||
document.documentElement.style.background = picked.header;
|
document.documentElement.style.background = picked.header;
|
||||||
document.body.style.background = picked.header;
|
document.body.style.background = picked.header;
|
||||||
|
|
||||||
const { view, cmdView, mode, draft } = await wirePanel(project);
|
const { view, views, mode, draft } = await wirePanel(project);
|
||||||
if (!draft.trim() && !cmdView.empty()) mode.to("commands");
|
// 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
|
// Archivieren: wie im angedockten Panel — Formular aufklappen, Abschicken
|
||||||
// wählt notfalls erst das Archiv-Home per Dialog.
|
// wählt notfalls erst das Archiv-Home per Dialog.
|
||||||
|
|||||||
+12
-11
@@ -5,6 +5,7 @@
|
|||||||
/// Split in <mark>-Elemente übersetzt.
|
/// Split in <mark>-Elemente übersetzt.
|
||||||
|
|
||||||
import { t } from "./messages";
|
import { t } from "./messages";
|
||||||
|
import { renderTile } from "./tiles";
|
||||||
|
|
||||||
interface Hit {
|
interface Hit {
|
||||||
relpath: string;
|
relpath: string;
|
||||||
@@ -99,17 +100,17 @@ export function initSearchView(
|
|||||||
: t("search.noHits", { scope });
|
: t("search.noHits", { scope });
|
||||||
results.append(head);
|
results.append(head);
|
||||||
for (const hit of run.hits) {
|
for (const hit of run.hits) {
|
||||||
const tile = document.createElement("div");
|
results.append(
|
||||||
tile.className = "hit-tile";
|
renderTile({
|
||||||
const title = document.createElement("div");
|
cls: "hit-tile",
|
||||||
title.className = "hit-title";
|
parts: [
|
||||||
title.textContent = hit.title;
|
{ cls: "hit-title", text: hit.title },
|
||||||
const path = document.createElement("div");
|
snippetEl(hit.snippet),
|
||||||
path.className = "hit-path";
|
{ cls: "hit-path", text: hit.relpath },
|
||||||
path.textContent = hit.relpath;
|
],
|
||||||
tile.append(title, snippetEl(hit.snippet), path);
|
onClick: () => onOpen(`${run.home}/${hit.relpath}`),
|
||||||
tile.addEventListener("click", () => onOpen(`${run.home}/${hit.relpath}`));
|
}),
|
||||||
results.append(tile);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+12
-4
@@ -15,7 +15,7 @@ import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
|
|||||||
import { listen } from "@tauri-apps/api/event";
|
import { listen } from "@tauri-apps/api/event";
|
||||||
import { wirePanel } from "./panel-wiring";
|
import { wirePanel } from "./panel-wiring";
|
||||||
import { applyTheme, THEMES } from "./themes";
|
import { applyTheme, THEMES } from "./themes";
|
||||||
import { flash, panelToast } from "./commands-view";
|
import { flash, panelToast } from "./tiles";
|
||||||
import { initArchiveForm } from "./archive-form";
|
import { initArchiveForm } from "./archive-form";
|
||||||
import { applyI18n, t } from "./messages";
|
import { applyI18n, t } from "./messages";
|
||||||
|
|
||||||
@@ -212,12 +212,20 @@ function hidePanel() {
|
|||||||
|
|
||||||
// Gemeinsame Verdrahtung (Views, Modi, Update-Events); jedes eingehende
|
// Gemeinsame Verdrahtung (Views, Modi, Update-Events); jedes eingehende
|
||||||
// Update blendet das angedockte Panel ein, solange es nicht abgelöst ist.
|
// 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;
|
hasContent = true;
|
||||||
if (!detached) showPanel();
|
if (!detached) showPanel();
|
||||||
});
|
});
|
||||||
// Panel startet zugeklappt — kein Tab aktiv, bis eine Ansicht geöffnet wird.
|
// Panel startet zugeklappt — außer das ToDo-Modul ist gewählt und die Liste
|
||||||
mode.clear();
|
// 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.
|
// Tab-Klick im Header öffnet das (angedockte) Panel, falls es zu ist.
|
||||||
const headerTabs = document.getElementById("panel-tabs")!;
|
const headerTabs = document.getElementById("panel-tabs")!;
|
||||||
|
|||||||
+101
@@ -0,0 +1,101 @@
|
|||||||
|
/// Gemeinsamer Kachel-Baustein der Panel-Ansichten (Befehle, Suchtreffer,
|
||||||
|
/// künftig ToDo): deklarativer Tile-Renderer plus die geteilten Aktions-
|
||||||
|
/// Knöpfe und Feedback-Helfer. DOM entsteht per createElement — Inhalte sind
|
||||||
|
/// Fremdtext und gehen nie durch innerHTML (die SVG-Icons der Aktionen sind
|
||||||
|
/// eigene Literale).
|
||||||
|
|
||||||
|
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
|
||||||
|
|
||||||
|
/// Entfernt Bidi- und Zero-Width-Steuerzeichen (U+200B–200F, U+202A–202E,
|
||||||
|
/// U+2060–2064, U+2066–2069, U+FEFF) aus Fremdtexten — sonst sieht der
|
||||||
|
/// Nutzer einen anderen Text, als die Zwischenablage enthält.
|
||||||
|
export function stripInvisibles(s: string): string {
|
||||||
|
return s.replace(/[\u200B-\u200F\u202A-\u202E\u2060-\u2064\u2066-\u2069\uFEFF]/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Kurzes visuelles Feedback (copied/error) — der eine Flash-Helper fürs
|
||||||
|
/// ganze Panel.
|
||||||
|
export function flash(el: HTMLElement, cls: string, ms = 1200) {
|
||||||
|
el.classList.add(cls);
|
||||||
|
setTimeout(() => el.classList.remove(cls), ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sichtbare Fehlermeldung im Panel: kurz eingeblendete Zeile oben rechts.
|
||||||
|
export function panelToast(msg: string) {
|
||||||
|
const t = document.createElement("div");
|
||||||
|
t.className = "panel-toast";
|
||||||
|
t.textContent = msg;
|
||||||
|
document.body.append(t);
|
||||||
|
setTimeout(() => t.remove(), 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Kopier-Knopf: legt `text()` in die Zwischenablage, quittiert mit Flash.
|
||||||
|
export function copyAction(title: string, text: () => string): HTMLButtonElement {
|
||||||
|
const btn = document.createElement("button");
|
||||||
|
btn.className = "panel-btn cmd-copy";
|
||||||
|
btn.title = title;
|
||||||
|
btn.innerHTML =
|
||||||
|
'<svg width="14" height="14" viewBox="0 0 16 16"><rect x="5.5" y="5.5" width="8" height="8" rx="1.5" /><path d="M10.5 3.2V3A1.5 1.5 0 0 0 9 1.5H3A1.5 1.5 0 0 0 1.5 3v6A1.5 1.5 0 0 0 3 10.5h.2" /></svg>';
|
||||||
|
btn.addEventListener("click", async () => {
|
||||||
|
await writeText(text());
|
||||||
|
flash(btn, "copied");
|
||||||
|
});
|
||||||
|
return btn;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function editAction(title: string, onClick: () => void): HTMLButtonElement {
|
||||||
|
const btn = document.createElement("button");
|
||||||
|
btn.className = "panel-btn cmd-edit";
|
||||||
|
btn.title = title;
|
||||||
|
btn.innerHTML =
|
||||||
|
'<svg width="14" height="14" viewBox="0 0 16 16"><path d="M11.1 2.4a1.4 1.4 0 0 1 2 2l-8 8-2.9.9.9-2.9z" /><path d="M9.9 3.6l2 2" /></svg>';
|
||||||
|
btn.addEventListener("click", onClick);
|
||||||
|
return btn;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteAction(title: string, onClick: () => void): HTMLButtonElement {
|
||||||
|
const btn = document.createElement("button");
|
||||||
|
btn.className = "panel-btn cmd-del";
|
||||||
|
btn.title = title;
|
||||||
|
btn.innerHTML =
|
||||||
|
'<svg width="14" height="14" viewBox="0 0 16 16"><path d="M2.5 4.5h11" /><path d="M5.5 4.5V3a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1v1.5" /><path d="M4 4.5l.7 8.6a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9l.7-8.6" /></svg>';
|
||||||
|
btn.addEventListener("click", onClick);
|
||||||
|
return btn;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TileSpec {
|
||||||
|
cls: string;
|
||||||
|
/// Inhalts-Zeilen in Reihenfolge; fertige Elemente (z. B. Snippet mit
|
||||||
|
/// <mark>-Hervorhebung) gehen als Element durch.
|
||||||
|
parts: (HTMLElement | { cls: string; text: string })[];
|
||||||
|
/// Umschließt die parts als eigener Container (Layout neben den actions),
|
||||||
|
/// z. B. "cmd-body".
|
||||||
|
bodyCls?: string;
|
||||||
|
/// Aktions-Knöpfe hinter dem Body (copyAction/deleteAction).
|
||||||
|
actions?: HTMLElement[];
|
||||||
|
onClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderTile(spec: TileSpec): HTMLElement {
|
||||||
|
const tile = document.createElement("div");
|
||||||
|
tile.className = spec.cls;
|
||||||
|
let host = tile;
|
||||||
|
if (spec.bodyCls) {
|
||||||
|
host = document.createElement("div");
|
||||||
|
host.className = spec.bodyCls;
|
||||||
|
tile.append(host);
|
||||||
|
}
|
||||||
|
for (const part of spec.parts) {
|
||||||
|
if (part instanceof HTMLElement) {
|
||||||
|
host.append(part);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const div = document.createElement("div");
|
||||||
|
div.className = part.cls;
|
||||||
|
div.textContent = part.text;
|
||||||
|
host.append(div);
|
||||||
|
}
|
||||||
|
if (spec.actions) tile.append(...spec.actions);
|
||||||
|
if (spec.onClick) tile.addEventListener("click", spec.onClick);
|
||||||
|
return tile;
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
// Kachel-Ansicht der ToDo-Liste: Sortierung, Ampel-Badge, Löschen per ID,
|
||||||
|
// Formular für Anlegen und Bearbeiten.
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { initTodoView } from "./todo-view";
|
||||||
|
|
||||||
|
function iso(offsetDays: number): string {
|
||||||
|
const d = new Date(2026, 6, 22 + offsetDays); // Basis 2026-07-22 (fake time)
|
||||||
|
const p = (n: number) => String(n).padStart(2, "0");
|
||||||
|
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("initTodoView", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date(2026, 6, 22, 12, 0, 0));
|
||||||
|
document.body.innerHTML = `<div id="c"></div>`;
|
||||||
|
});
|
||||||
|
afterEach(() => vi.useRealTimers());
|
||||||
|
|
||||||
|
const jsonl = [
|
||||||
|
JSON.stringify({ id: "a", ts: 100, text: "ohne Datum alt" }),
|
||||||
|
JSON.stringify({ id: "b", ts: 200, text: "ohne Datum neu" }),
|
||||||
|
JSON.stringify({ id: "c", ts: 150, text: "überfällig", due: iso(-1) }),
|
||||||
|
JSON.stringify({ id: "d", ts: 150, text: "bald", note: "mit Notiz", due: iso(2) }),
|
||||||
|
JSON.stringify({ id: "e", ts: 150, text: "später", due: iso(10) }),
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
it("sortiert fällige zuerst und hängt Ampel-Badges an", () => {
|
||||||
|
const view = initTodoView(document.getElementById("c")!, () => {}, () => {});
|
||||||
|
view.set(jsonl);
|
||||||
|
expect(view.empty()).toBe(false);
|
||||||
|
const texts = [...document.querySelectorAll(".todo-text")].map(
|
||||||
|
(el) => el.textContent,
|
||||||
|
);
|
||||||
|
// due aufsteigend, danach ohne Datum neueste oben.
|
||||||
|
expect(texts).toEqual([
|
||||||
|
"überfällig",
|
||||||
|
"bald",
|
||||||
|
"später",
|
||||||
|
"ohne Datum neu",
|
||||||
|
"ohne Datum alt",
|
||||||
|
]);
|
||||||
|
const badges = [...document.querySelectorAll(".todo-due")];
|
||||||
|
expect(badges.map((b) => b.className)).toEqual([
|
||||||
|
"todo-due overdue",
|
||||||
|
"todo-due soon",
|
||||||
|
"todo-due later",
|
||||||
|
]);
|
||||||
|
// Datum nach Locale (Testsprache de).
|
||||||
|
expect(badges[0].textContent).toBe(new Date(2026, 6, 21).toLocaleDateString("de"));
|
||||||
|
expect(document.querySelector(".cmd-note")!.textContent).toBe("mit Notiz");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("meldet Löschen mit der Eintrags-ID", () => {
|
||||||
|
const onDelete = vi.fn();
|
||||||
|
const view = initTodoView(document.getElementById("c")!, onDelete, () => {});
|
||||||
|
view.set(jsonl);
|
||||||
|
const dels = document.querySelectorAll<HTMLElement>(".cmd-del");
|
||||||
|
dels[0].click(); // erste Kachel = überfälliges ToDo (id "c")
|
||||||
|
expect(onDelete).toHaveBeenCalledWith("c");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leerer Puffer meldet empty", () => {
|
||||||
|
const view = initTodoView(document.getElementById("c")!, () => {}, () => {});
|
||||||
|
view.set("");
|
||||||
|
expect(view.empty()).toBe(true);
|
||||||
|
expect(document.querySelectorAll(".cmd-tile").length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Plus-Knopf öffnet das leere Formular und meldet Anlegen ohne ID", () => {
|
||||||
|
const onSave = vi.fn();
|
||||||
|
initTodoView(document.getElementById("c")!, () => {}, onSave);
|
||||||
|
const form = document.querySelector<HTMLElement>(".todo-form")!;
|
||||||
|
expect(form.hidden).toBe(true);
|
||||||
|
document.querySelector<HTMLElement>(".todo-add")!.click();
|
||||||
|
expect(form.hidden).toBe(false);
|
||||||
|
const [text, note, due] = form.querySelectorAll("input");
|
||||||
|
text.value = " neu ";
|
||||||
|
note.value = "";
|
||||||
|
due.value = iso(1);
|
||||||
|
form.querySelector<HTMLElement>(".todo-form-submit")!.click();
|
||||||
|
// Text geht ungetrimmt raus (trimmt das Backend), leere Notiz entfällt.
|
||||||
|
expect(onSave).toHaveBeenCalledWith({
|
||||||
|
id: undefined,
|
||||||
|
text: " neu ",
|
||||||
|
note: undefined,
|
||||||
|
due: iso(1),
|
||||||
|
});
|
||||||
|
expect(form.hidden).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Stift öffnet das Formular vorbefüllt und meldet Speichern mit ID", () => {
|
||||||
|
const onSave = vi.fn();
|
||||||
|
const view = initTodoView(document.getElementById("c")!, () => {}, onSave);
|
||||||
|
view.set(jsonl);
|
||||||
|
const form = document.querySelector<HTMLElement>(".todo-form")!;
|
||||||
|
// zweite Kachel = "bald" (id "d") mit Notiz und Fälligkeit.
|
||||||
|
document.querySelectorAll<HTMLElement>(".cmd-edit")[1].click();
|
||||||
|
const [text, note, due] = form.querySelectorAll("input");
|
||||||
|
expect([text.value, note.value, due.value]).toEqual([
|
||||||
|
"bald",
|
||||||
|
"mit Notiz",
|
||||||
|
iso(2),
|
||||||
|
]);
|
||||||
|
text.value = "bald geändert";
|
||||||
|
form.querySelector<HTMLElement>(".todo-form-submit")!.click();
|
||||||
|
expect(onSave).toHaveBeenCalledWith({
|
||||||
|
id: "d",
|
||||||
|
text: "bald geändert",
|
||||||
|
note: "mit Notiz",
|
||||||
|
due: iso(2),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Abbrechen schließt das Formular ohne zu melden", () => {
|
||||||
|
const onSave = vi.fn();
|
||||||
|
initTodoView(document.getElementById("c")!, () => {}, onSave);
|
||||||
|
document.querySelector<HTMLElement>(".todo-add")!.click();
|
||||||
|
const form = document.querySelector<HTMLElement>(".todo-form")!;
|
||||||
|
form.querySelector<HTMLElement>(".todo-form-cancel")!.click();
|
||||||
|
expect(form.hidden).toBe(true);
|
||||||
|
expect(onSave).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Escape schließt das Formular ohne zu melden", () => {
|
||||||
|
const onSave = vi.fn();
|
||||||
|
initTodoView(document.getElementById("c")!, () => {}, onSave);
|
||||||
|
document.querySelector<HTMLElement>(".todo-add")!.click();
|
||||||
|
const form = document.querySelector<HTMLElement>(".todo-form")!;
|
||||||
|
form.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
|
||||||
|
expect(form.hidden).toBe(true);
|
||||||
|
expect(onSave).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
/// Kachel-Ansicht der persistenten ToDo-Liste: rendert die JSONL-Datei
|
||||||
|
/// (write_todos im MCP-Server) als Kacheln mit Kopier-, Bearbeitungs- und
|
||||||
|
/// Löschfunktion — Löschen geht als onDelete(id), Anlegen und Bearbeiten als
|
||||||
|
/// onSave(todo) an den Aufrufer, der neue Stand kommt über den Watcher
|
||||||
|
/// zurück. Der Plus-Knopf über der Liste öffnet das Formular leer, der Stift
|
||||||
|
/// auf der Kachel vorbefüllt (mit ID). Fälligkeit als Ampel-Badge (überfällig
|
||||||
|
/// rot, in den nächsten zwei Tagen gelb, sonst grün), Datum formatiert nach
|
||||||
|
/// Locale. Sortierung: fällige ToDos zuerst (aufsteigend), der Rest neueste
|
||||||
|
/// oben.
|
||||||
|
|
||||||
|
import { storedLocale, t } from "./messages";
|
||||||
|
import {
|
||||||
|
copyAction,
|
||||||
|
deleteAction,
|
||||||
|
editAction,
|
||||||
|
renderTile,
|
||||||
|
stripInvisibles,
|
||||||
|
} from "./tiles";
|
||||||
|
|
||||||
|
interface Todo {
|
||||||
|
id?: string;
|
||||||
|
ts: number;
|
||||||
|
text: string;
|
||||||
|
note?: string;
|
||||||
|
/// Fälligkeit als YYYY-MM-DD (lokales Datum).
|
||||||
|
due?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Formular-Ergebnis: mit ID bearbeiten (todos_update), ohne ID anlegen
|
||||||
|
/// (todos_add).
|
||||||
|
export interface TodoEdit {
|
||||||
|
id?: string;
|
||||||
|
text: string;
|
||||||
|
note?: string;
|
||||||
|
due?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TodoView {
|
||||||
|
set(text: string): void;
|
||||||
|
empty(): boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dueDate(due: string): Date {
|
||||||
|
const [y, m, d] = due.split("-").map(Number);
|
||||||
|
return new Date(y, m - 1, d);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ampel-Klasse zur Fälligkeit.
|
||||||
|
function dueClass(due: string): string {
|
||||||
|
const now = new Date();
|
||||||
|
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||||
|
const days = Math.round((dueDate(due).getTime() - today.getTime()) / 86_400_000);
|
||||||
|
return days < 0 ? "overdue" : days <= 2 ? "soon" : "later";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initTodoView(
|
||||||
|
container: HTMLElement,
|
||||||
|
onDelete: (id: string) => void,
|
||||||
|
onSave: (todo: TodoEdit) => void,
|
||||||
|
): TodoView {
|
||||||
|
let count = 0;
|
||||||
|
|
||||||
|
const head = document.createElement("div");
|
||||||
|
head.className = "todo-head";
|
||||||
|
const addBtn = document.createElement("button");
|
||||||
|
addBtn.className = "todo-add";
|
||||||
|
addBtn.textContent = t("todos.add");
|
||||||
|
addBtn.title = t("todos.addTitle");
|
||||||
|
head.append(addBtn);
|
||||||
|
|
||||||
|
const form = document.createElement("div");
|
||||||
|
form.className = "todo-form";
|
||||||
|
form.hidden = true;
|
||||||
|
const text = document.createElement("input");
|
||||||
|
text.type = "text";
|
||||||
|
text.placeholder = t("todos.formText");
|
||||||
|
const note = document.createElement("input");
|
||||||
|
note.type = "text";
|
||||||
|
note.placeholder = t("todos.formNote");
|
||||||
|
const due = document.createElement("input");
|
||||||
|
due.type = "date";
|
||||||
|
due.title = t("todos.formDue");
|
||||||
|
const cancel = document.createElement("button");
|
||||||
|
cancel.className = "todo-form-cancel";
|
||||||
|
cancel.textContent = t("todos.formCancel");
|
||||||
|
const submit = document.createElement("button");
|
||||||
|
submit.className = "todo-form-submit";
|
||||||
|
const btns = document.createElement("div");
|
||||||
|
btns.className = "todo-form-btns";
|
||||||
|
btns.append(cancel, submit);
|
||||||
|
form.append(text, note, due, btns);
|
||||||
|
|
||||||
|
const list = document.createElement("div");
|
||||||
|
container.append(head, form, list);
|
||||||
|
|
||||||
|
/// ID des ToDos im Formular; undefined = Anlegen.
|
||||||
|
let editing: string | undefined;
|
||||||
|
const close = () => {
|
||||||
|
form.hidden = true;
|
||||||
|
};
|
||||||
|
function open(todo?: Todo) {
|
||||||
|
editing = todo?.id;
|
||||||
|
text.value = todo?.text ?? "";
|
||||||
|
note.value = todo?.note ?? "";
|
||||||
|
due.value = todo?.due ?? "";
|
||||||
|
submit.textContent = t(todo ? "todos.formSave" : "todos.formCreate");
|
||||||
|
form.hidden = false;
|
||||||
|
text.focus();
|
||||||
|
}
|
||||||
|
const fire = () => {
|
||||||
|
onSave({
|
||||||
|
id: editing,
|
||||||
|
text: text.value,
|
||||||
|
note: note.value.trim() || undefined,
|
||||||
|
due: due.value || undefined,
|
||||||
|
});
|
||||||
|
close();
|
||||||
|
};
|
||||||
|
submit.addEventListener("click", fire);
|
||||||
|
cancel.addEventListener("click", close);
|
||||||
|
form.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
fire();
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
addBtn.addEventListener("click", () => (form.hidden ? open() : close()));
|
||||||
|
|
||||||
|
function render(todos: Todo[]) {
|
||||||
|
list.textContent = "";
|
||||||
|
const sorted = [...todos].sort((a, b) => {
|
||||||
|
if (a.due && b.due)
|
||||||
|
return a.due < b.due ? -1 : a.due > b.due ? 1 : b.ts - a.ts;
|
||||||
|
if (a.due || b.due) return a.due ? -1 : 1;
|
||||||
|
return b.ts - a.ts;
|
||||||
|
});
|
||||||
|
for (const todo of sorted) {
|
||||||
|
const visible = stripInvisibles(todo.text);
|
||||||
|
list.append(
|
||||||
|
renderTile({
|
||||||
|
cls: "cmd-tile",
|
||||||
|
bodyCls: "cmd-body",
|
||||||
|
parts: [
|
||||||
|
{ cls: "todo-text", text: visible },
|
||||||
|
...(todo.note ? [{ cls: "cmd-note", text: todo.note }] : []),
|
||||||
|
...(todo.due
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
cls: `todo-due ${dueClass(todo.due)}`,
|
||||||
|
text: dueDate(todo.due).toLocaleDateString(storedLocale()),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
],
|
||||||
|
actions: [
|
||||||
|
copyAction(t("todos.copyOne"), () => visible),
|
||||||
|
editAction(t("todos.editOne"), () => open(todo)),
|
||||||
|
deleteAction(t("todos.removeOne"), () => onDelete(todo.id ?? "")),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
set(text: string) {
|
||||||
|
const todos: Todo[] = [];
|
||||||
|
for (const line of text.split("\n")) {
|
||||||
|
if (!line.trim()) continue;
|
||||||
|
todos.push(JSON.parse(line));
|
||||||
|
}
|
||||||
|
count = todos.length;
|
||||||
|
render(todos);
|
||||||
|
},
|
||||||
|
empty: () => count === 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
+3
-11
@@ -8,14 +8,8 @@
|
|||||||
<header data-tauri-drag-region>
|
<header data-tauri-drag-region>
|
||||||
<img id="project-icon" hidden data-tauri-drag-region />
|
<img id="project-icon" hidden data-tauri-drag-region />
|
||||||
<span id="project-name" data-tauri-drag-region></span>
|
<span id="project-name" data-tauri-drag-region></span>
|
||||||
<div class="panel-tabs" id="panel-tabs">
|
<!-- Tabs kommen aus der Modul-Registry (panel-wiring). -->
|
||||||
<button class="panel-btn" data-mode="commands" data-i18n-title="panel.tabCommandsTitle" data-i18n="panel.tabCommands">Befehle</button>
|
<div class="panel-tabs" id="panel-tabs"></div>
|
||||||
<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>
|
|
||||||
<span id="pool"></span>
|
<span id="pool"></span>
|
||||||
<div id="winbtns">
|
<div id="winbtns">
|
||||||
<button class="winbtn" id="win-min" data-i18n-aria="panel.minimize">
|
<button class="winbtn" id="win-min" data-i18n-aria="panel.minimize">
|
||||||
@@ -69,10 +63,8 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Modul-Container (<mode>-content) reihen sich hier ein. -->
|
||||||
<div id="panel-content" class="md"></div>
|
<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>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
<div id="resize-grips" aria-hidden="true">
|
<div id="resize-grips" aria-hidden="true">
|
||||||
|
|||||||
Reference in New Issue
Block a user