From 1523856ea738124f37e6cf3c7c438134346bd8f1 Mon Sep 17 00:00:00 2001 From: marcus hinz Date: Thu, 23 Jul 2026 11:35:29 +0200 Subject: [PATCH] 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 --- panel.html | 14 +- src-tauri/build.rs | 13 +- src-tauri/capabilities/main.json | 6 +- src-tauri/capabilities/panel.json | 11 +- src-tauri/capabilities/terminal.json | 11 +- .../autogenerated/buffer_read.toml | 11 + .../autogenerated/enabled_modules.toml | 11 + .../autogenerated/module_registry.toml | 11 + .../permissions/autogenerated/set_module.toml | 11 + .../permissions/autogenerated/todos_add.toml | 11 + .../autogenerated/todos_delete.toml | 11 + .../autogenerated/todos_update.toml | 11 + src-tauri/src/app.rs | 13 +- src-tauri/src/commands.rs | 41 ++- src-tauri/src/domain/mod.rs | 2 +- src-tauri/src/domain/modules.rs | 294 ++++++++++++++++++ src-tauri/src/domain/paths.rs | 9 + src-tauri/src/domain/project.rs | 72 ++--- src-tauri/src/domain/todo.rs | 166 ---------- src-tauri/src/mcp.rs | 217 +++++++++++-- src-tauri/src/terminal.rs | 259 +++++++++++---- src/commands-view.test.ts | 14 +- src/commands-view.ts | 130 +++----- src/components/ProjectList.vue | 93 +++--- src/messages.test.ts | 4 +- src/messages.ts | 56 +++- src/modules/archive.ts | 38 +++ src/modules/commands.ts | 16 + src/modules/index.ts | 59 ++++ src/modules/todo.ts | 29 ++ src/panel-tiles.css | 137 ++++++++ src/panel-view.ts | 2 +- src/panel-wiring.ts | 169 +++++----- src/panel.ts | 13 +- src/search-view.ts | 23 +- src/terminal.ts | 16 +- src/tiles.ts | 101 ++++++ src/todo-view.test.ts | 134 ++++++++ src/todo-view.ts | 179 +++++++++++ terminal.html | 14 +- 40 files changed, 1832 insertions(+), 600 deletions(-) create mode 100644 src-tauri/permissions/autogenerated/buffer_read.toml create mode 100644 src-tauri/permissions/autogenerated/enabled_modules.toml create mode 100644 src-tauri/permissions/autogenerated/module_registry.toml create mode 100644 src-tauri/permissions/autogenerated/set_module.toml create mode 100644 src-tauri/permissions/autogenerated/todos_add.toml create mode 100644 src-tauri/permissions/autogenerated/todos_delete.toml create mode 100644 src-tauri/permissions/autogenerated/todos_update.toml create mode 100644 src-tauri/src/domain/modules.rs delete mode 100644 src-tauri/src/domain/todo.rs create mode 100644 src/modules/archive.ts create mode 100644 src/modules/commands.ts create mode 100644 src/modules/index.ts create mode 100644 src/modules/todo.ts create mode 100644 src/tiles.ts create mode 100644 src/todo-view.test.ts create mode 100644 src/todo-view.ts diff --git a/panel.html b/panel.html index e324592..a3b0ed1 100644 --- a/panel.html +++ b/panel.html @@ -6,14 +6,8 @@
-
- - - - - - -
+ +
@@ -57,10 +51,8 @@
+
- - -
@@ -1016,12 +1027,6 @@ onUnmounted(() => window.clearInterval(timer)); justify-content: flex-end; } -/* Todo-Zeile: volle Dialogbreite, der Text wird nie gekürzt. */ -.todo-line { - font-size: 0.85rem; - color: var(--subtext); -} - .spath { font-family: var(--mono); font-size: 0.75rem; diff --git a/src/messages.test.ts b/src/messages.test.ts index 1f5ebeb..f3e0a74 100644 --- a/src/messages.test.ts +++ b/src/messages.test.ts @@ -35,6 +35,7 @@ describe("Nachrichtentabellen", () => { "Name", "Theme", "Wiki", + "ToDo", "MD", "Autostart", "Input", @@ -44,14 +45,13 @@ describe("Nachrichtentabellen", () => { "Cache ↓", "+ oAuth", "+ apiKey", + "+ ToDo", "System", "Terminal", "Terminal — {name}", ".desktop-Starter", ".ai-control/ (Config + Icon)", "Archiv-Berechtigung in .claude/settings.json", - "Todo-Hook in .claude/settings.json", - "OFFENE-PUNKTE.md bei jedem Sessionstart einspielen", ]); const gleich = keys(messages.de).filter((k) => { const de = pick(messages.de, k); diff --git a/src/messages.ts b/src/messages.ts index e2de064..b8c20c5 100644 --- a/src/messages.ts +++ b/src/messages.ts @@ -90,14 +90,11 @@ const de = { artRegistry: "Registry-Eintrag", artAiControl: ".ai-control/ (Config + Icon)", artArchivePerm: "Archiv-Berechtigung in .claude/settings.json", - artTodoHook: "Todo-Hook in .claude/settings.json", artPanelFiles: "Panel-Kanaldatei | {count} Panel-Kanaldateien", artDesktop: ".desktop-Starter", artArchive: "Archiv {path}", artProjectDir: "Projektordner {path}", artWorkDir: "Arbeitsordner {path}", - todo: "Todoliste", - todoDesc: "OFFENE-PUNKTE.md bei jedem Sessionstart einspielen", archiveChangeTitle: "Archiv wechseln", archiveChangeText: "Das Archiv wechselt von {old} nach {neu}.", archiveMigrate: "Dokumente ins neue Archiv verschieben", @@ -106,9 +103,30 @@ const de = { archiveChangeConfirm: "Wechseln", groupAppearance: "Darstellung", groupFolders: "Ordner", - groupSession: "Session", + groupModules: "Module", writesImmediately: "Änderungen hier schreiben sofort — ohne Speichern.", }, + modules: { + commands: "Befehle", + commandsDesc: "Befehls-Kacheln im Panel (write_commands)", + todo: "ToDo-Liste", + todoDesc: "persistente Aufgaben-Kacheln mit Fälligkeits-Ampel; öffnet beim Sessionstart", + archive: "Archiv", + archiveDesc: "Archivieren, Wiki und Suche", + }, + todos: { + copyOne: "Aufgabe kopieren", + editOne: "Aufgabe bearbeiten", + removeOne: "Aufgabe entfernen", + add: "+ ToDo", + addTitle: "Neue Aufgabe anlegen", + formText: "Aufgabe", + formNote: "Notiz", + formDue: "Fälligkeit", + formCreate: "Anlegen", + formSave: "Speichern", + formCancel: "Abbrechen", + }, pools: { pool: "Pool", type: "Typ", @@ -164,6 +182,8 @@ const de = { windowProjects: "Projekte", tabCommands: "Befehle", tabCommandsTitle: "Befehls-History", + tabTodo: "ToDo", + tabTodoTitle: "ToDo-Liste des Projekts", tabDraft: "Dokument", tabDraftTitle: "Dokument", tabWiki: "Wiki", @@ -315,14 +335,11 @@ const en: typeof de = { artRegistry: "registry entry", artAiControl: ".ai-control/ (config + icon)", artArchivePerm: "archive permission in .claude/settings.json", - artTodoHook: "todo hook in .claude/settings.json", artPanelFiles: "panel channel file | {count} panel channel files", artDesktop: ".desktop launcher", artArchive: "archive {path}", artProjectDir: "project folder {path}", artWorkDir: "working folder {path}", - todo: "Todo list", - todoDesc: "Inject OFFENE-PUNKTE.md at every session start", archiveChangeTitle: "Change archive", archiveChangeText: "The archive changes from {old} to {neu}.", archiveMigrate: "Move documents to the new archive", @@ -331,9 +348,30 @@ const en: typeof de = { archiveChangeConfirm: "Change", groupAppearance: "Appearance", groupFolders: "Folders", - groupSession: "Session", + groupModules: "Modules", writesImmediately: "Changes here are written immediately — no save step.", }, + modules: { + commands: "Commands", + commandsDesc: "command tiles in the panel (write_commands)", + todo: "ToDo list", + todoDesc: "persistent task tiles with due-date signal; opens at session start", + archive: "Archive", + archiveDesc: "archiving, wiki and search", + }, + todos: { + copyOne: "Copy task", + editOne: "Edit task", + removeOne: "Remove task", + add: "+ ToDo", + addTitle: "Add a task", + formText: "Task", + formNote: "Note", + formDue: "Due date", + formCreate: "Add", + formSave: "Save", + formCancel: "Cancel", + }, pools: { pool: "Pool", type: "Type", @@ -389,6 +427,8 @@ const en: typeof de = { windowProjects: "Projects", tabCommands: "Commands", tabCommandsTitle: "Command history", + tabTodo: "ToDo", + tabTodoTitle: "Project to-do list", tabDraft: "Document", tabDraftTitle: "Document", tabWiki: "Wiki", diff --git a/src/modules/archive.ts b/src/modules/archive.ts new file mode 100644 index 0000000..14c88ec --- /dev/null +++ b/src/modules/archive.ts @@ -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}`), + ); + }), +}; diff --git a/src/modules/commands.ts b/src/modules/commands.ts new file mode 100644 index 0000000..7a8aad5 --- /dev/null +++ b/src/modules/commands.ts @@ -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 }), + ), +}; diff --git a/src/modules/index.ts b/src/modules/index.ts new file mode 100644 index 0000000..9bb5869 --- /dev/null +++ b/src/modules/index.ts @@ -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 +/// `-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 (`-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 `-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, +]; diff --git a/src/modules/todo.ts b/src/modules/todo.ts new file mode 100644 index 0000000..1ca799d --- /dev/null +++ b/src/modules/todo.ts @@ -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))); + }, + ), +}; diff --git a/src/panel-tiles.css b/src/panel-tiles.css index 70ef3a7..c612514 100644 --- a/src/panel-tiles.css +++ b/src/panel-tiles.css @@ -92,6 +92,143 @@ system-ui, sans-serif; } +/* ToDo-Kacheln: Layout wie Befehls-Kacheln (cmd-tile/cmd-body), eigener + Text-Stil und Fälligkeits-Ampel. */ +#todo-content { + flex: 1; + min-height: 0; + overflow: auto; + padding: 12px 14px; +} +#todo-content::-webkit-scrollbar { + width: 8px; +} +#todo-content::-webkit-scrollbar-thumb { + background: var(--line); + border-radius: 4px; +} +.todo-text { + font: + 400 12px/1.45 -apple-system, + system-ui, + sans-serif; + word-break: break-word; + user-select: text; + -webkit-user-select: text; + cursor: text; +} +.todo-due { + display: inline-block; + margin-top: 5px; + border-radius: 999px; + padding: 2px 8px; + font-family: "JetBrains Mono", Menlo, monospace; + font-size: 10px; + font-weight: 500; +} +.todo-due.overdue { + color: var(--err); + border: 1px solid var(--err); +} +.todo-due.soon { + color: var(--warn); + border: 1px solid var(--warn); +} +.todo-due.later { + color: var(--ok); + border: 1px solid var(--ok); +} +/* Kopfzeile mit Anlegen-Knopf und das ToDo-Formular (Anlegen/Bearbeiten), + in-flow über der Liste; Input-Stil wie im Archiv-Formular. */ +.todo-head { + display: flex; + justify-content: flex-end; +} +.todo-add { + border: none; + background: transparent; + color: var(--muted); + font: + 500 11px/1 -apple-system, + system-ui, + sans-serif; + border-radius: 6px; + padding: 4px 6px; +} +.todo-add:hover { + background: var(--line); + color: var(--text); +} +.todo-form { + display: flex; + flex-direction: column; + gap: 6px; + background: var(--tile); + border: 1px solid var(--line-strong); + border-radius: 8px; + padding: 10px; + margin-top: 6px; +} +.todo-form[hidden] { + display: none; +} +.todo-form input { + box-sizing: border-box; + width: 100%; + background: var(--surface); + border: 1px solid var(--line); + border-radius: 6px; + padding: 6px 10px; + color: var(--text); + font: + 400 12px/1.4 -apple-system, + system-ui, + sans-serif; + outline: none; +} +.todo-form input:focus { + border-color: var(--line-strong); +} +.todo-form input::placeholder { + color: var(--faint); +} +.todo-form input[type="date"] { + color-scheme: dark; +} +.todo-form-btns { + display: flex; + gap: 6px; + justify-content: flex-end; +} +.todo-form-cancel { + border: 1px solid var(--line); + background: none; + color: var(--text); + border-radius: 6px; + padding: 6px 10px; + font: + 500 12px/1.4 -apple-system, + system-ui, + sans-serif; +} +.todo-form-cancel:hover { + background: var(--line); +} +.todo-form-submit { + border: none; + background: var(--line); + color: var(--text); + border-radius: 6px; + padding: 6px 10px; + font: + 500 12px/1.4 -apple-system, + system-ui, + sans-serif; +} +.todo-form-submit:hover { + background: var(--line-strong); +} + #search-content { flex: 1; min-height: 0; diff --git a/src/panel-view.ts b/src/panel-view.ts index 9bfa968..f6cc719 100644 --- a/src/panel-view.ts +++ b/src/panel-view.ts @@ -1,6 +1,6 @@ import { renderMarkdown } from "./markdown"; import { writeText } from "@tauri-apps/plugin-clipboard-manager"; -import { flash } from "./commands-view"; +import { flash } from "./tiles"; import { t } from "./messages"; export interface PanelView { diff --git a/src/panel-wiring.ts b/src/panel-wiring.ts index 1b03fec..a998601 100644 --- a/src/panel-wiring.ts +++ b/src/panel-wiring.ts @@ -1,26 +1,24 @@ /// Gemeinsame Panel-Verdrahtung für das angedockte Panel (terminal.ts) und -/// das abgelöste Fenster (panel.ts): Entwurfs-Ansicht, Befehls- und -/// Such-Kacheln, Modus-Umschalter, Wikilink-Klick und die drei Update-Events. -/// Die Element-IDs sind in beiden HTML-Dateien identisch; die Draft-Controls -/// trägt das Markup als `.draft-only`. +/// das abgelöste Fenster (panel.ts): die Entwurfs-Ansicht als Kern, alle +/// weiteren Tabs aus der Modul-Registry (src/modules) — Tab-Knöpfe und +/// Content-Container entstehen hier aus der Liste der aktiven Module, +/// Erstbefüllung per buffer_read, Updates über `-update`-Events. +/// Die Element-IDs des Entwurfs-Markups sind in beiden HTML-Dateien +/// identisch; die Draft-Controls trägt das Markup als `.draft-only`. import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; import { initPanelView, type PanelView } from "./panel-view"; -import { - initCommandsView, - initPanelMode, - panelToast, - type CommandsView, - type PanelMode, -} from "./commands-view"; -import { initSearchView } from "./search-view"; -import { initWikiView } from "./wiki-view"; +import { initPanelMode, type ModeTab, type PanelMode } from "./commands-view"; +import { panelToast } from "./tiles"; +import { PANEL_TABS, type ModuleCtx, type ModuleView } from "./modules"; +import { t } from "./messages"; import "./panel-tiles.css"; export interface PanelWiring { view: PanelView; - cmdView: CommandsView; + /// Modul-Ansichten nach Tab-Modus; enthält nur Tabs aktiver Module. + views: Map; mode: { to(m: PanelMode): void; clear(): void; current(): PanelMode | null }; /// Entwurfstext beim Start (für die Anfangs-Modus-Entscheidung). draft: string; @@ -31,24 +29,17 @@ export async function wirePanel( onIncoming?: () => void, ): Promise { 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("enabled_modules", { project }), invoke("spellcheck_lang"), - invoke("panel_read", { project }), - invoke("commands_read", { project }), - invoke("search_read", { project }), - invoke("wiki_read", { project }), - invoke("panel_archive_dir_cmd", { project }), + invoke("buffer_read", { project, buffer: "panel" }), ]); + const tabs = PANEL_TABS.filter((tab) => enabled.includes(tab.module)); - // Ohne Archiv (in den Projekt-Einstellungen abgewählt) gibt es nur Befehle - // und Dokument — Wiki-/Suche-Tabs und die Archiv-Werkzeuge verschwinden. - if (!archiveHome) { - for (const sel of [ - '#panel-tabs [data-mode="wiki"]', - '#panel-tabs [data-mode="search"]', - "#panel-archive", - "#panel-wiki-jump", - ]) { + // Ohne Archiv-Modul (abgewählt oder kein Archiv-Home) verschwinden auch + // die Archiv-Werkzeuge des Entwurfs-Tabs. + if (!enabled.includes("archive")) { + for (const sel of ["#panel-archive", "#panel-wiki-jump"]) { const el = document.querySelector(sel)!; el.hidden = true; // Raus aus der draft-only-Menge, sonst blendet der Modus-Umschalter @@ -61,7 +52,15 @@ export async function wirePanel( // den Kern; das Ergebnis kommt über den Wiki-Puffer und `wiki-update` zurück. // Fehler (z. B. Ziel nicht im Archiv) erscheinen als Toast. const openWiki = (name: string) => - invoke("wiki_open", { project, name }).catch((e) => panelToast(String(e))); + void invoke("wiki_open", { project, name }).catch((e) => panelToast(String(e))); + + const ctx: ModuleCtx = { + project, + toast: panelToast, + openDoc: (path) => + void invoke("panel_load", { project, path }).catch((e) => panelToast(String(e))), + openWiki, + }; const view = initPanelView({ content: document.getElementById("panel-content")!, @@ -75,57 +74,61 @@ export async function wirePanel( onCommit: (text) => invoke("panel_set", { project, text }), onWikiLink: openWiki, }); - const cmdView = initCommandsView( - document.getElementById("commands-content")!, - (id) => invoke("commands_delete", { project, id }), - ); - // Treffer-Klick lädt das Dokument in den Dokument-Tab (dort editier- und - // archivierbar); der Sprung ins Wiki geht von dort aus. - const searchView = initSearchView( - document.getElementById("search-content")!, - (path) => - void invoke("panel_load", { project, path }).catch((e) => panelToast(String(e))), - (raw) => { - // `#tag`-Tokens filtern aufs Schlagwort, der Rest ist die Volltext-Query. - const words = raw.split(/\s+/).filter(Boolean); - const tag = words.find((w) => w.startsWith("#"))?.slice(1) ?? null; - const query = words.filter((w) => !w.startsWith("#")).join(" "); - void invoke("search_run", { project, query, tag }).catch((e) => - panelToast(`Suche fehlgeschlagen: ${e}`), - ); - }, - ); - const wikiView = initWikiView(document.getElementById("wiki-content")!, openWiki); + + // Tab-Leiste und Container aus der Registry. Die Container-IDs + // (`-content`) sind zugleich die CSS-Anker der Ansichten; die + // Container reihen sich hinter #panel-content ein. + const tabsEl = document.getElementById("panel-tabs")!; + const views = new Map(); + const modeTabs: ModeTab[] = []; + let anchor = document.getElementById("panel-content")!; + for (const tab of tabs) { + const btn = document.createElement("button"); + btn.className = "panel-btn"; + btn.dataset.mode = tab.mode; + btn.textContent = t(tab.labelKey); + btn.title = t(tab.titleKey); + tabsEl.append(btn); + if (tab.sepAfter) { + const sep = document.createElement("span"); + sep.className = "tab-sep"; + tabsEl.append(sep); + } + let content: HTMLElement | null = null; + if (tab.init) { + content = document.createElement("div"); + content.id = `${tab.mode}-content`; + content.hidden = true; + anchor.after(content); + anchor = content; + views.set(tab.mode, tab.init(content, ctx)); + } + modeTabs.push({ + mode: tab.mode, + btn, + content, + label: t(tab.labelKey), + onActivate: tab.onActivate + ? () => tab.onActivate!(views.get(tab.mode)!, ctx) + : undefined, + }); + } const mode = initPanelMode({ - tabsEl: document.getElementById("panel-tabs")!, + tabs: modeTabs, draftEls: [ document.getElementById("panel-content")!, ...document.querySelectorAll(".draft-only"), ], - commandsContent: document.getElementById("commands-content")!, - searchContent: document.getElementById("search-content")!, - wikiContent: document.getElementById("wiki-content")!, titleEl, flush: () => void view.flush(), }); - view.set(draft); - cmdView.set(cmds); - searchView.set(search); - wikiView.set(wiki); - - // Wiki-Tab bei leerem Puffer (Session-Start): Übersicht direkt laden. - document - .querySelector('#panel-tabs [data-mode="wiki"]')! - .addEventListener("click", () => { - if (wikiView.empty()) void openWiki("tag:"); - }); - // Sprung Dokument → Wiki: löst den angezeigten Titel gegen das Archiv auf. document .getElementById("panel-wiki-jump")! - .addEventListener("click", () => void openWiki(titleEl.textContent || "")); + .addEventListener("click", () => openWiki(titleEl.textContent || "")); + view.set(draft); await listen("panel-update", (e) => { // Erst umschalten, dann setzen: to("draft") restauriert den gemerkten // Titel — der neue Inhalt (und damit sein Titel) muss danach kommen. @@ -133,21 +136,19 @@ export async function wirePanel( view.set(e.payload); onIncoming?.(); }); - await listen("commands-update", (e) => { - cmdView.set(e.payload); - mode.to("commands"); - onIncoming?.(); - }); - await listen("search-update", (e) => { - searchView.set(e.payload); - mode.to("search"); - onIncoming?.(); - }); - await listen("wiki-update", (e) => { - wikiView.set(e.payload); - mode.to("wiki"); - onIncoming?.(); - }); + await Promise.all( + tabs + .filter((tab) => tab.init) + .map(async (tab) => { + const v = views.get(tab.mode)!; + v.set(await invoke("buffer_read", { project, buffer: tab.buffer })); + await listen(`${tab.buffer}-update`, (e) => { + v.set(e.payload); + mode.to(tab.mode); + onIncoming?.(); + }); + }), + ); - return { view, cmdView, mode, draft }; + return { view, views, mode, draft }; } diff --git a/src/panel.ts b/src/panel.ts index c66bf12..54d4900 100644 --- a/src/panel.ts +++ b/src/panel.ts @@ -6,7 +6,7 @@ import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; import { PhysicalPosition, PhysicalSize } from "@tauri-apps/api/dpi"; import { emit } from "@tauri-apps/api/event"; import { wirePanel } from "./panel-wiring"; -import { flash, panelToast } from "./commands-view"; +import { flash, panelToast } from "./tiles"; import { initArchiveForm } from "./archive-form"; import { applyTheme, THEMES } from "./themes"; import { applyI18n, t } from "./messages"; @@ -99,8 +99,15 @@ applyTheme(picked); document.documentElement.style.background = picked.header; document.body.style.background = picked.header; -const { view, cmdView, mode, draft } = await wirePanel(project); -if (!draft.trim() && !cmdView.empty()) mode.to("commands"); +const { view, views, mode, draft } = await wirePanel(project); +// Ohne Entwurf startet das Fenster in der ToDo-Liste (falls gewählt und +// nicht leer), sonst in den Befehlen. +const todos = views.get("todo"); +const cmds = views.get("commands"); +if (!draft.trim()) { + if (todos && !todos.empty()) mode.to("todo"); + else if (cmds && !cmds.empty()) mode.to("commands"); +} // Archivieren: wie im angedockten Panel — Formular aufklappen, Abschicken // wählt notfalls erst das Archiv-Home per Dialog. diff --git a/src/search-view.ts b/src/search-view.ts index dfe5544..5299215 100644 --- a/src/search-view.ts +++ b/src/search-view.ts @@ -5,6 +5,7 @@ /// Split in -Elemente übersetzt. import { t } from "./messages"; +import { renderTile } from "./tiles"; interface Hit { relpath: string; @@ -99,17 +100,17 @@ export function initSearchView( : t("search.noHits", { scope }); results.append(head); for (const hit of run.hits) { - const tile = document.createElement("div"); - tile.className = "hit-tile"; - const title = document.createElement("div"); - title.className = "hit-title"; - title.textContent = hit.title; - const path = document.createElement("div"); - path.className = "hit-path"; - path.textContent = hit.relpath; - tile.append(title, snippetEl(hit.snippet), path); - tile.addEventListener("click", () => onOpen(`${run.home}/${hit.relpath}`)); - results.append(tile); + results.append( + renderTile({ + cls: "hit-tile", + parts: [ + { cls: "hit-title", text: hit.title }, + snippetEl(hit.snippet), + { cls: "hit-path", text: hit.relpath }, + ], + onClick: () => onOpen(`${run.home}/${hit.relpath}`), + }), + ); } } diff --git a/src/terminal.ts b/src/terminal.ts index 1b14038..917fb4b 100644 --- a/src/terminal.ts +++ b/src/terminal.ts @@ -15,7 +15,7 @@ import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; import { listen } from "@tauri-apps/api/event"; import { wirePanel } from "./panel-wiring"; import { applyTheme, THEMES } from "./themes"; -import { flash, panelToast } from "./commands-view"; +import { flash, panelToast } from "./tiles"; import { initArchiveForm } from "./archive-form"; import { applyI18n, t } from "./messages"; @@ -212,12 +212,20 @@ function hidePanel() { // Gemeinsame Verdrahtung (Views, Modi, Update-Events); jedes eingehende // Update blendet das angedockte Panel ein, solange es nicht abgelöst ist. -const { view, mode } = await wirePanel(project, () => { +const { view, views, mode } = await wirePanel(project, () => { hasContent = true; if (!detached) showPanel(); }); -// Panel startet zugeklappt — kein Tab aktiv, bis eine Ansicht geöffnet wird. -mode.clear(); +// Panel startet zugeklappt — außer das ToDo-Modul ist gewählt und die Liste +// nicht leer: dann öffnet das Panel beim Start mit der ToDo-Ansicht. +const todos = views.get("todo"); +if (todos && !todos.empty()) { + hasContent = true; + showPanel(); + mode.to("todo"); +} else { + mode.clear(); +} // Tab-Klick im Header öffnet das (angedockte) Panel, falls es zu ist. const headerTabs = document.getElementById("panel-tabs")!; diff --git a/src/tiles.ts b/src/tiles.ts new file mode 100644 index 0000000..d62a694 --- /dev/null +++ b/src/tiles.ts @@ -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 = + ''; + 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 = + ''; + 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 = + ''; + btn.addEventListener("click", onClick); + return btn; +} + +export interface TileSpec { + cls: string; + /// Inhalts-Zeilen in Reihenfolge; fertige Elemente (z. B. Snippet mit + /// -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; +} diff --git a/src/todo-view.test.ts b/src/todo-view.test.ts new file mode 100644 index 0000000..6ad6caa --- /dev/null +++ b/src/todo-view.test.ts @@ -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 = `
`; + }); + 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(".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(".todo-form")!; + expect(form.hidden).toBe(true); + document.querySelector(".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(".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(".todo-form")!; + // zweite Kachel = "bald" (id "d") mit Notiz und Fälligkeit. + document.querySelectorAll(".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(".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(".todo-add")!.click(); + const form = document.querySelector(".todo-form")!; + form.querySelector(".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(".todo-add")!.click(); + const form = document.querySelector(".todo-form")!; + form.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); + expect(form.hidden).toBe(true); + expect(onSave).not.toHaveBeenCalled(); + }); +}); diff --git a/src/todo-view.ts b/src/todo-view.ts new file mode 100644 index 0000000..00ad0cd --- /dev/null +++ b/src/todo-view.ts @@ -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, + }; +} diff --git a/terminal.html b/terminal.html index 2769f95..42b1d0c 100644 --- a/terminal.html +++ b/terminal.html @@ -8,14 +8,8 @@
-
- - - - - - -
+ +
+
- - -