From 9de58d43e8ba94a8e0d743e9ea4fd7c0eb3ddc63 Mon Sep 17 00:00:00 2001 From: marcus hinz Date: Sun, 26 Jul 2026 14:44:06 +0200 Subject: [PATCH] Archiv als eigenes Fenster: Trilium raus, Notizbaum mit In-Place-Bearbeitung MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trilium-Integration komplett zurückgenommen (Revert von 47951a6): keine verwaltete Instanz, kein Runtime-Download, kein ETAPI, kein Keyring-Token, kein Setup-Wizard. Das Archiv sind wieder Markdown-Dateien unter archiveHome samt FTS5-Suche und Wikilinks. Notizmodell (an Trilium orientiert, ohne dessen Überbau): - Jeder Knoten besitzt eine Textdatei — ensure_node_texts legt fehlende beim Seitenaufbau an (Ordner: gleichnamiges .md daneben, Wurzel: index.md); Ordner und Zwillingsdatei sind EINE Notiz mit Inhalt und Kindern. - Der angezeigte Titel steht im Frontmatter der Datei, der Datei-/Ordnername ist technischer Verwaltungsname; move_folder nimmt den Knotentext mit. Archiv-Ansicht (wiki-view): - Baum links: Knoten- und Blatt-Symbole, Führungslinien pro Ebene, Dokumente näher an der Linie als Ordner; ganze Zeile wählt aus, nur der Pfeil klappt. - Notiz-Ansicht rechts: großer Titel (Klick bearbeitet ihn → archive_set_title), Zurück-Knopf mit Auswahl-Verlauf, Details als Popup am Info-Knopf, Kindliste als abgesetzter Karten-Block mit Anlage-/Änderungsdatum. - Bearbeiten in place: Split-View aus Rohtext und Live-Vorschau, Speichern per Knopf oder Ctrl+S (archive_read/archive_write; Frontmatter bleibt). - Rechte Maustaste im Baum als einziger Aktionsweg (eigenes Kontextmenü, Standard-Menü des Webviews aus); Anlegen/Umbenennen über modale Dialoge. Feste Flächenaufteilung statt Ablösen/Andocken: - Angedocktes Panel = Session-Begleiter (ToDo, Befehle, flüchtiger Entwurf); der Dokument-Tab ist aus der Leiste raus. - Archiv-Fenster = Archiv + Suche, Größe und Position einmalig beim Öffnen aus dem Monitor des rufenden Terminal-Fensters (85 % / 92 %); Öffner-Klick bei stehendem Fenster fokussiert und schaltet den Tab (panel-mode). - Terminal-Fenster zu → Archiv-Fenster geht mit. Neue Commands: archive_read, archive_write, archive_set_title. --- panel.html | 32 +- src-tauri/Cargo.lock | 101 ++- src-tauri/Cargo.toml | 4 +- src-tauri/build.rs | 25 +- src-tauri/capabilities/main.json | 12 +- src-tauri/capabilities/panel.json | 20 +- src-tauri/capabilities/terminal.json | 19 +- .../autogenerated/archive_docs_cmd.toml | 11 + .../autogenerated/archive_read.toml | 11 + .../autogenerated/archive_set_title.toml | 11 + .../autogenerated/archive_write.toml | 11 + .../change_archive_home_cmd.toml | 11 + .../autogenerated/clear_archive_home_cmd.toml | 11 + .../autogenerated/panel_archive_cmd.toml | 11 + .../autogenerated/panel_archive_dir_cmd.toml | 11 + .../permissions/autogenerated/panel_load.toml | 11 + .../permissions/autogenerated/panel_set.toml | 11 + .../autogenerated/reveal_path_cmd.toml | 11 + .../autogenerated/set_archive_home_cmd.toml | 11 + .../autogenerated/spellcheck_lang.toml | 11 + .../autogenerated/trilium_connect.toml | 11 - .../autogenerated/trilium_disable.toml | 11 - .../autogenerated/trilium_open.toml | 11 - .../autogenerated/trilium_setup.toml | 11 - .../autogenerated/trilium_status.toml | 11 - .../permissions/autogenerated/wiki_open.toml | 11 + src-tauri/src/app.rs | 33 +- src-tauri/src/commands.rs | 174 ++-- src-tauri/src/domain/archive.rs | 565 ++++++++++++ src-tauri/src/domain/archive_index.rs | 440 ++++++++++ src-tauri/src/domain/archive_ops.rs | 418 +++++++++ src-tauri/src/domain/archive_search.rs | 227 +++++ src-tauri/src/domain/mod.rs | 5 +- src-tauri/src/domain/modules.rs | 68 +- src-tauri/src/domain/paths.rs | 28 +- src-tauri/src/domain/project.rs | 122 ++- src-tauri/src/domain/settings.rs | 10 +- src-tauri/src/domain/trilium.rs | 555 ------------ src-tauri/src/lib.rs | 23 +- src-tauri/src/mcp.rs | 248 ++++-- src-tauri/src/platform/linux/mod.rs | 5 + src-tauri/src/platform/macos.rs | 5 +- src-tauri/src/platform/mod.rs | 2 +- src-tauri/src/platform/windows.rs | 7 +- src-tauri/src/terminal.rs | 284 +++++- src/archive-form.test.ts | 74 ++ src/archive-form.ts | 74 ++ src/commands-view.test.ts | 53 +- src/commands-view.ts | 38 +- src/components/ProjectList.vue | 380 +++----- src/messages.test.ts | 12 +- src/messages.ts | 186 ++-- src/modules/archive.ts | 66 +- src/modules/commands.ts | 1 - src/modules/index.ts | 23 +- src/panel-tiles.css | 691 ++++++++++++++- src/panel-view.test.ts | 68 ++ src/panel-view.ts | 271 ++++++ src/panel-window.css | 133 ++- src/panel-wiring.ts | 154 +++- src/panel.ts | 66 +- src/search-view.test.ts | 13 +- src/search-view.ts | 36 +- src/terminal-window.css | 133 ++- src/terminal.ts | 81 +- src/wiki-view.test.ts | 372 ++++++++ src/wiki-view.ts | 813 ++++++++++++++++++ terminal.html | 30 +- 68 files changed, 5943 insertions(+), 1466 deletions(-) create mode 100644 src-tauri/permissions/autogenerated/archive_docs_cmd.toml create mode 100644 src-tauri/permissions/autogenerated/archive_read.toml create mode 100644 src-tauri/permissions/autogenerated/archive_set_title.toml create mode 100644 src-tauri/permissions/autogenerated/archive_write.toml create mode 100644 src-tauri/permissions/autogenerated/change_archive_home_cmd.toml create mode 100644 src-tauri/permissions/autogenerated/clear_archive_home_cmd.toml create mode 100644 src-tauri/permissions/autogenerated/panel_archive_cmd.toml create mode 100644 src-tauri/permissions/autogenerated/panel_archive_dir_cmd.toml create mode 100644 src-tauri/permissions/autogenerated/panel_load.toml create mode 100644 src-tauri/permissions/autogenerated/panel_set.toml create mode 100644 src-tauri/permissions/autogenerated/reveal_path_cmd.toml create mode 100644 src-tauri/permissions/autogenerated/set_archive_home_cmd.toml create mode 100644 src-tauri/permissions/autogenerated/spellcheck_lang.toml delete mode 100644 src-tauri/permissions/autogenerated/trilium_connect.toml delete mode 100644 src-tauri/permissions/autogenerated/trilium_disable.toml delete mode 100644 src-tauri/permissions/autogenerated/trilium_open.toml delete mode 100644 src-tauri/permissions/autogenerated/trilium_setup.toml delete mode 100644 src-tauri/permissions/autogenerated/trilium_status.toml create mode 100644 src-tauri/permissions/autogenerated/wiki_open.toml create mode 100644 src-tauri/src/domain/archive.rs create mode 100644 src-tauri/src/domain/archive_index.rs create mode 100644 src-tauri/src/domain/archive_ops.rs create mode 100644 src-tauri/src/domain/archive_search.rs delete mode 100644 src-tauri/src/domain/trilium.rs create mode 100644 src/archive-form.test.ts create mode 100644 src/archive-form.ts create mode 100644 src/panel-view.test.ts create mode 100644 src/panel-view.ts create mode 100644 src/wiki-view.test.ts create mode 100644 src/wiki-view.ts diff --git a/panel.html b/panel.html index b60c21e..a3b0ed1 100644 --- a/panel.html +++ b/panel.html @@ -2,7 +2,7 @@ - Panel + Dokument
@@ -21,12 +21,38 @@
-
- + Dokument + +
+
+ + + + + +
+ +
@@ -878,63 +849,27 @@ onUnmounted(() => window.clearInterval(timer)); -
-
-

{{ $t("trilium.wizardTitle") }}

-

{{ $t("trilium.wizardIntro") }}

-

{{ triWizard.error }}

- -
    -
  • - {{ triWizard.status.runtime_ready ? "✓" : "✗" }} - - {{ $t("trilium.stepRuntime") }} - Trilium {{ triWizard.status.version }} - -
  • -
  • - {{ triWizard.status.instance_running ? "✓" : "✗" }} - - {{ $t("trilium.stepInstance") }} - {{ $t("trilium.port", { port: triWizard.status.port }) }} - -
  • -
  • - {{ triWizard.status.token_ok ? "✓" : "✗" }} - {{ $t("trilium.stepToken") }} -
  • -
- -

- {{ $t("trilium.dataDir") }}: {{ triWizard.status.data_dir }} - · http://127.0.0.1:{{ triWizard.status.port }} +

+
+

{{ $t("projects.archiveChangeTitle") }}

+

+ {{ $t("projects.archiveChangeText", { + old: contractHome(settings.archiveHome ?? ""), + neu: contractHome(pendingArchive.dir), + }) }}

- -

{{ $t("trilium.firstRunHint") }}

- -
- {{ $t("trilium.password") }} - - -
- + +

{{ $t("projects.archiveMigrateHint") }}

- - -
@@ -1115,105 +1050,4 @@ onUnmounted(() => window.clearInterval(timer)); .settings-dialog .actions { margin-top: 0.25rem; } - -/* Trilium-Wizard. Maße nach UI-Guideline (px statt rem): 14 px Basis für - Interaktives, Meta-Zeilen 12 px, Zeilen mindestens 28 px, 4-px-Skala. */ -.tri-dialog { - width: min(40rem, 94vw); - max-width: none; - padding: 24px; - gap: 12px; -} - -.tri-dialog h3 { - font-size: 16px; -} - -.tri-dialog .hint, -.tri-dialog .error { - font-size: 13px; - line-height: 1.5; -} - -.tri-steps { - list-style: none; - margin: 4px 0; - padding: 0; - display: flex; - flex-direction: column; - gap: 8px; -} - -.tri-steps li { - display: flex; - gap: 12px; - align-items: flex-start; - min-height: 28px; - font-size: 14px; - color: var(--text); -} - -.tri-steps li > span:last-child { - display: flex; - flex-direction: column; - gap: 4px; - min-width: 0; - padding-top: 2px; -} - -.tri-steps small { - font-family: var(--mono); - font-size: 12px; - color: var(--subtext); -} - -.tri-steps .mark { - width: 24px; - height: 24px; - flex: none; - display: flex; - align-items: center; - justify-content: center; - font-size: 14px; - border-radius: 6px; - background: var(--surface0); - color: var(--overlay); -} - -.tri-steps .mark.ok { - color: var(--green, #a6e3a1); -} - -.tri-meta { - margin: 0; - font-size: 12px; - color: var(--overlay); -} - -.tri-meta code { - font-family: var(--mono); - font-size: 12px; - color: var(--subtext); -} - -/* Verbinden-Zeile: Label | Passwort | Knopf. */ -.tri-connect { - display: grid; - grid-template-columns: 8rem 1fr auto; - align-items: center; - gap: 12px; - min-height: 32px; - font-size: 14px; - color: var(--subtext); -} - -.tri-connect input { - min-height: 28px; - font-size: 14px; -} - -.tri-dialog .actions button { - min-height: 28px; - font-size: 14px; -} diff --git a/src/messages.test.ts b/src/messages.test.ts index 32e3ad0..08deeef 100644 --- a/src/messages.test.ts +++ b/src/messages.test.ts @@ -32,7 +32,6 @@ describe("Nachrichtentabellen", () => { const erlaubt = new Set([ "Pool", "Pools", - "Panel", "Name", "Theme", "Wiki", @@ -48,7 +47,6 @@ describe("Nachrichtentabellen", () => { "+ apiKey", "+ ToDo", "System", - "Trilium", "Terminal", "Terminal — {name}", ".desktop-Starter", @@ -71,7 +69,7 @@ function pick(obj: object, key: string): string { describe("t()", () => { it("löst Punkt-Pfade auf", () => { - expect(t("panel.tabSearch")).toBe("Suche"); + expect(t("panel.tabDraft")).toBe("Dokument"); }); it("setzt Platzhalter ein", () => { @@ -90,7 +88,7 @@ describe("t()", () => { it("folgt der gespeicherten Sprache", () => { window.localStorage.setItem("lang", "en"); expect(storedLocale()).toBe("en"); - expect(t("panel.tabSearch")).toBe("Search"); + expect(t("panel.tabDraft")).toBe("Document"); }); it("fällt bei unbekannter Sprache auf die Browsersprache zurück", () => { @@ -102,13 +100,13 @@ describe("t()", () => { describe("applyI18n()", () => { it("beschriftet Text und Attribute", () => { document.body.innerHTML = ` - + `; applyI18n(); const btn = document.querySelector("button")!; - expect(btn.textContent).toBe("Suche"); - expect(btn.title).toBe("Suchtreffer"); + expect(btn.textContent).toBe("Archiv"); + expect(btn.title).toBe("Archiv-Notizen"); expect(document.querySelectorAll("button")[1].getAttribute("aria-label")).toBe( "Schließen", ); diff --git a/src/messages.ts b/src/messages.ts index 58dcbc1..34950dd 100644 --- a/src/messages.ts +++ b/src/messages.ts @@ -34,7 +34,6 @@ const de = { start: "Starten", stop: "Beenden", terminalSettings: "Terminal-Einstellungen", - openArchive: "Archiv öffnen (Trilium)", stillRunning: "{name} läuft noch", poolChangeSaved: "Der Pool-Wechsel ist gespeichert. Die laufende Session arbeitet aber weiter im alten Pool — der neue gilt erst ab dem nächsten Start.", @@ -60,6 +59,8 @@ const de = { addFolder: "Projekt importieren …", addFolderTitle: "Bestehenden Ordner als Projekt aufnehmen", addWorkDir: "Ordner hinzufügen …", + archive: "Archiv", + archiveNone: "kein Archiv — Panel ohne Wiki und Suche", changeDir: "Ändern …", wizardTitle: "Neues Projekt", name: "Name", @@ -81,17 +82,25 @@ const de = { scopeIntegrationDesc: "ai-control-Spuren werden entfernt; Projektordner, memory/ und Todoliste bleiben.", scopeArchive: "Integration & Archiv", - scopeArchiveDesc: "zusätzlich das Trilium-Archiv {path} — endgültig.", + scopeArchiveDesc: "zusätzlich {path} — endgültig.", scopeFull: "Projekt komplett löschen", scopeFullDesc: "zusätzlich der Projektordner {path} — endgültig.", deletePreviewTitle: "Wird entfernt:", + docCount: "kein Dokument | 1 Dokument | {count} Dokumente", artRegistry: "Registry-Eintrag", artAiControl: ".ai-control/ (Config + Icon)", + artArchivePerm: "Archiv-Berechtigung in .claude/settings.json", artPanelFiles: "Panel-Kanaldatei | {count} Panel-Kanaldateien", artDesktop: ".desktop-Starter", - artArchive: "Trilium-Archiv {path}", + artArchive: "Archiv {path}", artProjectDir: "Projektordner {path}", artWorkDir: "Arbeitsordner {path}", + archiveChangeTitle: "Archiv wechseln", + archiveChangeText: "Das Archiv wechselt von {old} nach {neu}.", + archiveMigrate: "Dokumente ins neue Archiv verschieben", + archiveMigrateHint: + "Ohne Haken bleibt das bisherige Archiv unverändert liegen; die App verweist künftig nur noch auf das neue.", + archiveChangeConfirm: "Wechseln", groupAppearance: "Darstellung", groupFolders: "Ordner", groupModules: "Module", @@ -102,28 +111,8 @@ const de = { commandsDesc: "Befehls-Kacheln im Panel (write_commands)", todo: "ToDo-Liste", todoDesc: "persistente Aufgaben-Kacheln mit Fälligkeits-Ampel; öffnet beim Sessionstart", - trilium: "Trilium", - triliumDesc: - "Archiv als eigene Trilium-Instanz — Archivieren, Suche und Notizen laufen über Trilium", - }, - trilium: { - wizardTitle: "Trilium einrichten", - wizardIntro: - "Richtet das Projekt-Archiv als eigene Trilium-Instanz ein: Runtime versionsgepinnt geladen, Instanz nur auf 127.0.0.1 mit eigenem Datenverzeichnis und Port. Das Modul wird erst nach erfolgreicher Verbindung aktiviert.", - stepRuntime: "Runtime vorhanden", - stepInstance: "Instanz läuft", - stepToken: "Verbunden (ETAPI-Token)", - port: "Port {port}", - dataDir: "Daten", - firstRunHint: - "Beim ersten Verbinden legt die App das Dokument an und setzt das gewählte Passwort. Gespeichert wird nur der ETAPI-Token (Keyring), nie das Passwort.", - password: "Passwort", - connect: "Verbinden", - setup: "Einrichten / Starten", - working: "Läuft …", - open: "Trilium öffnen", - close: "Schließen", - wizard: "Einrichtung / Status", + archive: "Archiv", + archiveDesc: "Archivieren, Wiki und Suche", }, todos: { copyOne: "Aufgabe kopieren", @@ -189,15 +178,17 @@ const de = { }, panel: { windowTerminal: "Terminal", - windowPanel: "Panel", + windowDocument: "Dokument", windowProjects: "Projekte", tabCommands: "Befehle", tabCommandsTitle: "Befehls-History", tabTodo: "ToDo", tabTodoTitle: "ToDo-Liste des Projekts", + tabDraft: "Dokument", + tabDraftTitle: "Dokument", + tabWiki: "Archiv", + tabWikiTitle: "Archiv-Notizen", tabSearch: "Suche", - archiveOpen: "Archiv", - archiveOpenTitle: "Archiv in Trilium öffnen", tabSearchTitle: "Suchtreffer", minimize: "Minimieren", maximize: "Maximieren", @@ -206,6 +197,16 @@ const de = { hidePanel: "Panel ausblenden", detach: "In eigenes Fenster ablösen", dock: "Wieder andocken", + editTitle: "Titel bearbeiten", + spellcheckLang: "Sprache der Rechtschreibprüfung", + openInWiki: "Im Wiki öffnen", + toggleRaw: "Rohtext / gerendert", + rendered: "MD", + raw: "Roh", + editDraft: "Entwurf bearbeiten (Cmd/Ctrl+Enter speichert, Esc verwirft)", + copy: "In die Zwischenablage kopieren", + archive: "In den Archiv-Ordner speichern", + chooseArchiveDir: "Archiv-Ordner wählen", processEnded: "[Prozess beendet]", }, popup: { @@ -218,12 +219,47 @@ const de = { copyAll: "Alle kopieren", session: "Session", }, + archiveForm: { + folder: "Ordner — z. B. konzepte/panel", + description: "Beschreibung", + tags: "Schlagwörter, kommagetrennt", + submit: "Archivieren", + }, search: { placeholder: "Archiv durchsuchen — #tag filtert", minChars: "Mindestens 3 Zeichen — oder Enter.", hits: "{count} Treffer {scope}", noHits: "Keine Treffer für {scope}", }, + wiki: { + archive: "Archiv", + docOne: "{count} Dokument", + docMany: "{count} Dokumente", + backlinks: "Verweise hierher", + emptyArchive: "Das Archiv ist leer.", + emptyFolder: "Keine Dokumente in diesem Ordner.", + emptyHint: + "Archivieren: Archiv-Button im Entwurf oder „archiviere das“ im Chat — mit Ordner, Beschreibung und Schlagwörtern.", + noPage: "Keine Wiki-Seite geladen.", + openOverview: "Archiv-Übersicht öffnen", + folderRename: "Ordnerpfad ändern", + renameDoc: "Dateiname ändern", + titleEdit: "Titel bearbeiten — klicken", + editDoc: "Bearbeiten", + save: "Speichern", + back: "Zurück", + info: "Details (Datum, Schlagwörter, Verweise)", + infoCaption: "Details", + createdAt: "angelegt {date}", + changedAt: "geändert {date}", + deleteDoc: "Dokument löschen", + newFolder: "Neuer Ordner", + newDoc: "Neues Dokument", + create: "Anlegen", + cancel: "Abbrechen", + folderPath: "ordner/unterordner", + docName: "Name des Dokuments", + }, }; const en: typeof de = { @@ -255,7 +291,6 @@ const en: typeof de = { start: "Start", stop: "Stop", terminalSettings: "Terminal settings", - openArchive: "Open archive (Trilium)", stillRunning: "{name} is still running", poolChangeSaved: "The pool change is saved. The running session keeps working in the old pool — the new one applies from the next start.", @@ -281,6 +316,8 @@ const en: typeof de = { addFolder: "Import project …", addFolderTitle: "Add an existing folder as a project", addWorkDir: "Add folder …", + archive: "Archive", + archiveNone: "no archive — panel without wiki and search", changeDir: "Change …", wizardTitle: "New project", name: "Name", @@ -302,17 +339,25 @@ const en: typeof de = { scopeIntegrationDesc: "ai-control traces are removed; project folder, memory/ and todo list stay.", scopeArchive: "Integration & archive", - scopeArchiveDesc: "additionally the Trilium archive {path} — permanent.", + scopeArchiveDesc: "additionally {path} — permanent.", scopeFull: "Delete project completely", scopeFullDesc: "additionally the project folder {path} — permanent.", deletePreviewTitle: "Will be removed:", + docCount: "no documents | 1 document | {count} documents", artRegistry: "registry entry", artAiControl: ".ai-control/ (config + icon)", + artArchivePerm: "archive permission in .claude/settings.json", artPanelFiles: "panel channel file | {count} panel channel files", artDesktop: ".desktop launcher", - artArchive: "Trilium archive {path}", + artArchive: "archive {path}", artProjectDir: "project folder {path}", artWorkDir: "working folder {path}", + archiveChangeTitle: "Change archive", + archiveChangeText: "The archive changes from {old} to {neu}.", + archiveMigrate: "Move documents to the new archive", + archiveMigrateHint: + "Unchecked, the previous archive stays untouched; the app just points to the new one.", + archiveChangeConfirm: "Change", groupAppearance: "Appearance", groupFolders: "Folders", groupModules: "Modules", @@ -323,28 +368,8 @@ const en: typeof de = { commandsDesc: "command tiles in the panel (write_commands)", todo: "ToDo list", todoDesc: "persistent task tiles with due-date signal; opens at session start", - trilium: "Trilium", - triliumDesc: - "archive as a dedicated Trilium instance — archiving, search and notes run through Trilium", - }, - trilium: { - wizardTitle: "Set up Trilium", - wizardIntro: - "Sets up the project archive as its own Trilium instance: version-pinned runtime download, instance on 127.0.0.1 only with its own data directory and port. The module is enabled only after a successful connection.", - stepRuntime: "Runtime present", - stepInstance: "Instance running", - stepToken: "Connected (ETAPI token)", - port: "port {port}", - dataDir: "Data", - firstRunHint: - "On first connect the app creates the document and sets the chosen password. Only the ETAPI token is stored (keyring), never the password.", - password: "Password", - connect: "Connect", - setup: "Set up / start", - working: "Working …", - open: "Open Trilium", - close: "Close", - wizard: "Setup / status", + archive: "Archive", + archiveDesc: "archiving, wiki and search", }, todos: { copyOne: "Copy task", @@ -410,15 +435,17 @@ const en: typeof de = { }, panel: { windowTerminal: "Terminal", - windowPanel: "Panel", + windowDocument: "Document", windowProjects: "Projects", tabCommands: "Commands", tabCommandsTitle: "Command history", tabTodo: "ToDo", tabTodoTitle: "Project to-do list", + tabDraft: "Document", + tabDraftTitle: "Document", + tabWiki: "Archive", + tabWikiTitle: "Archive notes", tabSearch: "Search", - archiveOpen: "Archive", - archiveOpenTitle: "Open the archive in Trilium", tabSearchTitle: "Search hits", minimize: "Minimize", maximize: "Maximize", @@ -427,6 +454,16 @@ const en: typeof de = { hidePanel: "Hide panel", detach: "Detach into its own window", dock: "Dock again", + editTitle: "Edit title", + spellcheckLang: "Spell-check language", + openInWiki: "Open in wiki", + toggleRaw: "Raw text / rendered", + rendered: "MD", + raw: "Raw", + editDraft: "Edit draft (Cmd/Ctrl+Enter saves, Esc discards)", + copy: "Copy to clipboard", + archive: "Save to the archive folder", + chooseArchiveDir: "Choose archive folder", processEnded: "[process ended]", }, popup: { @@ -439,12 +476,47 @@ const en: typeof de = { copyAll: "Copy all", session: "Session", }, + archiveForm: { + folder: "Folder — e.g. concepts/panel", + description: "Description", + tags: "Tags, comma-separated", + submit: "Archive", + }, search: { placeholder: "Search the archive — #tag filters", minChars: "At least 3 characters — or press Enter.", hits: "{count} hits {scope}", noHits: "No hits for {scope}", }, + wiki: { + archive: "Archive", + docOne: "{count} document", + docMany: "{count} documents", + backlinks: "Links here", + emptyArchive: "The archive is empty.", + emptyFolder: "No documents in this folder.", + emptyHint: + "To archive: the archive button in the draft, or “archive this” in the chat — with folder, description and tags.", + noPage: "No wiki page loaded.", + openOverview: "Open archive overview", + folderRename: "Change folder path", + renameDoc: "Change file name", + titleEdit: "Edit title — click", + editDoc: "Edit", + save: "Save", + back: "Back", + info: "Details (date, tags, links)", + infoCaption: "Info", + createdAt: "created {date}", + changedAt: "changed {date}", + deleteDoc: "Delete document", + newFolder: "New folder", + newDoc: "New document", + create: "Create", + cancel: "Cancel", + folderPath: "folder/subfolder", + docName: "Document name", + }, }; export const messages = { de, en }; @@ -466,7 +538,7 @@ export function storedLocale(): Locale { return navigator.language.startsWith("de") ? "de" : "en"; } -/// Übersetzt einen Punkt-Pfad (`panel.tabSearch`) und setzt `{platzhalter}` ein. +/// Übersetzt einen Punkt-Pfad (`panel.tabDraft`) und setzt `{platzhalter}` ein. /// Fehlt der Schlüssel, kommt der Pfad selbst zurück — im UI sichtbar statt /// still leer. export function t(key: string, params?: Record): string { diff --git a/src/modules/archive.ts b/src/modules/archive.ts index 2a67e72..5062dce 100644 --- a/src/modules/archive.ts +++ b/src/modules/archive.ts @@ -1,22 +1,76 @@ import { invoke } from "@tauri-apps/api/core"; import { initSearchView } from "../search-view"; +import { initWikiView } from "../wiki-view"; import type { PanelTab } from "./index"; +/// Suchtreffer-Sprung ins Archiv: der Treffer merkt den relpath vor, lädt die +/// Übersicht neu (wiki-update wechselt den Tab), und die Archiv-Ansicht holt +/// die Vormerkung beim nächsten set() ab. +let pendingSelect: string | null = null; + +export const wikiTab: PanelTab = { + mode: "wiki", + module: "archive", + buffer: "wiki", + popupOnly: true, + labelKey: "panel.tabWiki", + titleKey: "panel.tabWikiTitle", + // Dokument-/Ordner-Operationen laufen als Commands; der neue Stand kommt + // über den Wiki-Puffer (wiki-update) zurück, Fehler als Toast. Der + // Notiz-Inhalt kommt pro Auswahl über archive_read; Bearbeiten lädt wie + // bisher in den Dokument-Tab. + init: (container, ctx) => { + const run = (cmd: string, args: Record) => + void invoke(cmd, { project: ctx.project, ...args }).catch((e) => + ctx.toast(String(e)), + ); + return initWikiView(container, { + autoStart: ctx.standalone, + readDoc: (relpath) => invoke("archive_read", { project: ctx.project, relpath }), + writeDoc: (relpath, text) => + invoke("archive_write", { project: ctx.project, relpath, text }), + setTitle: (relpath, title) => run("archive_set_title", { relpath, title }), + openWiki: ctx.openWiki, + takePending: () => { + const p = pendingSelect; + pendingSelect = null; + return p; + }, + actions: { + rename: (relpath, name) => run("archive_rename", { relpath, name }), + remove: (relpath) => run("archive_delete", { relpath }), + moveFolder: (folder, to) => run("archive_move_folder", { folder, to }), + createFolder: (folder) => run("archive_create_folder", { folder }), + createDoc: (folder, name) => run("archive_create_doc", { folder, name }), + }, + }); + }, + // Beim Aktivieren die Übersicht frisch laden: das Anlegen eines Dokuments + // öffnet den Dokument-Tab, ohne den Wiki-Puffer anzufassen — und auch + // direkt archivierte oder von Hand abgelegte Dateien erscheinen so. + // Die Fenstergröße setzt allein open_panel_window beim Öffnen des Popups. + onActivate: (_view, ctx) => ctx.openWiki("tag:"), +}; + export const searchTab: PanelTab = { mode: "search", - module: "trilium", + module: "archive", buffer: "search", + popupOnly: true, labelKey: "panel.tabSearch", titleKey: "panel.tabSearchTitle", + // Trenner: Archiv-Fenster-Tabs (Archiv, Suche) links, Session-Tabs rechts. sepAfter: true, - // Treffer-Klick öffnet die Notiz im Trilium-Fenster des Projekts. + // Treffer-Klick öffnet die Notiz im Archiv-Tab: relpath vormerken, die + // Übersicht frisch laden — das wiki-update wechselt den Tab und die + // Ansicht wählt die vorgemerkte Notiz aus. init: (container, ctx) => initSearchView( container, - (noteId) => - void invoke("trilium_open", { project: ctx.project, noteId }).catch((e) => - ctx.toast(String(e)), - ), + (_path, relpath) => { + pendingSelect = relpath; + ctx.openWiki("tag:"); + }, (raw) => { // `#tag`-Tokens filtern aufs Schlagwort, der Rest ist die Volltext-Query. const words = raw.split(/\s+/).filter(Boolean); diff --git a/src/modules/commands.ts b/src/modules/commands.ts index 7a8aad5..dac244f 100644 --- a/src/modules/commands.ts +++ b/src/modules/commands.ts @@ -8,7 +8,6 @@ export const commandsTab: PanelTab = { 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 index 5518d82..5b8b3a2 100644 --- a/src/modules/index.ts +++ b/src/modules/index.ts @@ -6,7 +6,7 @@ /// `-update`-Event. import { commandsTab } from "./commands"; -import { searchTab } from "./archive"; +import { searchTab, wikiTab } from "./archive"; import { todoTab } from "./todo"; export interface ModuleView { @@ -18,7 +18,13 @@ export interface ModuleView { /// braucht; Modulspezifisches (eigene Invokes) macht der Deskriptor selbst. export interface ModuleCtx { project: string; + /// Läuft die Ansicht im eigenen Panel-Fenster (statt angedockt)? + standalone: boolean; 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 { @@ -27,16 +33,23 @@ export interface PanelTab { mode: string; /// Backend-Modul (enabled_modules) — bestimmt, ob der Tab erscheint. module: string; + /// Tab gehört ins eigene Panel-Fenster: angedockt zieht ein Puffer-Update + /// den Tab nicht in den Vordergrund (Klick öffnet dort das Fenster). + popupOnly?: boolean; /// 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. - init(container: HTMLElement, ctx: ModuleCtx): ModuleView; - /// Nach Tab-Klick. + /// 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, searchTab]; +// Der Entwurf ("draft") hat bewusst KEINEN Tab: Die Ansicht erscheint, wenn +// ein Entwurf hereinkommt (panel-update → mode "draft") oder eine Notiz zum +// Bearbeiten geladen wird — als flüchtige Fläche, nicht als Reiter. +export const PANEL_TABS: PanelTab[] = [wikiTab, searchTab, todoTab, commandsTab]; diff --git a/src/panel-tiles.css b/src/panel-tiles.css index 2c50484..ff03dc8 100644 --- a/src/panel-tiles.css +++ b/src/panel-tiles.css @@ -139,7 +139,7 @@ border: 1px solid var(--ok); } /* Kopfzeile mit Anlegen-Knopf und das ToDo-Formular (Anlegen/Bearbeiten), - in-flow über der Liste. */ + in-flow über der Liste; Input-Stil wie im Archiv-Formular. */ .todo-head { display: flex; justify-content: flex-end; @@ -311,6 +311,695 @@ font-size: 10px; } +/* Wiki-Ansicht: Ordner-Baum links, flache Inhaltsliste rechts. */ +#wiki-content { + flex: 1; + min-height: 0; + overflow: auto; + padding: 14px 16px; +} +#wiki-content::-webkit-scrollbar { + width: 8px; +} +#wiki-content::-webkit-scrollbar-thumb { + background: var(--line); + border-radius: 4px; +} +/* Baum links, Seite rechts; beide scrollen gemeinsam im Tab. */ +.wiki-layout { + display: flex; + align-items: flex-start; + gap: 12px; +} +.wiki-tree { + flex: 0 0 230px; + min-width: 0; + border-right: 1px solid var(--line-strong); + padding-right: 8px; + position: sticky; + top: 0; +} +.wiki-main { + flex: 1; + min-width: 0; +} +.wiki-tree .wiki-tree-children { + padding-left: 0; + border-left: 2px solid var(--line-strong); +} +/* Wurzel-Ebene: Linie mittig unter dem Archiv-Knoten. */ +.wiki-tree > .wiki-tree-children { + margin-left: 1em; +} +/* Ordner-Ebenen: Linie genauso mittig unter dem Ordner-Knoten (0.7em + Zeilenbeginn + 1em). */ +.wiki-tree details > .wiki-tree-children { + margin-left: 1.7em; +} +/* Absatz zwischen Dokumenten und Ordner-Gruppe derselben Ebene. */ +.wiki-tree .wiki-tree-doc + details { + margin-top: 3px; +} +.wiki-tree summary { + display: flex; + align-items: center; + gap: 2px; + list-style: none; + cursor: pointer; + padding: 5px 6px 5px 0.7em; + border-radius: 5px; + color: var(--text); + font: + 500 16px/1.5 -apple-system, + system-ui, + sans-serif; + user-select: none; + -webkit-user-select: none; +} +/* Notiz-Blatt im Baum (Dokument ohne Kinder). */ +.wiki-tree-doc { + display: flex; + align-items: center; + width: 100%; + text-align: left; + border: none; + background: none; + cursor: pointer; + padding: 5px 6px 5px 0.4em; + border-radius: 5px; + color: var(--text); + font: + 400 16px/1.5 -apple-system, + system-ui, + sans-serif; +} +/* Typ-Symbole (Ordner/Notiz) im Trilium-Stil vor dem Namen. */ +.wiki-tree-icon { + flex: none; + display: inline-flex; + margin-right: 6px; +} +.wiki-tree-icon svg { + fill: none; + stroke: var(--muted); + stroke-width: 1.3; + stroke-linecap: round; + stroke-linejoin: round; +} +.wiki-tree summary.active .wiki-tree-icon svg, +.wiki-tree-doc.active .wiki-tree-icon svg { + stroke: var(--accent); +} +.wiki-tree-doc:hover { + background: var(--tile); +} +.wiki-tree-doc.active, +.wiki-tree summary.active, +.wiki-tree-root.active { + background: var(--tile); + color: var(--accent); +} +.wiki-tree summary::-webkit-details-marker { + display: none; +} +.wiki-tree-arrow { + flex: none; + width: 16px; + color: var(--muted); + font-size: 13px; + text-align: center; + transition: transform 0.12s; +} +.wiki-tree-arrow:hover { + color: var(--text); +} +.wiki-tree details[open] > summary .wiki-tree-arrow { + transform: rotate(90deg); +} +.wiki-tree-name { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.wiki-tree summary .panel-btn { + visibility: hidden; +} +.wiki-tree summary:hover .panel-btn { + visibility: visible; +} +.wiki-tree summary:hover, +.wiki-tree-root:hover { + background: var(--tile); + border-radius: 4px; +} +.wiki-tree-root { + display: block; + width: 100%; + text-align: left; + border: none; + background: none; + color: var(--muted); + border-radius: 4px; + padding: 2px 4px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font: + 600 16px/1.7 -apple-system, + system-ui, + sans-serif; +} +.wiki-tree-input { + box-sizing: border-box; + width: 100%; + background: var(--surface); + border: 1px solid var(--line-strong); + border-radius: 4px; + padding: 2px 4px; + color: var(--text); + font: + 400 15px/1.4 -apple-system, + system-ui, + sans-serif; + outline: none; +} +/* Zeilen-Aktionen erst beim Überfahren der Zeile. */ +.wiki-doc-actions { + display: flex; + gap: 2px; + visibility: hidden; +} +.wiki-doc:hover .wiki-doc-actions { + visibility: visible; +} +/* Kopfzeile des Baums: Wurzel-Knopf + Ordner anlegen. */ +.wiki-tree-head { + display: flex; + align-items: center; + gap: 2px; +} +.wiki-tree-head .wiki-tree-root { + flex: 1; + min-width: 0; +} +.wiki-add { + border: none; + background: none; + color: var(--muted); + border-radius: 4px; + padding: 0 5px; + font: + 500 13px/1.4 -apple-system, + system-ui, + sans-serif; + cursor: pointer; +} +.wiki-add:hover { + background: var(--tile); + color: var(--text); +} +/* Anlege-Dialog (Ordner/Dokument): modales Popup mit abgedunkeltem + Hintergrund. */ +.wiki-modal { + position: fixed; + inset: 0; + z-index: 300; + background: rgba(0, 0, 0, 0.45); + display: flex; + align-items: center; + justify-content: center; +} +.wiki-form { + display: flex; + flex-direction: column; + gap: 12px; + min-width: 380px; + background: var(--tile); + border: 1px solid var(--line-strong); + border-radius: 12px; + box-shadow: + 0 16px 48px rgba(0, 0, 0, 0.6), + 0 2px 8px rgba(0, 0, 0, 0.4); + padding: 18px 20px; +} +.wiki-form-title { + color: var(--text); + font: + 600 16px/1.4 -apple-system, + system-ui, + sans-serif; +} +.wiki-form-row { + display: flex; + justify-content: flex-end; + gap: 8px; +} +.wiki-form .wiki-tree-input { + width: 100%; + padding: 8px 10px; + font-size: 15px; +} +.wiki-form-submit, +.wiki-form-cancel { + border: none; + background: var(--line); + color: var(--text); + border-radius: 6px; + padding: 7px 16px; + cursor: pointer; + font: + 500 14px/1.4 -apple-system, + system-ui, + sans-serif; +} +.wiki-form-submit:hover { + background: var(--line-strong); +} +.wiki-form-cancel { + background: none; + color: var(--muted); +} +.wiki-form-cancel:hover { + background: var(--tile); +} +/* Notiz-Ansicht rechts: großer Titel wie in Trilium, Meta-Zeile, Inhalt. */ +.wiki-note-head { + border-bottom: 1px solid var(--line-strong); + padding-bottom: 8px; + margin-bottom: 12px; +} +.wiki-note-titlerow { + display: flex; + align-items: center; + gap: 8px; +} +.wiki-note-back { + flex: none; + border: none; + background: none; + color: var(--muted); + border-radius: 6px; + padding: 2px 8px; + font-size: 20px; + line-height: 1.2; + cursor: pointer; +} +.wiki-note-back:hover:not(:disabled) { + background: var(--tile); + color: var(--text); +} +.wiki-note-back:disabled { + color: var(--line); + cursor: default; +} +.wiki-note-title { + flex: 1; + min-width: 0; + color: var(--text); + font: + 600 24px/1.3 -apple-system, + system-ui, + sans-serif; +} +.wiki-note-title.editable { + cursor: text; + border-radius: 6px; + padding: 2px 6px; + margin-left: -6px; +} +.wiki-note-title.editable:hover { + background: var(--tile); +} +.wiki-note-title-input { + flex: 1; + min-width: 0; + box-sizing: border-box; + background: var(--surface); + color: var(--text); + border: 1px solid var(--accent); + border-radius: 6px; + padding: 2px 6px; + margin-left: -6px; + outline: none; + font: + 600 24px/1.3 -apple-system, + system-ui, + sans-serif; +} +.wiki-note-actions { + display: flex; + gap: 2px; + visibility: hidden; +} +.wiki-note-head:hover .wiki-note-title.editable { + cursor: text; + border-radius: 6px; + padding: 2px 6px; + margin-left: -6px; +} +.wiki-note-title.editable:hover { + background: var(--tile); +} +.wiki-note-title-input { + flex: 1; + min-width: 0; + box-sizing: border-box; + background: var(--surface); + color: var(--text); + border: 1px solid var(--accent); + border-radius: 6px; + padding: 2px 6px; + margin-left: -6px; + outline: none; + font: + 600 24px/1.3 -apple-system, + system-ui, + sans-serif; +} +.wiki-note-actions { + visibility: visible; +} +/* Markdown-Editor der Notiz (Bearbeiten im Archiv): Rohtext links, + Live-Vorschau rechts. */ +.wiki-edit-split { + display: flex; + gap: 14px; + align-items: stretch; +} +.wiki-edit-split > * { + flex: 1 1 50%; + min-width: 0; +} +.wiki-edit-preview { + overflow: auto; + max-height: 70vh; + border-left: 1px solid var(--line-strong); + padding-left: 14px; +} +.wiki-note-editor { + box-sizing: border-box; + width: 100%; + min-height: 60vh; + resize: vertical; + background: var(--surface); + color: var(--text); + border: 1px solid var(--line-strong); + border-radius: 8px; + padding: 12px 14px; + outline: none; + font: 400 14px/1.6 "JetBrains Mono", Menlo, monospace; +} +.wiki-note-editor:focus { + border-color: var(--accent); +} +.wiki-note-error { + color: #f38ba8; + margin-bottom: 8px; + font: + 500 13px/1.5 -apple-system, + system-ui, + sans-serif; +} +/* Kontextmenü im Baum (rechte Maustaste). */ +.wiki-menu { + position: fixed; + z-index: 200; + min-width: 180px; + background: var(--tile); + border: 1px solid var(--line-strong); + border-radius: 8px; + box-shadow: + 0 12px 40px rgba(0, 0, 0, 0.55), + 0 2px 8px rgba(0, 0, 0, 0.4); + padding: 4px; + display: flex; + flex-direction: column; +} +.wiki-menu-item { + border: none; + background: none; + color: var(--text); + text-align: left; + border-radius: 5px; + padding: 6px 10px; + cursor: pointer; + font: + 400 14px/1.4 -apple-system, + system-ui, + sans-serif; +} +.wiki-menu-item:hover { + background: var(--surface); +} +/* Info-Popup am ⓘ-Knopf der Titelzeile. */ +.wiki-note-titlerow { + position: relative; +} +.wiki-note-info-pop { + position: absolute; + top: 100%; + right: 0; + z-index: 100; + min-width: 240px; + background: var(--tile); + border: 1px solid var(--accent); + border-radius: 10px; + box-shadow: + 0 12px 40px rgba(0, 0, 0, 0.55), + 0 2px 8px rgba(0, 0, 0, 0.4); + padding: 12px 16px; + margin-top: 6px; +} +.wiki-info-caption { + color: var(--text); + margin-bottom: 6px; + font: + 600 13px/1.4 -apple-system, + system-ui, + sans-serif; +} +.wiki-info-line { + color: var(--muted); + white-space: nowrap; + font: + 500 13px/1.7 -apple-system, + system-ui, + sans-serif; +} +.wiki-note-body { + color: var(--text); + font: + 400 17px/1.6 -apple-system, + system-ui, + sans-serif; +} +.wiki-note-body h1, +.wiki-note-body h2, +.wiki-note-body h3 { + line-height: 1.25; + margin: 1em 0 0.4em; +} +.wiki-note-body h1 { + font-size: 1.35em; +} +.wiki-note-body h2 { + font-size: 1.15em; +} +.wiki-note-body h3 { + font-size: 1.05em; +} +.wiki-note-body :first-child { + margin-top: 0; +} +.wiki-note-body p, +.wiki-note-body ul, +.wiki-note-body ol, +.wiki-note-body pre, +.wiki-note-body blockquote { + margin: 0 0 0.7em; +} +.wiki-note-body code { + font-family: "JetBrains Mono", Menlo, monospace; + font-size: 0.85em; + background: var(--line); + border-radius: 4px; + padding: 1px 5px; +} +.wiki-note-body pre { + background: var(--tile); + border-radius: 8px; + padding: 12px 14px; + overflow: auto; +} +.wiki-note-body pre code { + background: none; + padding: 0; +} +.wiki-note-body blockquote { + border-left: 3px solid var(--line); + padding-left: 12px; + color: var(--muted); +} +.wiki-note-body a { + color: var(--accent); +} +.wiki-note-children { + margin-top: 26px; + background: var(--tile); + border: 1px solid var(--line-strong); + border-radius: 10px; + padding: 12px 14px; +} +.wiki-children-caption { + color: var(--faint); + text-transform: uppercase; + letter-spacing: 0.08em; + margin-bottom: 10px; + font: + 600 12px/1.4 -apple-system, + system-ui, + sans-serif; +} +/* Default-Text einer Ordner-Notiz ohne Inhaltsdatei. */ +.wiki-note-body.default { + color: var(--muted); +} +/* Einstiegs-Knopf im Leerzustand. */ +.wiki-chip { + border: 1px solid var(--line-strong); + background: transparent; + color: var(--muted); + border-radius: 999px; + padding: 2px 8px; + font: + 500 10.5px/1.5 -apple-system, + system-ui, + sans-serif; +} +.wiki-chip:hover { + background: var(--line); + color: var(--text); +} +.wiki-doc { + padding: 10px 12px; + margin: 0 0 8px; + background: var(--surface); + border: 1px solid var(--line-strong); + border-radius: 8px; + cursor: pointer; +} +.wiki-doc:last-child { + margin-bottom: 0; +} +.wiki-doc:hover { + border-color: var(--accent); +} +.wiki-doc-line { + display: flex; + align-items: baseline; + gap: 8px; +} +.wiki-doc-title { + flex: 1; + min-width: 0; + color: var(--accent); + font: + 600 16px/1.4 -apple-system, + system-ui, + sans-serif; +} +.wiki-doc-date { + color: var(--faint); + font-family: "JetBrains Mono", Menlo, monospace; + font-size: 11.5px; + white-space: nowrap; +} +.wiki-doc-desc { + margin-top: 2px; + color: var(--muted); + font: + 400 14px/1.5 -apple-system, + system-ui, + sans-serif; +} +.wiki-empty { + margin-top: 18px; + color: var(--muted); + font: + 400 12px/1.6 -apple-system, + system-ui, + sans-serif; +} +.wiki-empty strong { + display: block; + margin-bottom: 6px; + color: var(--text); + font-weight: 600; +} +/* Backlink-Zähler in der Dokumentzeile. */ +.wiki-doc-back { + color: var(--faint); + font: + 500 10px/1 -apple-system, + system-ui, + sans-serif; + white-space: nowrap; +} + +/* Archiv-Formular: klappt unter dem Archiv-Button auf. */ +.archive-form { + position: fixed; + z-index: 20; + display: flex; + flex-direction: column; + gap: 6px; + width: 260px; + background: var(--surface); + border: 1px solid var(--line-strong); + border-radius: 8px; + padding: 10px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); +} +.archive-form[hidden] { + display: none; +} +.archive-form input { + box-sizing: border-box; + width: 100%; + background: var(--tile); + border: 1px solid var(--line-strong); + border-radius: 6px; + padding: 6px 10px; + color: var(--text); + font: + 400 12px/1.4 -apple-system, + system-ui, + sans-serif; + outline: none; +} +.archive-form input:focus { + border-color: var(--line-strong); +} +.archive-form input::placeholder { + color: var(--faint); +} +.archive-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; +} +.archive-form-submit:hover { + background: var(--line-strong); +} + /* Sichtbare Fehlermeldung (panelToast), oben rechts über dem Panel-Inhalt. */ .panel-toast { position: fixed; diff --git a/src/panel-view.test.ts b/src/panel-view.test.ts new file mode 100644 index 0000000..31de2ba --- /dev/null +++ b/src/panel-view.test.ts @@ -0,0 +1,68 @@ +// Dokument-Ansicht: Wikilink-Verlinkung im DOM und Editor-Flush. +import { describe, expect, it, vi } from "vitest"; +import { initPanelView, linkWikiRefs } from "./panel-view"; + +describe("linkWikiRefs", () => { + it("verlinkt [[ziel]] und [[ziel|Label]], Code bleibt unangetastet", () => { + const root = document.createElement("div"); + root.innerHTML = + "

Siehe [[adr-logging]] und [[notiz|die Notiz]].

" + + "
if [[ -f x ]]; then
"; + const onClick = vi.fn(); + linkWikiRefs(root, onClick); + const links = [...root.querySelectorAll("a.wiki")]; + expect(links.map((a) => a.textContent)).toEqual(["adr-logging", "die Notiz"]); + links[1].click(); + expect(onClick).toHaveBeenCalledWith("notiz"); + expect(root.querySelector("code")!.textContent).toBe("if [[ -f x ]]; then"); + }); +}); + +function viewSetup(onCommit: (text: string) => void | Promise) { + document.body.innerHTML = ` +
+ + + `; + const view = initPanelView({ + content: document.getElementById("content")!, + copyBtn: document.getElementById("copy")!, + modeBtn: document.getElementById("mode")!, + editContentBtn: document.getElementById("edit")!, + onCommit, + }); + const editor = document.querySelector(".panel-editor")!; + return { view, editor, editBtn: document.getElementById("edit")! }; +} + +describe("initPanelView — Editor", () => { + it("flush speichert eine offene Bearbeitung und beendet sie", async () => { + const onCommit = vi.fn(); + const { view, editor, editBtn } = viewSetup(onCommit); + view.set("Alt"); + editBtn.click(); + expect(editor.hidden).toBe(false); + editor.value = "Neu"; + await view.flush(); + expect(onCommit).toHaveBeenCalledWith("Neu"); + expect(editor.hidden).toBe(true); + }); + + it("flush ohne offene Bearbeitung tut nichts", async () => { + const onCommit = vi.fn(); + const { view } = viewSetup(onCommit); + view.set("Alt"); + await view.flush(); + expect(onCommit).not.toHaveBeenCalled(); + }); + + it("puffert eingehende Updates während der Bearbeitung", () => { + const { view, editor, editBtn } = viewSetup(() => {}); + view.set("Alt"); + editBtn.click(); + view.set("Update von außen"); + expect(editBtn.classList.contains("changed")).toBe(true); + editor.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); + expect(view.raw()).toBe("Update von außen"); + }); +}); diff --git a/src/panel-view.ts b/src/panel-view.ts new file mode 100644 index 0000000..f6cc719 --- /dev/null +++ b/src/panel-view.ts @@ -0,0 +1,271 @@ +import { renderMarkdown } from "./markdown"; +import { writeText } from "@tauri-apps/plugin-clipboard-manager"; +import { flash } from "./tiles"; +import { t } from "./messages"; + +export interface PanelView { + set(text: string): void; + raw(): string; + /// Schreibt eine offene Inhalts-Bearbeitung zurück (und beendet sie); + /// aufgelöst, sobald der Entwurf gespeichert ist. + flush(): Promise; +} + +/// Verkabelt einen Panel-Inhaltsbereich: MD/Roh-Umschalter und Copy-Button. +/// Der Rohtext bleibt die Quelle für „Kopieren"; die Ansicht rendert Markdown. +/// Entfernt Inline-Markdown (Emphasis, Code, Links), damit der Plain-Text-Titel +/// nicht die rohen Marker zeigt. +function stripInlineMd(s: string): string { + return s + .replace(/`([^`]*)`/g, "$1") + .replace(/\*\*([^*]+)\*\*/g, "$1") + .replace(/\*([^*]+)\*/g, "$1") + .replace(/__([^_]+)__/g, "$1") + .replace(/_([^_]+)_/g, "$1") + .replace(/~~([^~]+)~~/g, "$1") + .replace(/\[([^\]]+)\]\([^)]*\)/g, "$1") + .trim(); +} + +/// Titel für den Panel-Kopf: erste Überschrift (# …) oder sonst erste +/// nicht-leere Zeile, ohne Inline-Markdown; „Dokument" bei leerem Text. +function firstLine(text: string): string { + let fallback = ""; + for (const line of text.split("\n")) { + const t = line.trim(); + if (!t) continue; + const h = t.replace(/^#+/, "").trim(); + if (t.startsWith("#") && h) return stripInlineMd(h); + if (!fallback) fallback = t; + } + return fallback ? stripInlineMd(fallback) : t("panel.tabDraft"); +} + +/// Ersetzt die erste Überschrift (bzw. legt eine an) im Rohtext durch `title`. +function setHeading(text: string, title: string): string { + const lines = text.split("\n"); + for (let i = 0; i < lines.length; i++) { + const t = lines[i].trim(); + if (!t) continue; + if (t.startsWith("#")) { + const hashes = (t.match(/^#+/) as RegExpMatchArray)[0]; + lines[i] = `${hashes} ${title}`; + } else { + lines.splice(i, 0, `# ${title}`, ""); + } + return lines.join("\n"); + } + return `# ${title}\n`; +} + +/// Macht `[[name]]`-Wikilinks im gerendertem Markdown klickbar. Läuft über die +/// Textknoten des DOM statt über den Rohtext, damit Vorkommen in Code-Spans +/// und Code-Blöcken (z. B. bash `[[ -f x ]]`) unangetastet bleiben. +export function linkWikiRefs(root: HTMLElement, onClick: (name: string) => void) { + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); + const nodes: Text[] = []; + for (let n = walker.nextNode(); n; n = walker.nextNode()) { + const t = n as Text; + if (!t.textContent?.includes("[[")) continue; + if (t.parentElement?.closest("code, pre, a")) continue; + nodes.push(t); + } + for (const node of nodes) { + const parts = node.textContent!.split(/\[\[([^\]]+)\]\]/g); + if (parts.length < 3) continue; + const frag = document.createDocumentFragment(); + parts.forEach((part, i) => { + if (i % 2) { + // `[[ziel]]` oder `[[ziel|label]]`. + const sep = part.indexOf("|"); + const target = sep < 0 ? part : part.slice(0, sep); + const label = sep < 0 ? part : part.slice(sep + 1); + const a = document.createElement("a"); + a.href = "#"; + a.className = "wiki"; + a.textContent = label; + a.addEventListener("click", (e) => { + e.preventDefault(); + onClick(target); + }); + frag.append(a); + } else if (part) { + frag.append(part); + } + }); + node.replaceWith(frag); + } +} + +export function initPanelView(opts: { + content: HTMLElement; + copyBtn: HTMLElement; + modeBtn: HTMLElement; + titleEl?: HTMLElement; + editBtn?: HTMLElement; + editContentBtn?: HTMLElement; + langSelect?: HTMLSelectElement; + /// Standard-Sprache der Rechtschreibprüfung (aus den App-Settings). + defaultLang?: string; + /// Wird mit dem neuen Rohtext aufgerufen, wenn Titel oder Inhalt geändert + /// wurden. + onCommit?: (text: string) => void | Promise; + /// Klick auf einen `[[name]]`-Wikilink im gerenderten Markdown. + onWikiLink?: (name: string) => void; +}): PanelView { + let rawText = ""; + let rendered = true; + // Content-Edit-Zustand: während des Editierens werden eingehende Updates + // gepuffert statt angewandt. + let editing = false; + let pending: string | null = null; + let flushEditor: () => Promise = async () => {}; + + function draw() { + if (rendered) { + opts.content.className = "md"; + opts.content.innerHTML = renderMarkdown(rawText); + if (opts.onWikiLink) linkWikiRefs(opts.content, opts.onWikiLink); + } else { + opts.content.className = "raw"; + opts.content.textContent = rawText; + } + opts.modeBtn.textContent = rendered ? t("panel.rendered") : t("panel.raw"); + } + + opts.modeBtn.addEventListener("click", () => { + rendered = !rendered; + draw(); + }); + + opts.copyBtn.addEventListener("click", async () => { + await writeText(rawText); + flash(opts.copyBtn, "copied"); + }); + + // Titel-Edit: Edit-Button macht den Titel editierbar, Enter/Blur schreibt die + // geänderte Überschrift zurück, Escape verwirft. + const titleEl = opts.titleEl; + if (opts.editBtn && titleEl && opts.onCommit) { + const commit = () => { + if (titleEl.getAttribute("contenteditable") !== "true") return; + titleEl.removeAttribute("contenteditable"); + const nt = (titleEl.textContent || "").trim(); + if (nt && nt !== firstLine(rawText)) opts.onCommit!(setHeading(rawText, nt)); + else titleEl.textContent = firstLine(rawText); + }; + opts.editBtn.addEventListener("click", () => { + titleEl.setAttribute("contenteditable", "true"); + titleEl.focus(); + const r = document.createRange(); + r.selectNodeContents(titleEl); + const sel = window.getSelection(); + sel?.removeAllRanges(); + sel?.addRange(r); + }); + titleEl.addEventListener("keydown", (e) => { + if (e.key === "Enter") { + e.preventDefault(); + titleEl.blur(); + } else if (e.key === "Escape") { + titleEl.textContent = firstLine(rawText); + titleEl.removeAttribute("contenteditable"); + titleEl.blur(); + } + }); + titleEl.addEventListener("blur", commit); + } + + // Content-Edit: Button schaltet den Inhalt auf eine Rohtext-Textarea. Speichern + // (Button erneut oder Cmd/Ctrl+Enter) schreibt zurück, Escape verwirft. + // Eingehende Updates während des Editierens werden gepuffert. + if (opts.editContentBtn && opts.onCommit) { + const editBtn = opts.editContentBtn; + const editor = document.createElement("textarea"); + editor.className = "panel-editor"; + editor.spellcheck = true; + editor.hidden = true; + opts.content.after(editor); + + // Sprache der Rechtschreibprüfung: Default aus den Settings, per Selector + // pro Text überschreibbar. + let lang = opts.defaultLang || "de"; + const sel = opts.langSelect; + if (sel) { + if ([...sel.options].some((o) => o.value === lang)) sel.value = lang; + else lang = sel.value; + sel.addEventListener("change", () => { + lang = sel.value; + editor.lang = lang; + // Neuprüfung erzwingen. + editor.spellcheck = false; + editor.spellcheck = true; + if (!editor.hidden) editor.focus(); + }); + } + editor.lang = lang; + + const leave = () => { + editing = false; + editor.hidden = true; + opts.content.hidden = false; + editBtn.classList.remove("active", "changed"); + }; + const enter = () => { + editing = true; + pending = null; + editor.value = rawText; + opts.content.hidden = true; + editor.hidden = false; + editBtn.classList.add("active"); + editor.focus(); + }; + flushEditor = async () => { + if (!editing) return; + const v = editor.value; + leave(); + await opts.onCommit!(v); // schreibt Datei -> panel-update -> set() rendert + }; + const save = () => void flushEditor(); + const cancel = () => { + const p = pending; + leave(); + if (p !== null) applyText(p); + else draw(); + }; + editBtn.addEventListener("click", () => (editing ? save() : enter())); + editor.addEventListener("keydown", (e) => { + if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + save(); + } else if (e.key === "Escape") { + e.preventDefault(); + cancel(); + } + }); + } + + function applyText(text: string) { + rawText = text; + if (titleEl && titleEl.getAttribute("contenteditable") !== "true") { + titleEl.textContent = firstLine(text); + } + draw(); + } + + draw(); + return { + set(text: string) { + // Während einer Inhalts-Bearbeitung nicht überschreiben — puffern und den + // Edit-Button als „geändert" markieren. + if (editing) { + pending = text; + opts.editContentBtn?.classList.add("changed"); + return; + } + applyText(text); + }, + raw: () => rawText, + flush: () => flushEditor(), + }; +} diff --git a/src/panel-window.css b/src/panel-window.css index 69c5f92..7bbb0a6 100644 --- a/src/panel-window.css +++ b/src/panel-window.css @@ -179,12 +179,20 @@ body { z-index: 101; } .tab-sep { - width: 1px; - height: 16px; - background: var(--line-strong); - margin: 0 6px; + width: 2px; + height: 22px; + background: var(--accent); + opacity: 0.7; + border-radius: 1px; + margin: 0 12px; align-self: center; } +.panel-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 2px; +} .panel-title { flex: 0 1 auto; min-width: 0; @@ -193,6 +201,13 @@ body { white-space: nowrap; color: var(--muted); } +.panel-title[contenteditable="true"] { + overflow: visible; + text-overflow: clip; + outline: none; + color: inherit; + cursor: text; +} .panel-btn { border: none; background: transparent; @@ -236,10 +251,118 @@ body { .panel-btn.changed { color: var(--warn); } +.panel-editor { + flex: 1; + min-height: 0; + resize: none; + border: none; + outline: none; + padding: 16px 20px; + background: transparent; + color: inherit; + font-family: "JetBrains Mono", Menlo, monospace; + font-size: 12px; + line-height: 1.55; +} +.panel-editor[hidden] { + display: none; +} .panel-tabs { display: flex; gap: 2px; } -.panel-btn[hidden] { +.panel-lang { + appearance: none; + -webkit-appearance: none; + margin-right: auto; + background: transparent; + color: var(--muted); + border: none; + border-radius: 6px; + padding: 6px 9px; + font: inherit; + cursor: default; +} +.panel-lang:hover { + background: var(--line); + color: var(--text); +} +#panel-content { + flex: 1; + min-height: 0; + overflow: auto; + padding: 16px 20px; + user-select: text; + -webkit-user-select: text; + cursor: text; + font-size: 13px; + line-height: 1.55; +} +#panel-content::-webkit-scrollbar { + width: 8px; +} +#panel-content::-webkit-scrollbar-thumb { + background: var(--line); + border-radius: 4px; +} +#panel-content.raw { + white-space: pre-wrap; + word-break: break-word; + font-family: "JetBrains Mono", Menlo, monospace; + font-size: 12px; +} +#panel-content.md h1, +#panel-content.md h2, +#panel-content.md h3 { + line-height: 1.25; + margin: 1em 0 0.4em; +} +#panel-content.md h1 { + font-size: 1.4em; +} +#panel-content.md h2 { + font-size: 1.2em; +} +#panel-content.md h3 { + font-size: 1.05em; +} +#panel-content.md :first-child { + margin-top: 0; +} +#panel-content.md p, +#panel-content.md ul, +#panel-content.md ol, +#panel-content.md pre, +#panel-content.md blockquote { + margin: 0 0 0.7em; +} +#panel-content.md code { + font-family: "JetBrains Mono", Menlo, monospace; + font-size: 0.9em; + background: var(--line); + border-radius: 4px; + padding: 1px 5px; +} +#panel-content.md pre { + background: var(--tile); + border-radius: 8px; + padding: 12px 14px; + overflow: auto; +} +#panel-content.md pre code { + background: none; + padding: 0; +} +#panel-content.md blockquote { + border-left: 3px solid var(--line); + padding-left: 12px; + color: var(--muted); +} +#panel-content.md a { + color: var(--accent); +} +.panel-btn[hidden], +.panel-lang[hidden], +#panel-content[hidden] { display: none; } diff --git a/src/panel-wiring.ts b/src/panel-wiring.ts index c3c1879..a8b29de 100644 --- a/src/panel-wiring.ts +++ b/src/panel-wiring.ts @@ -1,11 +1,14 @@ /// Gemeinsame Panel-Verdrahtung für das angedockte Panel (terminal.ts) und -/// das abgelöste Fenster (panel.ts): alle Tabs kommen 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. +/// 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 { initPanelMode, type ModeTab, type PanelMode } from "./commands-view"; import { panelToast } from "./tiles"; import { PANEL_TABS, type ModuleCtx, type ModuleView } from "./modules"; @@ -13,28 +16,82 @@ import { t } from "./messages"; import "./panel-tiles.css"; export interface PanelWiring { + view: PanelView; /// 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; } export async function wirePanel( project: string, onIncoming?: () => void, + standalone = false, ): Promise { const titleEl = document.querySelector(".panel-title") as HTMLElement; - const enabled = await invoke("enabled_modules", { project }); - const tabs = PANEL_TABS.filter((tab) => enabled.includes(tab.module)); + const [enabled, defaultLang, draft] = await Promise.all([ + invoke("enabled_modules", { project }), + invoke("spellcheck_lang"), + invoke("buffer_read", { project, buffer: "panel" }), + ]); + // Feste Aufgabenteilung: Session-Tabs (ToDo, Befehle) leben im angedockten + // Panel, Archiv-Tabs (popupOnly: Archiv, Suche) im eigenen Panel-Fenster. + // Im Dock erscheinen die Archiv-Tabs nur als Öffner-Knöpfe (terminal.ts + // fängt den Klick und öffnet das Fenster). + const tabs = PANEL_TABS.filter((tab) => enabled.includes(tab.module)).filter( + (tab) => (standalone ? !!tab.popupOnly : true), + ); - const ctx: ModuleCtx = { project, toast: panelToast }; + // 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 + // die Archiv-Werkzeuge im Dokument-Modus wieder ein. + el.classList.remove("draft-only"); + } + } + + // Jeder Wiki-Sprung (Wikilink, Chip, Dokument-Sprung) geht als ein Invoke an + // 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) => + void invoke("wiki_open", { project, name }).catch((e) => panelToast(String(e))); + + const ctx: ModuleCtx = { + project, + standalone, + toast: panelToast, + openDoc: (path) => + void invoke("panel_load", { project, path }).catch((e) => panelToast(String(e))), + openWiki, + }; + + const view = initPanelView({ + content: document.getElementById("panel-content")!, + copyBtn: document.getElementById("panel-copy")!, + modeBtn: document.getElementById("panel-mode")!, + titleEl, + editBtn: document.getElementById("panel-title-edit")!, + editContentBtn: document.getElementById("panel-content-edit")!, + langSelect: document.getElementById("panel-lang") as HTMLSelectElement, + defaultLang, + onCommit: (text) => invoke("panel_set", { project, text }), + onWikiLink: openWiki, + }); // Tab-Leiste und Container aus der Registry. Die Container-IDs // (`-content`) sind zugleich die CSS-Anker der Ansichten; die - // Container reihen sich hinter der Kopfzeile (.panel-head) ein. + // Container reihen sich hinter #panel-content ein. const tabsEl = document.getElementById("panel-tabs")!; const views = new Map(); const modeTabs: ModeTab[] = []; - let anchor = document.querySelector(".panel-head") as HTMLElement; + /// Tabs mit eigener Ansicht in DIESER Fläche (Öffner-Knöpfe zählen nicht). + const activeTabs: typeof tabs = []; + let anchor = document.getElementById("panel-content")!; for (const tab of tabs) { const btn = document.createElement("button"); btn.className = "panel-btn"; @@ -47,12 +104,20 @@ export async function wirePanel( sep.className = "tab-sep"; tabsEl.append(sep); } - const 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)); + // Archiv-Tabs im Dock: nur der Öffner-Knopf, keine Ansicht. + if (tab.popupOnly && !standalone) { + continue; + } + activeTabs.push(tab); + 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, @@ -63,34 +128,43 @@ export async function wirePanel( : undefined, }); } - // Archiv-Zugang: fester Knopf hinter den Tabs — öffnet das Trilium-Fenster - // des Projekts (kein Modus, kein Content-Container). - if (enabled.includes("trilium")) { - const btn = document.createElement("button"); - btn.className = "panel-btn"; - btn.textContent = t("panel.archiveOpen"); - btn.title = t("panel.archiveOpenTitle"); - btn.addEventListener("click", () => - void invoke("trilium_open", { project, noteId: null }).catch((e) => - panelToast(String(e)), - ), - ); - tabsEl.append(btn); - } + const mode = initPanelMode({ + tabs: modeTabs, + draftEls: [ + document.getElementById("panel-content")!, + ...document.querySelectorAll(".draft-only"), + ], + titleEl, + flush: () => void view.flush(), + }); - const mode = initPanelMode({ tabs: modeTabs, titleEl }); + // Sprung Dokument → Wiki: öffnet den Archiv-Navigator (Dokumentseiten im + // Wiki gibt es nicht mehr — Dokumente öffnen immer im Dokument-Tab). + document + .getElementById("panel-wiki-jump")! + .addEventListener("click", () => openWiki("tag:")); + 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. + mode.to("draft"); + view.set(e.payload); + onIncoming?.(); + }); await Promise.all( - tabs.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?.(); - }); - }), + activeTabs + .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 { views, mode }; + return { view, views, mode, draft }; } diff --git a/src/panel.ts b/src/panel.ts index ba2b105..1c565eb 100644 --- a/src/panel.ts +++ b/src/panel.ts @@ -4,13 +4,15 @@ import "@fontsource/jetbrains-mono/500.css"; import { invoke } from "@tauri-apps/api/core"; 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 "./tiles"; +import { initArchiveForm } from "./archive-form"; import { applyTheme, THEMES } from "./themes"; -import { applyI18n } from "./messages"; +import { applyI18n, t } from "./messages"; -// Abgelöstes Panel-Fenster: dieselben Modul-Tabs wie das angedockte Panel, -// gespeist aus denselben Puffer-Update-Events. +// Abgelöstes Panel-Fenster: liest den aktuellen Entwurf einmal ein und folgt +// danach denselben Update-Events wie das angedockte Panel; startet in +// „Befehle", wenn es (noch) keinen Entwurf gibt. applyI18n(); const project = new URLSearchParams(location.search).get("project")!; const win = getCurrentWebviewWindow(); @@ -96,25 +98,55 @@ applyTheme(picked); document.documentElement.style.background = picked.header; document.body.style.background = picked.header; -const { views, mode } = await wirePanel(project); -// Beim Ablösen kommt der dort aktive Tab als URL-Parameter mit; ohne ihn -// (Kaltstart): ToDo-Liste, falls gewählt und nicht leer, sonst der erste Tab. +const { view, mode, draft } = await wirePanel(project, undefined, true); +// Das Panel-Fenster ist die Archiv-Fläche: gewünschter Tab aus der URL, +// sonst das Archiv; ein hereingereichter Entwurf (Bearbeiten) gewinnt. const initialMode = new URLSearchParams(location.search).get("mode"); -const todos = views.get("todo"); -const first = views.keys().next().value; if (initialMode) { mode.to(initialMode); -} else if (todos && !todos.empty()) { - mode.to("todo"); -} else if (first) { - mode.to(first); +} else if (!draft.trim()) { + mode.to("wiki"); } -// „Andocken": angedocktes Panel wieder einblenden, dann dieses Fenster schließen. -document.getElementById("panel-dock")!.addEventListener("click", async () => { - await emit("panel-attached"); - win.close(); +// Archivieren: wie im angedockten Panel — Formular aufklappen, Abschicken +// wählt notfalls erst das Archiv-Home per Dialog. +const archiveBtn = document.getElementById("panel-archive")!; +const archiveForm = initArchiveForm(archiveBtn, async (meta) => { + try { + const configured = await invoke("panel_archive_dir_cmd", { + project, + }); + let dir: string | undefined; + if (!configured) { + const { open } = await import("@tauri-apps/plugin-dialog"); + const chosen = await open({ directory: true, title: t("panel.chooseArchiveDir") }); + if (!chosen) return; + dir = chosen as string; + } + await view.flush(); // offene Bearbeitung erst speichern — archiviert, was zu sehen ist + const path = await invoke("panel_archive_cmd", { + project, + dir, + folder: meta.folder ?? null, + description: meta.description ?? null, + tags: meta.tags, + }); + flash(archiveBtn, "copied", 1400); + invoke("reveal_path_cmd", { path }); + } catch (e) { + flash(archiveBtn, "error", 1400); + panelToast(`Archivieren fehlgeschlagen: ${e}`); + } }); +archiveBtn.addEventListener("click", () => archiveForm.toggle()); + +// Öffner-Klick im Terminal-Header bei stehendem Fenster: auf den +// gewünschten Tab schalten (das Fokussieren macht der Kern). +await win.listen("panel-mode", (e) => mode.to(e.payload)); + +// Andocken gibt es nicht mehr — die Flächen sind fest verteilt (Session-Tabs +// im Dock, Archiv hier); der Knopf bleibt versteckt. +document.getElementById("panel-dock")!.hidden = true; // „Schließen": Fenster zu, ohne wieder anzudocken (Panel bleibt aus, bis ein // neuer Entwurf kommt). diff --git a/src/search-view.test.ts b/src/search-view.test.ts index a5f537a..bf8dff6 100644 --- a/src/search-view.test.ts +++ b/src/search-view.test.ts @@ -20,7 +20,8 @@ function setup() { const run = JSON.stringify({ query: "arch", tag: null, - hits: [{ note_id: "abc123def", title: "X" }], + home: "/tmp/archiv", + hits: [{ relpath: "a/2026-01-01_0000-x.md", title: "X", snippet: "ein **arch**iv" }], }); describe("initSearchView", () => { @@ -73,18 +74,22 @@ describe("initSearchView", () => { expect(onSearch).toHaveBeenCalledWith("panel arch"); }); - it("rendert Treffer; Klick liefert die noteId", () => { + it("rendert Treffer; Klick liefert den absoluten Pfad", () => { const { view, onOpen } = setup(); view.set(run); const tile = document.querySelector(".hit-tile")!; expect(tile.querySelector(".hit-title")!.textContent).toBe("X"); + expect(tile.querySelectorAll("mark").length).toBe(1); tile.click(); - expect(onOpen).toHaveBeenCalledWith("abc123def"); + expect(onOpen).toHaveBeenCalledWith( + "/tmp/archiv/a/2026-01-01_0000-x.md", + "a/2026-01-01_0000-x.md", + ); }); it("zeigt „Keine Treffer“ bei leerem Ergebnis", () => { const { view } = setup(); - view.set(JSON.stringify({ query: "nix", tag: "adr", hits: [] })); + view.set(JSON.stringify({ query: "nix", tag: "adr", home: "/a", hits: [] })); expect(document.querySelector(".hit-head")!.textContent).toBe( "Keine Treffer für „nix“ · #adr", ); diff --git a/src/search-view.ts b/src/search-view.ts index 5d76b78..b33255c 100644 --- a/src/search-view.ts +++ b/src/search-view.ts @@ -1,20 +1,22 @@ /// Kachel-Ansicht der Archiv-Suchtreffer: rendert die Suchtreffer-Datei -/// (search_archive im MCP-Server bzw. search_run aus dem Suchfeld) als -/// klickbare Kacheln — Klick reicht die noteId des Treffers an onOpen -/// (öffnet die Notiz im Trilium-Fenster). Titel sind Fremdtext und gehen -/// nie durch innerHTML. +/// (search_archive im MCP-Server) als klickbare Kacheln — Klick reicht den +/// absoluten Pfad und den relpath des Treffers an onOpen. Titel/Snippet/Pfad sind Fremdtext und +/// gehen nie durch innerHTML; die `**…**`-Marker im Snippet werden per +/// Split in -Elemente übersetzt. import { t } from "./messages"; import { renderTile } from "./tiles"; interface Hit { - note_id: string; + relpath: string; title: string; + snippet: string; } interface SearchRun { query: string; tag?: string | null; + home: string; hits: Hit[]; } @@ -25,7 +27,7 @@ export interface SearchView { export function initSearchView( container: HTMLElement, - onOpen: (noteId: string) => void, + onOpen: (path: string, relpath: string) => void, onSearch: (query: string) => void, ): SearchView { let count = 0; @@ -39,7 +41,7 @@ export function initSearchView( // Live-Suche ab 3 Zeichen, entprellt (300 ms Debounce); endet die Eingabe // mitten im Wort, wird das letzte Wort als Präfix gesucht (arch → arch*) — // außer es ist ein #tag-Token. Enter sucht sofort und wörtlich — für exakte - // Trilium-Suchsyntax (Phrasen, Operatoren). + // FTS-Syntax (Phrasen, OR/NOT). let pending: number | undefined; input.addEventListener("input", () => { clearTimeout(pending); @@ -71,6 +73,21 @@ export function initSearchView( const results = document.createElement("div"); container.append(bar, results); + function snippetEl(snippet: string): HTMLElement { + const div = document.createElement("div"); + div.className = "hit-snippet"; + snippet.split("**").forEach((part, i) => { + if (i % 2) { + const m = document.createElement("mark"); + m.textContent = part; + div.append(m); + } else if (part) { + div.append(document.createTextNode(part)); + } + }); + return div; + } + function render(run: SearchRun) { results.textContent = ""; const head = document.createElement("div"); @@ -88,9 +105,10 @@ export function initSearchView( cls: "hit-tile", parts: [ { cls: "hit-title", text: hit.title }, - { cls: "hit-path", text: hit.note_id }, + snippetEl(hit.snippet), + { cls: "hit-path", text: hit.relpath }, ], - onClick: () => onOpen(hit.note_id), + onClick: () => onOpen(`${run.home}/${hit.relpath}`, hit.relpath), }), ); } diff --git a/src/terminal-window.css b/src/terminal-window.css index 502c5ff..ddef632 100644 --- a/src/terminal-window.css +++ b/src/terminal-window.css @@ -76,10 +76,12 @@ header .panel-tabs[hidden] + #pool { margin-left: auto; } .tab-sep { - width: 1px; - height: 16px; - background: var(--line-strong); - margin: 0 6px; + width: 2px; + height: 22px; + background: var(--accent); + opacity: 0.7; + border-radius: 1px; + margin: 0 12px; align-self: center; } #pool { @@ -265,6 +267,12 @@ header .panel-tabs[hidden] + #pool { #panel-detach { margin-left: auto; } +.panel-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 2px; +} .panel-title { flex: 0 1 auto; min-width: 0; @@ -273,6 +281,13 @@ header .panel-tabs[hidden] + #pool { white-space: nowrap; color: var(--muted); } +.panel-title[contenteditable="true"] { + overflow: visible; + text-overflow: clip; + outline: none; + color: inherit; + cursor: text; +} .panel-btn { border: none; background: transparent; @@ -316,10 +331,118 @@ header .panel-tabs[hidden] + #pool { .panel-btn.changed { color: var(--warn); } +.panel-editor { + flex: 1; + min-height: 0; + resize: none; + border: none; + outline: none; + padding: 14px 16px; + background: transparent; + color: inherit; + font-family: "JetBrains Mono", Menlo, monospace; + font-size: 12px; + line-height: 1.55; +} +.panel-editor[hidden] { + display: none; +} .panel-tabs { display: flex; gap: 2px; } -.panel-btn[hidden] { +.panel-lang { + appearance: none; + -webkit-appearance: none; + margin-right: auto; + background: transparent; + color: var(--muted); + border: none; + border-radius: 6px; + padding: 6px 9px; + font: inherit; + cursor: default; +} +.panel-lang:hover { + background: var(--line); + color: var(--text); +} +#panel-content { + flex: 1; + min-height: 0; + overflow: auto; + padding: 14px 16px; + user-select: text; + -webkit-user-select: text; + cursor: text; + font-size: 13px; + line-height: 1.55; +} +#panel-content::-webkit-scrollbar { + width: 8px; +} +#panel-content::-webkit-scrollbar-thumb { + background: var(--line); + border-radius: 4px; +} +#panel-content.raw { + white-space: pre-wrap; + word-break: break-word; + font-family: "JetBrains Mono", Menlo, monospace; + font-size: 12px; +} +#panel-content.md h1, +#panel-content.md h2, +#panel-content.md h3 { + line-height: 1.25; + margin: 1em 0 0.4em; +} +#panel-content.md h1 { + font-size: 1.4em; +} +#panel-content.md h2 { + font-size: 1.2em; +} +#panel-content.md h3 { + font-size: 1.05em; +} +#panel-content.md :first-child { + margin-top: 0; +} +#panel-content.md p, +#panel-content.md ul, +#panel-content.md ol, +#panel-content.md pre, +#panel-content.md blockquote { + margin: 0 0 0.7em; +} +#panel-content.md code { + font-family: "JetBrains Mono", Menlo, monospace; + font-size: 0.9em; + background: var(--line); + border-radius: 4px; + padding: 1px 5px; +} +#panel-content.md pre { + background: var(--tile); + border-radius: 8px; + padding: 12px 14px; + overflow: auto; +} +#panel-content.md pre code { + background: none; + padding: 0; +} +#panel-content.md blockquote { + border-left: 3px solid var(--line); + padding-left: 12px; + color: var(--muted); +} +#panel-content.md a { + color: var(--accent); +} +.panel-btn[hidden], +.panel-lang[hidden], +#panel-content[hidden] { display: none; } diff --git a/src/terminal.ts b/src/terminal.ts index d537a87..5cde479 100644 --- a/src/terminal.ts +++ b/src/terminal.ts @@ -15,7 +15,8 @@ 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 { panelToast } from "./tiles"; +import { flash, panelToast } from "./tiles"; +import { initArchiveForm } from "./archive-form"; import { applyI18n, t } from "./messages"; // Debug-Instrumentierung: Fehler auf der Seite anzeigen und ins Dev-Log spiegeln. @@ -188,10 +189,10 @@ window.addEventListener("resize", () => fit.fit()); await invoke("term_start", { project, rows: term.rows, cols: term.cols }); term.focus(); -// --- Andockbares Panel ---------------------------------------------------- -// Die MCP-Tools schreiben in die Puffer-Dateien; terminal.rs meldet neuen -// Inhalt als `-update`. Das Panel blendet sich dann ein — außer es -// ist gerade in ein eigenes Fenster abgelöst (`panel-detached`). +// --- Andockbares Entwurfs-Panel ------------------------------------------- +// Der Skill schreibt Entwürfe in eine Datei; terminal.rs meldet neuen Inhalt +// per `panel-update`. Das Panel blendet sich dann ein — außer es ist gerade in +// ein eigenes Fenster abgelöst (`panel-detached`). const panel = document.getElementById("panel")!; const splitter = document.getElementById("splitter")!; @@ -211,20 +212,10 @@ function hidePanel() { // Gemeinsame Verdrahtung (Views, Modi, Update-Events); jedes eingehende // Update blendet das angedockte Panel ein, solange es nicht abgelöst ist. -const { views, mode } = await wirePanel(project, () => { +const { view, views, mode } = await wirePanel(project, () => { hasContent = true; if (!detached) showPanel(); }); - -// write_panel hat eine Notiz angelegt: das Trilium-Fenster des Projekts -// zeigt sie. Leerer Puffer = Session-Start, keine Notiz. -await listen("noteopen-update", (e) => { - if (!e.payload.trim()) return; - const { noteId } = JSON.parse(e.payload); - void invoke("trilium_open", { project, noteId }).catch((err) => - panelToast(String(err)), - ); -}); // 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"); @@ -238,6 +229,22 @@ if (todos && !todos.empty()) { // Tab-Klick im Header öffnet das (angedockte) Panel, falls es zu ist. const headerTabs = document.getElementById("panel-tabs")!; +// Ausnahme Archiv & Suche: öffnen standardmäßig im eigenen Panel-Fenster +// (Popup) statt in der schmalen angedockten Spalte — Capture-Phase, damit +// weder der Modus-Wechsel des Tabs noch das Einblenden des Panels feuern. +headerTabs.addEventListener( + "click", + (e) => { + const btn = (e.target as HTMLElement).closest( + 'button[data-mode="wiki"], button[data-mode="search"]', + ); + if (!btn) return; + e.stopPropagation(); + e.preventDefault(); + void invoke("open_panel_window", { project, mode: btn.dataset.mode }); + }, + { capture: true }, +); headerTabs.addEventListener("click", () => { hasContent = true; if (!detached) showPanel(); @@ -263,17 +270,47 @@ await listen("panel-window-closed", () => { headerTabs.hidden = false; }); -// Der aktive Tab wandert mit ins abgelöste Fenster. -document - .getElementById("panel-detach")! - .addEventListener("click", () => - invoke("open_panel_window", { project, mode: mode.current() }), - ); +// Ablösen gibt es nicht mehr — die Flächen sind fest verteilt; der Knopf +// bleibt versteckt. +document.getElementById("panel-detach")!.hidden = true; document.getElementById("panel-hide")!.addEventListener("click", () => { hidePanel(); mode.clear(); }); +// Archivieren: Button klappt das Formular (Ordner/Beschreibung/Schlagwörter) +// auf; Abschicken wählt notfalls erst das Archiv-Home per Dialog. +const archiveBtn = document.getElementById("panel-archive")!; +const archiveForm = initArchiveForm(archiveBtn, async (meta) => { + try { + const configured = await invoke("panel_archive_dir_cmd", { + project, + }); + let dir: string | undefined; + if (!configured) { + const { open } = await import("@tauri-apps/plugin-dialog"); + const chosen = await open({ directory: true, title: t("panel.chooseArchiveDir") }); + if (!chosen) return; + dir = chosen as string; + } + await view.flush(); // offene Bearbeitung erst speichern — archiviert, was zu sehen ist + const path = await invoke("panel_archive_cmd", { + project, + dir, + folder: meta.folder ?? null, + description: meta.description ?? null, + tags: meta.tags, + }); + flash(archiveBtn, "copied", 1400); + invoke("reveal_path_cmd", { path }); + } catch (e) { + flash(archiveBtn, "error", 1400); + panelToast(`Archivieren fehlgeschlagen: ${e}`); + invoke("term_log", { msg: `archive: ${e}` }); + } +}); +archiveBtn.addEventListener("click", () => archiveForm.toggle()); + // Splitter: Panelbreite ziehen. splitter.addEventListener("mousedown", (e) => { e.preventDefault(); diff --git a/src/wiki-view.test.ts b/src/wiki-view.test.ts new file mode 100644 index 0000000..e4a2e61 --- /dev/null +++ b/src/wiki-view.test.ts @@ -0,0 +1,372 @@ +// Archiv-Ansicht: Baum mit Notizen (Ordner + Dokumente, Verschmelzung +// gleichnamiger Paare), Notiz-Ansicht rechts (Titel, Inhalt, Kindliste), +// Aktionen (bearbeiten, umbenennen, löschen, anlegen), Suchtreffer-Vormerkung, +// Leerzustände. +import { describe, expect, it, vi } from "vitest"; +import { initWikiView } from "./wiki-view"; + +function setup(pending: string | null = null) { + document.body.innerHTML = `
`; + const readDoc = vi.fn(() => Promise.resolve("# Inhalt\n\nText mit [[neu]].")); + const writeDoc = vi.fn(() => Promise.resolve()); + const setTitle = vi.fn(); + const openWiki = vi.fn(); + const actions = { + rename: vi.fn(), + remove: vi.fn(), + moveFolder: vi.fn(), + createFolder: vi.fn(), + createDoc: vi.fn(), + }; + let pendingSelect = pending; + const view = initWikiView(document.getElementById("w")!, { + autoStart: true, + readDoc, + writeDoc, + setTitle, + openWiki, + takePending: () => { + const p = pendingSelect; + pendingSelect = null; + return p; + }, + actions, + }); + return { view, readDoc, writeDoc, setTitle, openWiki, actions }; +} + +const doc = (relpath: string, name: string, extra: object = {}) => ({ + relpath, + name, + title: name.toUpperCase(), + description: null, + tags: [], + date: "2026-07-19", + backlinks: 0, + modified: "2026-07-24", + ...extra, +}); + +const page = JSON.stringify({ + kind: "page", + home: "/tmp/archiv", + tag: null, + total: 3, + tags: [], + folders: [ + { + name: "", + docs: [ + doc("2026-07-19_1000-wurzel-doc.md", "wurzel-doc", { + backlinks: 2, + description: "Beschreibung", + }), + ], + }, + { name: "konzepte", docs: [doc("konzepte/2026-07-19_1005-neu.md", "neu")] }, + { + name: "konzepte/panel", + docs: [doc("konzepte/panel/2026-07-19_1010-alt.md", "alt")], + }, + ], +}); + +/// Ordner `konzepte` mit gleichnamigem Dokument daneben — eine Notiz mit +/// Inhalt und Kindern. +const mergedPage = JSON.stringify({ + kind: "page", + home: "/tmp/archiv", + tag: null, + total: 2, + tags: [], + folders: [ + { name: "", docs: [doc("konzepte.md", "konzepte", { title: "Konzepte" })] }, + { name: "konzepte", docs: [doc("konzepte/2026-07-19_1005-neu.md", "neu")] }, + ], +}); + +const flush = () => new Promise((r) => setTimeout(r)); + +describe("initWikiView — Baum", () => { + it("Baum zeigt Ordner und Dokumente, Wurzel ist gewählt", () => { + const { view } = setup(); + view.set(page); + expect(document.querySelector(".wiki-tree-root")!.className).toContain("active"); + const rows = [...document.querySelectorAll(".wiki-tree .wiki-tree-name")].map( + (e) => e.textContent, + ); + // Unter jedem Knoten erst Dokumente, dann Ordner (mit Titel), sortiert. + expect(rows).toEqual(["WURZEL-DOC", "konzepte", "NEU", "panel", "ALT"]); + // Wurzel-Ansicht rechts: Titel + Kindzähler. + expect(document.querySelector(".wiki-note-title")!.textContent).toBe("Archiv"); + expect(document.querySelector(".wiki-children-caption")!.textContent).toBe( + "2 Dokumente", + ); + }); + + it("gleichnamiges Dokument verschmilzt mit dem Ordner zur Notiz", async () => { + const { view, readDoc } = setup(); + view.set(mergedPage); + const rows = [...document.querySelectorAll(".wiki-tree .wiki-tree-name")].map( + (e) => e.textContent, + ); + // Kein eigenes Blatt für konzepte.md — der Ordnerknoten trägt den Titel. + expect(rows).toEqual(["Konzepte", "NEU"]); + // Auswahl des Knotens zeigt Inhalt UND Kindliste. + document.querySelector(".wiki-tree summary .wiki-tree-name")!.click(); + await flush(); + expect(readDoc).toHaveBeenCalledWith("konzepte.md"); + expect(document.querySelector(".wiki-note-body h1")!.textContent).toBe("Inhalt"); + expect(document.querySelector(".wiki-note-children .wiki-doc-title")!.textContent).toBe( + "NEU", + ); + }); +}); + +describe("initWikiView — Notiz-Ansicht", () => { + it("Dokument-Klick lädt den Body in die Notiz-Ansicht", async () => { + const { view, readDoc } = setup(); + view.set(page); + document.querySelector(".wiki-tree-doc")!.click(); // WURZEL-DOC + await flush(); + expect(readDoc).toHaveBeenCalledWith("2026-07-19_1000-wurzel-doc.md"); + expect(document.querySelector(".wiki-note-title")!.textContent).toBe("WURZEL-DOC"); + // Meta als Popup am Info-Knopf. + const pop = document.querySelector(".wiki-note-info-pop")!; + expect(pop.hidden).toBe(true); + document.querySelector('.wiki-note-actions [title^="Details"]')!.click(); + expect(pop.hidden).toBe(false); + expect(pop.textContent).toContain("↩ 2"); + expect(document.querySelector(".wiki-note-body h1")!.textContent).toBe("Inhalt"); + }); + + it("Wikilink im Inhalt wählt die Ziel-Notiz lokal aus", async () => { + const { view, openWiki } = setup(); + view.set(page); + document.querySelector(".wiki-tree-doc")!.click(); // WURZEL-DOC + await flush(); + document.querySelector(".wiki-note-body a.wiki")!.click(); + expect(openWiki).not.toHaveBeenCalled(); + expect(document.querySelector(".wiki-note-title")!.textContent).toBe("NEU"); + }); + + it("Stift schaltet in den Editor, Speichern schreibt zurück", async () => { + const { view, writeDoc } = setup(); + view.set(page); + document.querySelector(".wiki-tree-doc")!.click(); + await flush(); + document.querySelector(".wiki-note-actions .panel-btn")!.click(); + await flush(); + const area = document.querySelector(".wiki-note-editor")!; + expect(area.value).toContain("# Inhalt"); + // Vorschau rechts zeigt den gerenderten Rohtext. + expect(document.querySelector(".wiki-edit-preview h1")!.textContent).toBe("Inhalt"); + area.value = "# Neu\n\nGeändert."; + area.dispatchEvent(new Event("input")); + expect(document.querySelector(".wiki-edit-preview h1")!.textContent).toBe("Neu"); + document.querySelector(".wiki-form-submit")!.click(); + await flush(); + expect(writeDoc).toHaveBeenCalledWith( + "2026-07-19_1000-wurzel-doc.md", + "# Neu\n\nGeändert.", + ); + // Nach dem Speichern wieder die Anzeige. + expect(document.querySelector(".wiki-note-editor")).toBeNull(); + expect(document.querySelector(".wiki-note-body")).not.toBeNull(); + }); + + it("Abbrechen verwirft und zeigt wieder an", async () => { + const { view, writeDoc } = setup(); + view.set(page); + document.querySelector(".wiki-tree-doc")!.click(); + await flush(); + document.querySelector(".wiki-note-actions .panel-btn")!.click(); + await flush(); + document.querySelector(".wiki-form-cancel")!.click(); + expect(writeDoc).not.toHaveBeenCalled(); + expect(document.querySelector(".wiki-note-body")).not.toBeNull(); + }); + + it("Auswahl übersteht ein Puffer-Update, Verschwundenes fällt zur Wurzel", () => { + const { view } = setup(); + view.set(page); + document.querySelector(".wiki-tree summary .wiki-tree-name")!.click(); + view.set(page); // Update: Auswahl bleibt + expect(document.querySelector(".wiki-note-title")!.textContent).toBe("konzepte"); + view.set( + JSON.stringify({ + kind: "page", + home: "/tmp/archiv", + tag: null, + total: 1, + tags: [], + folders: [{ name: "", docs: [doc("2026-07-19_1000-wurzel-doc.md", "wurzel-doc")] }], + }), + ); + expect(document.querySelector(".wiki-note-title")!.textContent).toBe("Archiv"); + }); + + it("vorgemerkter Suchtreffer wählt die Notiz beim set() aus", async () => { + const { view, readDoc } = setup("konzepte/2026-07-19_1005-neu.md"); + view.set(page); + await flush(); + expect(document.querySelector(".wiki-note-title")!.textContent).toBe("NEU"); + expect(readDoc).toHaveBeenCalledWith("konzepte/2026-07-19_1005-neu.md"); + }); +}); + +describe("initWikiView — Zurück", () => { + it("Zurück-Knopf führt zur vorherigen Auswahl", async () => { + const { view } = setup(); + view.set(page); + document.querySelector(".wiki-tree-doc")!.click(); // WURZEL-DOC + await flush(); + document.querySelector(".wiki-tree summary .wiki-tree-name")!.click(); // konzepte + expect(document.querySelector(".wiki-note-title")!.textContent).toBe("konzepte"); + document.querySelector(".wiki-note-back")!.click(); + await flush(); + expect(document.querySelector(".wiki-note-title")!.textContent).toBe("WURZEL-DOC"); + document.querySelector(".wiki-note-back")!.click(); + expect(document.querySelector(".wiki-note-title")!.textContent).toBe("Archiv"); + // Verlauf leer: Knopf deaktiviert. + expect( + document.querySelector(".wiki-note-back")!.disabled, + ).toBe(true); + }); +}); + +describe("initWikiView — Aktionen", () => { + it("Klick auf den Titel bearbeitet ihn, Enter setzt den Frontmatter-Titel", async () => { + const { view, setTitle } = setup(); + view.set(page); + document.querySelector(".wiki-tree-doc")!.click(); + await flush(); + document.querySelector(".wiki-note-title.editable")!.click(); + const input = document.querySelector(".wiki-note-title-input")!; + expect(input.value).toBe("WURZEL-DOC"); + input.value = "Neuer Titel"; + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + expect(setTitle).toHaveBeenCalledWith("2026-07-19_1000-wurzel-doc.md", "Neuer Titel"); + }); + + it("Escape verwirft die Titel-Bearbeitung", async () => { + const { view, setTitle } = setup(); + view.set(page); + document.querySelector(".wiki-tree-doc")!.click(); + await flush(); + document.querySelector(".wiki-note-title.editable")!.click(); + const input = document.querySelector(".wiki-note-title-input")!; + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); + expect(document.querySelector(".wiki-note-title")!.textContent).toBe("WURZEL-DOC"); + expect(setTitle).not.toHaveBeenCalled(); + }); + + it("Löschen meldet mit relpath", async () => { + const { view, actions } = setup(); + view.set(page); + document.querySelector(".wiki-tree-doc")!.click(); + await flush(); + document.querySelector(".wiki-note-actions .cmd-del")!.click(); + expect(actions.remove).toHaveBeenCalledWith("2026-07-19_1000-wurzel-doc.md"); + }); + + it("Plus im Notiz-Kopf: Formular legt Dokument im gewählten Ordner an", () => { + const { view, actions } = setup(); + view.set(page); + document.querySelector(".wiki-tree summary .wiki-tree-name")!.click(); + document.querySelector(".wiki-note-actions .wiki-add")!.click(); + const form = document.querySelector(".wiki-form")!; + expect(form.querySelector(".wiki-form-title")!.textContent).toBe("Neues Dokument"); + const input = form.querySelector("input")!; + input.value = "Deploy Notiz"; + form.querySelector(".wiki-form-submit")!.click(); + expect(actions.createDoc).toHaveBeenCalledWith("konzepte", "Deploy Notiz"); + expect(document.querySelector(".wiki-form")).toBeNull(); + }); + + it("Abbrechen und Escape schließen das Formular ohne Aktion", () => { + const { view, actions } = setup(); + view.set(page); + document.querySelector(".wiki-note-actions .wiki-add")!.click(); + document.querySelector(".wiki-form-cancel")!.click(); + expect(document.querySelector(".wiki-form")).toBeNull(); + + document.querySelector(".wiki-note-actions .wiki-add")!.click(); + document + .querySelector(".wiki-form")! + .dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); + expect(document.querySelector(".wiki-form")).toBeNull(); + expect(actions.createDoc).not.toHaveBeenCalled(); + }); + + it("Rechtsklick auf Ordner: Menü legt Ordner darunter an", () => { + const { view, actions } = setup(); + view.set(page); + const summary = document.querySelector(".wiki-tree summary")!; + summary.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true })); + const items = [...document.querySelectorAll(".wiki-menu-item")]; + items.find((i) => i.textContent === "Neuer Ordner")!.click(); + const input = document.querySelector(".wiki-form input")!; + expect(input.value).toBe("konzepte/"); + input.value = "konzepte/neu"; + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + expect(actions.createFolder).toHaveBeenCalledWith("konzepte/neu"); + }); + + it("Rechtsklick auf Ordner: Dialog ändert den Ordnerpfad", () => { + const { view, actions } = setup(); + view.set(page); + const summary = document.querySelector(".wiki-tree summary")!; + summary.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true })); + const items = [...document.querySelectorAll(".wiki-menu-item")]; + items.find((i) => i.textContent === "Ordnerpfad ändern")!.click(); + const input = document.querySelector(".wiki-form input")!; + expect(input.value).toBe("konzepte"); + input.value = "notizen/2026"; + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + expect(actions.moveFolder).toHaveBeenCalledWith("konzepte", "notizen/2026"); + }); + + it("Rechtsklick auf Dokument: Menü löscht mit relpath", () => { + const { view, actions } = setup(); + view.set(page); + document + .querySelector(".wiki-tree-doc")! + .dispatchEvent(new MouseEvent("contextmenu", { bubbles: true })); + const items = [...document.querySelectorAll(".wiki-menu-item")]; + items.find((i) => i.textContent === "Dokument löschen")!.click(); + expect(actions.remove).toHaveBeenCalledWith("2026-07-19_1000-wurzel-doc.md"); + expect(document.querySelector(".wiki-menu")).toBeNull(); + }); +}); + +describe("initWikiView — Leerzustände", () => { + it("leeres Archiv erklärt das Archivieren", () => { + const { view } = setup(); + view.set( + JSON.stringify({ + kind: "page", + home: "/a", + tag: null, + total: 0, + tags: [], + folders: [], + }), + ); + expect(document.querySelector(".wiki-empty")!.textContent).toContain( + "Das Archiv ist leer.", + ); + }); + + it("leerer Puffer fordert die Übersicht von selbst an", () => { + const { view, openWiki } = setup(); + view.set(""); + expect(view.empty()).toBe(true); + expect(openWiki).toHaveBeenCalledWith("tag:"); + // Nur einmal — das Update kommt über den Puffer zurück. + view.set(""); + expect(openWiki).toHaveBeenCalledTimes(1); + view.set(page); + expect(view.empty()).toBe(false); + }); +}); diff --git a/src/wiki-view.ts b/src/wiki-view.ts new file mode 100644 index 0000000..6e27f2c --- /dev/null +++ b/src/wiki-view.ts @@ -0,0 +1,813 @@ +/// Archiv-Ansicht des Panels, am Trilium-Layout orientiert: links ein Baum, +/// in dem die Notizen selbst hängen (Ordner = Notiz mit Kindern — ein Ordner +/// mit gleichnamigem Dokument daneben ist EINE Notiz mit Inhalt und Kindern), +/// rechts die Notiz-Ansicht: großer Titel mit Aktionen (bearbeiten, +/// umbenennen, löschen), darunter der gerenderte Inhalt bzw. für Notizen ohne +/// Inhalt die Kindliste. Titel, Beschreibungen und Pfade sind Fremdtext und +/// gehen nie durch innerHTML; der Markdown-Body läuft durch renderMarkdown. + +import { renderMarkdown } from "./markdown"; +import { t } from "./messages"; +import { linkWikiRefs } from "./panel-view"; +import { deleteAction } from "./tiles"; + +interface DocEntry { + relpath: string; + name: string; + title: string; + description?: string | null; + tags: string[]; + date?: string | null; + backlinks: number; + /// Letzte Änderung (Datei-mtime, YYYY-MM-DD). + modified: string; +} + +interface Folder { + name: string; + docs: DocEntry[]; +} + +interface Page { + kind: "page"; + home: string; + tag?: string | null; + total: number; + tags: { name: string; count: number }[]; + folders: Folder[]; +} + +/// Dokument-/Ordner-Operationen — laufen als Tauri-Commands, der neue Stand +/// kommt über den Wiki-Puffer zurück. +export interface WikiActions { + rename(relpath: string, name: string): void; + remove(relpath: string): void; + moveFolder(folder: string, to: string): void; + createFolder(folder: string): void; + createDoc(folder: string, name: string): void; +} + +export interface WikiCallbacks { + /// Leeren Puffer sofort mit der Übersicht füllen (eigenes Fenster). + autoStart?: boolean; + /// Body eines Archiv-Dokuments (ohne Frontmatter) für die Notiz-Ansicht. + readDoc(relpath: string): Promise; + /// Body einer Archiv-Notiz zurückschreiben (Bearbeiten im Archiv). + writeDoc(relpath: string, text: string): Promise; + /// Anzeige-Titel einer Notiz setzen (Klick auf den Titel). + setTitle(relpath: string, title: string): void; + /// Wiki-Ziel laden (`tag:` = Übersicht in den Puffer, Einstiegs-Chip). + openWiki(name: string): void; + /// Vorgemerkte Auswahl (Suchtreffer-Sprung): einmalig abholen. + takePending?(): string | null; + actions: WikiActions; +} + +export interface WikiView { + set(text: string): void; + /// Noch keine Seite im Puffer (Session-Start)? + empty(): boolean; +} + +/// Baum-Knoten: Unterordner als Kinder, Dokumente als Blätter; `content` ist +/// das mit dem Ordner verschmolzene gleichnamige Dokument (Notiz-Inhalt). +interface TreeNode { + children: Map; + docs: DocEntry[]; + content?: DocEntry; +} + +/// JS-Seite der slugify-Semantik aus archive.rs — für die lokale Auflösung +/// von Wikilinks gegen Name/Titel/Stem. +function slugify(s: string): string { + let out = ""; + for (const c of s) { + if (c === "ä" || c === "Ä") out += "ae"; + else if (c === "ö" || c === "Ö") out += "oe"; + else if (c === "ü" || c === "Ü") out += "ue"; + else if (c === "ß") out += "ss"; + else if (/[a-zA-Z0-9]/.test(c)) out += c.toLowerCase(); + else if (!out.endsWith("-")) out += "-"; + } + return out.replace(/^-+|-+$/g, "").slice(0, 60); +} + +function stem(relpath: string): string { + const base = relpath.split("/").pop()!; + return base.replace(/\.md$/, ""); +} + +function buildTree(p: Page): TreeNode { + const root: TreeNode = { children: new Map(), docs: [] }; + const node = (path: string): TreeNode => { + let n = root; + if (!path) return n; + for (const part of path.split("/")) { + if (!n.children.has(part)) { + n.children.set(part, { children: new Map(), docs: [] }); + } + n = n.children.get(part)!; + } + return n; + }; + for (const folder of p.folders) { + if (folder.name) node(folder.name); + } + // Dokumente einhängen; ein Dokument mit gleichnamigem Ordner daneben wird + // dessen Notiz-Inhalt statt eigenes Blatt. + for (const folder of p.folders) { + const parent = node(folder.name); + for (const doc of folder.docs) { + const twin = parent.children.get(doc.name); + if (twin && !twin.content) twin.content = doc; + else parent.docs.push(doc); + } + } + // Wurzel-Konvention: `index.md` im Archiv-Root ist der Text des + // Archiv-Knotens (die Wurzel hat keinen Namen für die Zwillingsregel). + const idx = root.docs.findIndex((d) => d.name === "index"); + if (idx >= 0 && !root.content) { + root.content = root.docs[idx]; + root.docs.splice(idx, 1); + } + return root; +} + +export function initWikiView(container: HTMLElement, cb: WikiCallbacks): WikiView { + /// Zugeklappte Ordner und Auswahl — Auswahl ist ein Ordnerpfad ("" = + /// Wurzel) oder ein Dokument-relpath (endet auf .md); beides überlebt + /// Puffer-Updates innerhalb der Ansicht. + const closed = new Set(); + let selected = ""; + let current: Page | null = null; + let tree: TreeNode = { children: new Map(), docs: [] }; + /// Auswahl-Verlauf für den Zurück-Knopf der Notiz-Ansicht. + const history: string[] = []; + /// Bearbeitungsmodus der aktuellen Notiz (Editor statt Anzeige). + let editing = false; + + const isDoc = (sel: string) => sel.endsWith(".md"); + + function findNode(path: string): TreeNode | null { + let n = tree; + if (!path) return n; + for (const part of path.split("/")) { + const next = n.children.get(part); + if (!next) return null; + n = next; + } + return n; + } + + function findDoc(relpath: string): DocEntry | null { + if (!current) return null; + for (const f of current.folders) { + const hit = f.docs.find((d) => d.relpath === relpath); + if (hit) return hit; + } + return null; + } + + /// Ordnerpfad eines Dokument-relpaths. + const docFolder = (relpath: string) => + relpath.includes("/") ? relpath.slice(0, relpath.lastIndexOf("/")) : ""; + + /// Wikilink lokal auflösen (Name, Titel, Stem — Slug-Vergleich wie im + /// Backend); ohne Treffer übernimmt das Backend (Dokument-Tab). + function followWikiLink(name: string) { + const want = slugify(name); + for (const f of current?.folders ?? []) { + for (const d of f.docs) { + if ([d.name, d.title, stem(d.relpath)].some((s) => slugify(s) === want)) { + select(d.relpath); + return; + } + } + } + cb.openWiki(name); + } + + // ---------- Kontextmenü (rechte Maustaste im Baum) ---------- + + /// Eigenes Kontextmenü an der Mausposition; Klick daneben oder Escape + /// schließt. Das Standard-Menü des Webviews ist im ganzen Archiv aus. + function openMenu(x: number, y: number, items: { label: string; run(): void }[]) { + container.querySelector(".wiki-menu")?.remove(); + const menu = document.createElement("div"); + menu.className = "wiki-menu"; + menu.style.left = `${x}px`; + menu.style.top = `${y}px`; + for (const item of items) { + const btn = document.createElement("button"); + btn.className = "wiki-menu-item"; + btn.textContent = item.label; + btn.addEventListener("click", () => { + menu.remove(); + item.run(); + }); + menu.append(btn); + } + const close = (e: Event) => { + if (e instanceof KeyboardEvent && e.key !== "Escape") return; + // Mousedown auf einem Menüpunkt: erst der Klick führt aus — das Menü + // räumt der Punkt selbst weg. + if (e instanceof MouseEvent && menu.contains(e.target as Node)) return; + menu.remove(); + document.removeEventListener("mousedown", close); + document.removeEventListener("keydown", close); + }; + setTimeout(() => { + document.addEventListener("mousedown", close); + document.addEventListener("keydown", close); + }); + container.append(menu); + } + + // ---------- Baum-Spalte ---------- + + /// Typ-Symbol im Baum (Stroke-SVG wie die übrigen App-Icons): neutraler + /// Knoten-Kreis für Ordner-Notizen, Blatt für Dokumente. + function typeIcon(kind: "node" | "doc"): HTMLElement { + const span = document.createElement("span"); + span.className = "wiki-tree-icon"; + span.innerHTML = + kind === "node" + ? `` + : ``; + return span; + } + + function folderRow(name: string, full: string, node: TreeNode): HTMLElement { + const det = document.createElement("details"); + det.className = "wiki-tree-folder"; + det.open = !closed.has(full); + det.addEventListener("toggle", () => { + if (det.open) closed.delete(full); + else closed.add(full); + }); + const sum = document.createElement("summary"); + sum.classList.add(node.content ? "has-content" : "book"); + if (full === selected || node.content?.relpath === selected) { + sum.classList.add("active"); + } + // Die ganze Zeile wählt die Notiz aus (wie in Trilium); NUR der Pfeil + // klappt — deshalb das summary-Default unterbinden und selbst schalten. + sum.addEventListener("click", (e) => { + e.preventDefault(); + select(full); + }); + const arrow = document.createElement("span"); + arrow.className = "wiki-tree-arrow"; + arrow.textContent = "▸"; + arrow.addEventListener("click", (e) => { + e.preventDefault(); + e.stopPropagation(); + det.open = !det.open; + }); + const label = document.createElement("span"); + label.className = "wiki-tree-name"; + label.textContent = node.content?.title ?? name; + sum.addEventListener("contextmenu", (e) => { + e.preventDefault(); + e.stopPropagation(); + openMenu(e.clientX, e.clientY, [ + { label: t("wiki.newDoc"), run: () => newDocForm(full) }, + { label: t("wiki.newFolder"), run: () => newFolderForm(full) }, + { + label: t("wiki.folderRename"), + run: () => + openForm(t("wiki.folderRename"), t("wiki.folderPath"), full, (v) => + cb.actions.moveFolder(full, v), + ), + }, + ]); + }); + sum.append(arrow, typeIcon("node"), label); + det.append(sum, renderChildren(node, full)); + return det; + } + + function docRow(doc: DocEntry): HTMLElement { + const row = document.createElement("button"); + row.className = "wiki-tree-doc"; + if (doc.relpath === selected) row.classList.add("active"); + const label = document.createElement("span"); + label.className = "wiki-tree-name"; + label.textContent = doc.title; + row.append(typeIcon("doc"), label); + row.addEventListener("click", () => select(doc.relpath)); + row.addEventListener("contextmenu", (e) => { + e.preventDefault(); + e.stopPropagation(); + openMenu(e.clientX, e.clientY, [ + { + label: t("wiki.editDoc"), + run: () => { + select(doc.relpath); + editing = true; + renderMain(); + }, + }, + { + label: t("wiki.renameDoc"), + run: () => + openForm(t("wiki.renameDoc"), t("wiki.docName"), doc.name, (v) => + cb.actions.rename(doc.relpath, v), + ), + }, + { label: t("wiki.deleteDoc"), run: () => cb.actions.remove(doc.relpath) }, + ]); + }); + return row; + } + + function renderChildren(node: TreeNode, path: string): HTMLElement { + const box = document.createElement("div"); + box.className = "wiki-tree-children"; + // Unter einem Knoten erst seine Dokumente (weniger eingerückt), dann + // seine Ordner (stärker eingerückt) — Skizze: Knoten / Dok (4em) / + // Ordner (6em). + for (const doc of [...node.docs].sort((a, b) => a.title.localeCompare(b.title))) { + box.append(docRow(doc)); + } + for (const [name, child] of [...node.children].sort((a, b) => + a[0].localeCompare(b[0]), + )) { + box.append(folderRow(name, path ? `${path}/${name}` : name, child)); + } + return box; + } + + function renderTree(): HTMLElement { + const aside = document.createElement("aside"); + aside.className = "wiki-tree"; + const head = document.createElement("div"); + head.className = "wiki-tree-head"; + const root = document.createElement("button"); + root.className = "wiki-tree-root" + (selected === "" ? " active" : ""); + root.textContent = t("wiki.archive"); + root.addEventListener("click", () => select("")); + root.addEventListener("contextmenu", (e) => { + e.preventDefault(); + e.stopPropagation(); + openMenu(e.clientX, e.clientY, [ + { label: t("wiki.newDoc"), run: () => newDocForm("") }, + { label: t("wiki.newFolder"), run: () => newFolderForm("") }, + ]); + }); + head.append(root); + aside.append(head, renderChildren(tree, "")); + return aside; + } + + // ---------- Anlege-Formular (Ordner/Dokument) ---------- + + function newDocForm(folder: string) { + openForm(t("wiki.newDoc"), t("wiki.docName"), "", (v) => cb.actions.createDoc(folder, v)); + } + + function newFolderForm(folder: string) { + openForm(t("wiki.newFolder"), t("wiki.folderPath"), folder ? `${folder}/` : "", (v) => + cb.actions.createFolder(v), + ); + } + + /// Modaler Dialog mit einem Textfeld: Anlegen/Abbrechen; Enter legt an, + /// Escape oder Klick auf den Hintergrund schließt. Leere oder ungültige + /// Eingaben meldet das Backend als Toast. + function openForm( + title: string, + placeholder: string, + initial: string, + onSubmit: (value: string) => void, + ) { + document.querySelector(".wiki-modal")?.remove(); + const backdrop = document.createElement("div"); + backdrop.className = "wiki-modal"; + const form = document.createElement("div"); + form.className = "wiki-form"; + const caption = document.createElement("div"); + caption.className = "wiki-form-title"; + caption.textContent = title; + const input = document.createElement("input"); + input.className = "wiki-tree-input"; + input.placeholder = placeholder; + input.value = initial; + const row = document.createElement("div"); + row.className = "wiki-form-row"; + const submit = document.createElement("button"); + submit.className = "wiki-form-submit"; + submit.textContent = t("wiki.create"); + const cancel = document.createElement("button"); + cancel.className = "wiki-form-cancel"; + cancel.textContent = t("wiki.cancel"); + const fire = () => { + const value = input.value.trim(); + backdrop.remove(); + onSubmit(value); + }; + submit.addEventListener("click", fire); + cancel.addEventListener("click", () => backdrop.remove()); + backdrop.addEventListener("mousedown", (e) => { + if (e.target === backdrop) backdrop.remove(); + }); + form.addEventListener("keydown", (e) => { + if (e.key === "Enter") fire(); + else if (e.key === "Escape") backdrop.remove(); + }); + row.append(submit, cancel); + form.append(caption, input, row); + backdrop.append(form); + container.append(backdrop); + input.focus(); + input.select(); + } + + // ---------- Notiz-Ansicht rechts ---------- + + /// Ein Schritt zurück im Auswahl-Verlauf; verschwundene Ziele werden + /// übersprungen. + function goBack() { + while (history.length) { + const prev = history.pop()!; + const exists = isDoc(prev) ? !!findDoc(prev) : !!findNode(prev); + if (exists) { + select(prev, false); + return; + } + } + } + + /// Kopf der Notiz-Ansicht: Zurück-Knopf, großer Titel, Meta-Zeile, Aktionen. + function noteHead( + title: string, + meta: (string | HTMLElement)[], + actions: HTMLElement[], + ): HTMLElement { + const head = document.createElement("div"); + head.className = "wiki-note-head"; + const row = document.createElement("div"); + row.className = "wiki-note-titlerow"; + const back = document.createElement("button"); + back.className = "wiki-note-back"; + back.title = t("wiki.back"); + back.textContent = "←"; + back.disabled = history.length === 0; + back.addEventListener("click", goBack); + row.append(back); + const h = document.createElement("div"); + h.className = "wiki-note-title"; + h.textContent = title; + // Klick auf den Titel bearbeitet ihn direkt (Frontmatter-Titel der + // Notiz); Enter übernimmt, Escape verwirft. Der technische Datei-/ + // Ordnername bleibt davon unberührt — der sitzt im Baum-Kontextmenü. + const titleDoc = isDoc(selected) ? findDoc(selected) : findNode(selected)?.content; + if (titleDoc && !editing) { + h.classList.add("editable"); + h.title = t("wiki.titleEdit"); + h.addEventListener("click", () => { + const input = document.createElement("input"); + input.className = "wiki-note-title-input"; + input.value = title; + input.addEventListener("keydown", (e) => { + e.stopPropagation(); + if (e.key === "Enter") cb.setTitle(titleDoc.relpath, input.value.trim()); + else if (e.key === "Escape") input.replaceWith(h); + }); + input.addEventListener("blur", () => input.replaceWith(h)); + h.replaceWith(input); + input.focus(); + input.select(); + }); + } + const acts = document.createElement("div"); + acts.className = "wiki-note-actions"; + acts.append(...actions); + row.append(h, acts); + head.append(row); + // Meta (Datum, Schlagwörter, Verweise) als kleines Popup am Info-Knopf + // rechts in den Aktionen; Klick daneben schließt es. + if (meta.length) { + const pop = document.createElement("div"); + pop.className = "wiki-note-info-pop"; + pop.hidden = true; + const caption = document.createElement("div"); + caption.className = "wiki-info-caption"; + caption.textContent = t("wiki.infoCaption"); + pop.append(caption); + for (const part of meta) { + const line = document.createElement("div"); + line.className = "wiki-info-line"; + line.append(part); + pop.append(line); + } + const info = document.createElement("button"); + info.className = "panel-btn"; + info.title = t("wiki.info"); + info.textContent = "ⓘ"; + const close = (e: MouseEvent) => { + if (e.target === info) return; + pop.hidden = true; + document.removeEventListener("click", close); + }; + info.addEventListener("click", () => { + pop.hidden = !pop.hidden; + if (!pop.hidden) { + setTimeout(() => document.addEventListener("click", close)); + } + }); + acts.append(info); + row.append(pop); + } + return head; + } + + /// Gerenderter Markdown-Body; lädt asynchron nach — verworfen, wenn die + /// Auswahl inzwischen gewechselt hat. Ladefehler erscheinen im Body statt + /// still zu verschwinden. + function noteBody(doc: DocEntry): HTMLElement { + const body = document.createElement("div"); + body.className = "wiki-note-body"; + cb.readDoc(doc.relpath).then( + (text) => { + if (selected !== doc.relpath && findNode(selected)?.content !== doc) return; + body.innerHTML = renderMarkdown(text); + linkWikiRefs(body, followWikiLink); + }, + (e) => { + body.textContent = String(e); + }, + ); + return body; + } + + /// Markdown-Editor der Notiz: links der Rohtext, rechts die Live-Vorschau + /// (dieselbe Darstellung wie die Anzeige, Wikilinks klickbar). Der Text + /// wandert unverändert zurück in die Datei — kein Rundlauf über ein + /// Dokumentmodell, das Zeilenumbrüche, Listenmarker oder `[[ziel]]` + /// normalisieren würde. Fehler erscheinen über dem Editor. + function noteEditor(doc: DocEntry): { el: HTMLElement; save(): void } { + const box = document.createElement("div"); + box.className = "wiki-note-edit"; + const err = document.createElement("div"); + err.className = "wiki-note-error"; + err.hidden = true; + const split = document.createElement("div"); + split.className = "wiki-edit-split"; + const area = document.createElement("textarea"); + area.className = "wiki-note-editor"; + area.spellcheck = false; + const preview = document.createElement("div"); + preview.className = "wiki-note-body wiki-edit-preview"; + const draw = () => { + preview.innerHTML = renderMarkdown(area.value); + linkWikiRefs(preview, followWikiLink); + }; + cb.readDoc(doc.relpath).then( + (text) => { + area.value = text; + draw(); + area.focus(); + }, + (e) => { + err.hidden = false; + err.textContent = String(e); + }, + ); + area.addEventListener("input", draw); + const save = () => { + cb.writeDoc(doc.relpath, area.value).then( + () => { + editing = false; + renderMain(); + }, + (e) => { + err.hidden = false; + err.textContent = String(e); + }, + ); + }; + area.addEventListener("keydown", (e) => { + if ((e.ctrlKey || e.metaKey) && e.key === "s") { + e.preventDefault(); + save(); + } + }); + split.append(area, preview); + box.append(err, split); + return { el: box, save }; + } + + /// Kopf-Aktionen im Bearbeitungsmodus: Speichern und Abbrechen. + function editActions(save: () => void): HTMLElement[] { + const ok = document.createElement("button"); + ok.className = "wiki-form-submit"; + ok.textContent = t("wiki.save"); + ok.addEventListener("click", save); + const cancel = document.createElement("button"); + cancel.className = "wiki-form-cancel"; + cancel.textContent = t("wiki.cancel"); + cancel.addEventListener("click", () => { + editing = false; + renderMain(); + }); + return [ok, cancel]; + } + + function metaParts(doc: DocEntry): string[] { + const parts = []; + if (doc.date) parts.push(doc.date); + if (doc.tags.length) parts.push(doc.tags.map((x) => `#${x}`).join(" ")); + if (doc.backlinks) parts.push(`↩ ${doc.backlinks}`); + return parts; + } + + function docActions(doc: DocEntry): HTMLElement[] { + const edit = document.createElement("button"); + edit.className = "panel-btn"; + edit.title = t("wiki.editDoc"); + edit.textContent = "✎"; + edit.addEventListener("click", () => { + editing = true; + renderMain(); + }); + return [edit, deleteAction(t("wiki.deleteDoc"), () => cb.actions.remove(doc.relpath))]; + } + + /// Kindzeile im Dokument-Abschnitt: Titel links, Anlage-/Änderungsdatum + /// rechts; Knoten ohne Inhaltsdatei zeigen nur den Titel. + function childRow(title: string, doc: DocEntry | null, onOpen: () => void): HTMLElement { + const row = document.createElement("div"); + row.className = "wiki-doc"; + const line = document.createElement("div"); + line.className = "wiki-doc-line"; + const head = document.createElement("div"); + head.className = "wiki-doc-title"; + head.textContent = title; + line.append(head); + if (doc) { + const dates = document.createElement("div"); + dates.className = "wiki-doc-date"; + const parts = []; + if (doc.date) parts.push(t("wiki.createdAt", { date: doc.date })); + parts.push(t("wiki.changedAt", { date: doc.modified })); + dates.textContent = parts.join(" · "); + line.append(dates); + } + row.append(line); + row.addEventListener("click", onOpen); + return row; + } + + function renderMain() { + const p = current!; + const main = container.querySelector(".wiki-main")!; + main.textContent = ""; + + if (isDoc(selected)) { + const doc = findDoc(selected); + if (!doc) return; + if (editing) { + const ed = noteEditor(doc); + main.append(noteHead(doc.title, [], editActions(ed.save)), ed.el); + return; + } + const head = noteHead(doc.title, metaParts(doc), docActions(doc)); + main.append(head, noteBody(doc)); + return; + } + + const node = findNode(selected); + if (!node) return; + const title = node.content?.title ?? (selected ? selected.split("/").pop()! : t("wiki.archive")); + const children = [...node.children.keys()].length + node.docs.length; + const meta: string[] = node.content ? metaParts(node.content) : []; + + const add = document.createElement("button"); + add.className = "wiki-add"; + add.title = t("wiki.newDoc"); + add.textContent = "+"; + add.addEventListener("click", () => + openForm(t("wiki.newDoc"), t("wiki.docName"), "", (v) => + cb.actions.createDoc(selected, v), + ), + ); + const actions: HTMLElement[] = node.content + ? [add, ...docActions(node.content)] + : [add]; + if (editing && node.content) { + const ed = noteEditor(node.content); + main.append(noteHead(title, [], editActions(ed.save)), ed.el); + return; + } + const head = noteHead(title, meta, actions); + main.append(head); + if (node.content) { + main.append(noteBody(node.content)); + } else { + // Jede Ordner-Notiz trägt von Haus aus Text — im Default ihren Namen. + const body = document.createElement("div"); + body.className = "wiki-note-body default"; + body.textContent = title; + main.append(body); + } + + if (children === 0 && !node.content) { + const empty = document.createElement("div"); + empty.className = "wiki-empty"; + const line = document.createElement("strong"); + line.textContent = p.total === 0 ? t("wiki.emptyArchive") : t("wiki.emptyFolder"); + empty.append(line); + if (p.total === 0) empty.append(t("wiki.emptyHint")); + main.append(empty); + return; + } + const list = document.createElement("div"); + list.className = "wiki-note-children"; + const caption = document.createElement("div"); + caption.className = "wiki-children-caption"; + caption.textContent = t(children === 1 ? "wiki.docOne" : "wiki.docMany", { + count: children, + }); + list.append(caption); + for (const [name, child] of [...node.children].sort((a, b) => a[0].localeCompare(b[0]))) { + const full = selected ? `${selected}/${name}` : name; + list.append( + childRow(child.content?.title ?? name, child.content ?? null, () => select(full)), + ); + } + for (const doc of [...node.docs].sort((a, b) => a.title.localeCompare(b.title))) { + list.append(childRow(doc.title, doc, () => select(doc.relpath))); + } + main.append(list); + } + + function select(target: string, remember = true) { + if (remember && target !== selected) history.push(selected); + editing = false; + selected = target; + // Elternordner eines gewählten Dokuments aufklappen, sonst bleibt die + // Hervorhebung unsichtbar. + if (isDoc(target)) { + const parts = docFolder(target).split("/").filter(Boolean); + let path = ""; + for (const part of parts) { + path = path ? `${path}/${part}` : part; + closed.delete(path); + } + } + if (current) render(); + } + + function render() { + container.textContent = ""; + const layout = document.createElement("div"); + layout.className = "wiki-layout"; + const main = document.createElement("div"); + main.className = "wiki-main"; + layout.append(renderTree(), main); + container.append(layout); + renderMain(); + } + + /// Leerer Puffer: die Übersicht direkt anfordern — das Archiv startet + /// von selbst, ohne Einstiegs-Klick. Nur einmal, das Update kommt über + /// den Wiki-Puffer zurück. + let requested = false; + + // Standard-Kontextmenü des Webviews im Archiv aus — es gibt nur unser + // eigenes an Baumzeilen. + container.addEventListener("contextmenu", (e) => e.preventDefault()); + + let loaded = false; + return { + set(text: string) { + container.textContent = ""; + loaded = !!text.trim(); + if (!loaded) { + current = null; + if (cb.autoStart && !requested) { + requested = true; + cb.openWiki("tag:"); + } + return; + } + current = JSON.parse(text); + tree = buildTree(current!); + // Vorgemerkte Auswahl (Suchtreffer-Sprung) schlägt die gemerkte. + const pending = cb.takePending?.(); + if (pending && findDoc(pending)) { + select(pending); + return; + } + // Die gewählte Notiz kann nach Umbenennen/Löschen weg sein — dann + // zurück zur Wurzel. + const exists = isDoc(selected) ? !!findDoc(selected) : !!findNode(selected); + if (selected && !exists) { + selected = ""; + } + render(); + }, + empty: () => !loaded, + }; +} diff --git a/terminal.html b/terminal.html index 8683f02..42b1d0c 100644 --- a/terminal.html +++ b/terminal.html @@ -27,10 +27,10 @@