diff --git a/panel.html b/panel.html index a3b0ed1..b60c21e 100644 --- a/panel.html +++ b/panel.html @@ -2,7 +2,7 @@ - Dokument + Panel
@@ -21,38 +21,12 @@
+
- Dokument - -
-
- - - - - - +
- -
@@ -849,27 +878,63 @@ onUnmounted(() => window.clearInterval(timer)); -
-
-

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

-

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

+
+

{{ $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.archiveMigrateHint") }}

+ +

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

+ +
+ {{ $t("trilium.password") }} + + +
+
- - +
@@ -1050,4 +1115,105 @@ 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 b799a55..32e3ad0 100644 --- a/src/messages.test.ts +++ b/src/messages.test.ts @@ -32,6 +32,7 @@ describe("Nachrichtentabellen", () => { const erlaubt = new Set([ "Pool", "Pools", + "Panel", "Name", "Theme", "Wiki", @@ -47,6 +48,7 @@ describe("Nachrichtentabellen", () => { "+ apiKey", "+ ToDo", "System", + "Trilium", "Terminal", "Terminal — {name}", ".desktop-Starter", @@ -69,7 +71,7 @@ function pick(obj: object, key: string): string { describe("t()", () => { it("löst Punkt-Pfade auf", () => { - expect(t("panel.tabDraft")).toBe("Dokument"); + expect(t("panel.tabSearch")).toBe("Suche"); }); it("setzt Platzhalter ein", () => { @@ -88,7 +90,7 @@ describe("t()", () => { it("folgt der gespeicherten Sprache", () => { window.localStorage.setItem("lang", "en"); expect(storedLocale()).toBe("en"); - expect(t("panel.tabDraft")).toBe("Document"); + expect(t("panel.tabSearch")).toBe("Search"); }); it("fällt bei unbekannter Sprache auf die Browsersprache zurück", () => { @@ -100,13 +102,13 @@ describe("t()", () => { describe("applyI18n()", () => { it("beschriftet Text und Attribute", () => { document.body.innerHTML = ` - + `; applyI18n(); const btn = document.querySelector("button")!; - expect(btn.textContent).toBe("Wiki"); - expect(btn.title).toBe("Archiv-Wiki"); + expect(btn.textContent).toBe("Suche"); + expect(btn.title).toBe("Suchtreffer"); expect(document.querySelectorAll("button")[1].getAttribute("aria-label")).toBe( "Schließen", ); diff --git a/src/messages.ts b/src/messages.ts index 736fb9b..58dcbc1 100644 --- a/src/messages.ts +++ b/src/messages.ts @@ -34,6 +34,7 @@ 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.", @@ -59,8 +60,6 @@ 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", @@ -82,25 +81,17 @@ const de = { scopeIntegrationDesc: "ai-control-Spuren werden entfernt; Projektordner, memory/ und Todoliste bleiben.", scopeArchive: "Integration & Archiv", - scopeArchiveDesc: "zusätzlich {path} — endgültig.", + scopeArchiveDesc: "zusätzlich das Trilium-Archiv {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: "Archiv {path}", + artArchive: "Trilium-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", @@ -111,8 +102,28 @@ const de = { commandsDesc: "Befehls-Kacheln im Panel (write_commands)", todo: "ToDo-Liste", todoDesc: "persistente Aufgaben-Kacheln mit Fälligkeits-Ampel; öffnet beim Sessionstart", - archive: "Archiv", - archiveDesc: "Archivieren, Wiki und Suche", + 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", }, todos: { copyOne: "Aufgabe kopieren", @@ -178,17 +189,15 @@ const de = { }, panel: { windowTerminal: "Terminal", - windowDocument: "Dokument", + windowPanel: "Panel", windowProjects: "Projekte", tabCommands: "Befehle", tabCommandsTitle: "Befehls-History", tabTodo: "ToDo", tabTodoTitle: "ToDo-Liste des Projekts", - tabDraft: "Dokument", - tabDraftTitle: "Dokument", - tabWiki: "Wiki", - tabWikiTitle: "Archiv-Wiki", tabSearch: "Suche", + archiveOpen: "Archiv", + archiveOpenTitle: "Archiv in Trilium öffnen", tabSearchTitle: "Suchtreffer", minimize: "Minimieren", maximize: "Maximieren", @@ -197,16 +206,6 @@ 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: { @@ -219,39 +218,12 @@ 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: "Ordner umbenennen", - renameDoc: "Dokument umbenennen", - 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 = { @@ -283,6 +255,7 @@ 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.", @@ -308,8 +281,6 @@ 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", @@ -331,25 +302,17 @@ const en: typeof de = { scopeIntegrationDesc: "ai-control traces are removed; project folder, memory/ and todo list stay.", scopeArchive: "Integration & archive", - scopeArchiveDesc: "additionally {path} — permanent.", + scopeArchiveDesc: "additionally the Trilium archive {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: "archive {path}", + artArchive: "Trilium 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", @@ -360,8 +323,28 @@ 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", - archive: "Archive", - archiveDesc: "archiving, wiki and search", + 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", }, todos: { copyOne: "Copy task", @@ -427,17 +410,15 @@ const en: typeof de = { }, panel: { windowTerminal: "Terminal", - windowDocument: "Document", + windowPanel: "Panel", windowProjects: "Projects", tabCommands: "Commands", tabCommandsTitle: "Command history", tabTodo: "ToDo", tabTodoTitle: "Project to-do list", - tabDraft: "Document", - tabDraftTitle: "Document", - tabWiki: "Wiki", - tabWikiTitle: "Archive wiki", tabSearch: "Search", + archiveOpen: "Archive", + archiveOpenTitle: "Open the archive in Trilium", tabSearchTitle: "Search hits", minimize: "Minimize", maximize: "Maximize", @@ -446,16 +427,6 @@ 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: { @@ -468,39 +439,12 @@ 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: "Rename folder", - renameDoc: "Rename document", - deleteDoc: "Delete document", - newFolder: "New folder", - newDoc: "New document", - create: "Create", - cancel: "Cancel", - folderPath: "folder/subfolder", - docName: "Document name", - }, }; export const messages = { de, en }; @@ -522,7 +466,7 @@ export function storedLocale(): Locale { return navigator.language.startsWith("de") ? "de" : "en"; } -/// Übersetzt einen Punkt-Pfad (`panel.tabDraft`) und setzt `{platzhalter}` ein. +/// Übersetzt einen Punkt-Pfad (`panel.tabSearch`) 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 bfc101a..2a67e72 100644 --- a/src/modules/archive.ts +++ b/src/modules/archive.ts @@ -1,57 +1,30 @@ import { invoke } from "@tauri-apps/api/core"; import { initSearchView } from "../search-view"; -import { initWikiView } from "../wiki-view"; import type { PanelTab } from "./index"; -export const wikiTab: PanelTab = { - mode: "wiki", - module: "archive", - buffer: "wiki", - labelKey: "panel.tabWiki", - titleKey: "panel.tabWikiTitle", - // Dokument-/Ordner-Operationen laufen als Commands; der neue Stand kommt - // über den Wiki-Puffer (wiki-update) zurück, Fehler als Toast. Der - // Dokument-Sprung lädt wie ein Suchtreffer 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, { - openDoc: ctx.openDoc, - openWiki: ctx.openWiki, - 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. - onActivate: (_view, ctx) => ctx.openWiki("tag:"), -}; - export const searchTab: PanelTab = { mode: "search", - module: "archive", + module: "trilium", buffer: "search", labelKey: "panel.tabSearch", titleKey: "panel.tabSearchTitle", sepAfter: true, - // Treffer-Klick lädt das Dokument in den Dokument-Tab (dort editier- und - // archivierbar); der Sprung ins Wiki geht von dort aus. + // Treffer-Klick öffnet die Notiz im Trilium-Fenster des Projekts. init: (container, ctx) => - initSearchView(container, ctx.openDoc, (raw) => { - // `#tag`-Tokens filtern aufs Schlagwort, der Rest ist die Volltext-Query. - const words = raw.split(/\s+/).filter(Boolean); - const tag = words.find((w) => w.startsWith("#"))?.slice(1) ?? null; - const query = words.filter((w) => !w.startsWith("#")).join(" "); - void invoke("search_run", { project: ctx.project, query, tag }).catch((e) => - ctx.toast(`Suche fehlgeschlagen: ${e}`), - ); - }), + initSearchView( + container, + (noteId) => + void invoke("trilium_open", { project: ctx.project, noteId }).catch((e) => + ctx.toast(String(e)), + ), + (raw) => { + // `#tag`-Tokens filtern aufs Schlagwort, der Rest ist die Volltext-Query. + const words = raw.split(/\s+/).filter(Boolean); + const tag = words.find((w) => w.startsWith("#"))?.slice(1) ?? null; + const query = words.filter((w) => !w.startsWith("#")).join(" "); + void invoke("search_run", { project: ctx.project, query, tag }).catch((e) => + ctx.toast(`Suche fehlgeschlagen: ${e}`), + ); + }, + ), }; diff --git a/src/modules/index.ts b/src/modules/index.ts index 9bb5869..5518d82 100644 --- a/src/modules/index.ts +++ b/src/modules/index.ts @@ -6,7 +6,7 @@ /// `-update`-Event. import { commandsTab } from "./commands"; -import { searchTab, wikiTab } from "./archive"; +import { searchTab } from "./archive"; import { todoTab } from "./todo"; export interface ModuleView { @@ -19,10 +19,6 @@ export interface ModuleView { export interface ModuleCtx { project: string; toast(msg: string): void; - /// Dokument in den Entwurfs-Tab laden (Treffer-Klick). - openDoc(path: string): void; - /// Wiki-Ziel öffnen (Wikilink, Chip, `tag:`-Namensraum). - openWiki(name: string): void; } export interface PanelTab { @@ -37,23 +33,10 @@ export interface PanelTab { titleKey: string; /// Visueller Trenner hinter dem Tab. sepAfter?: boolean; - /// Baut die Ansicht des Tabs. Ohne init verdrahtet panel-wiring die - /// Ansicht selbst (Entwurfs-Tab — Kern mit eigenem Markup). - init?(container: HTMLElement, ctx: ModuleCtx): ModuleView; - /// Nach Tab-Klick (z. B. Wiki: leere Ansicht lädt die Übersicht). + /// Baut die Ansicht des Tabs. + init(container: HTMLElement, ctx: ModuleCtx): ModuleView; + /// Nach Tab-Klick. onActivate?(view: ModuleView, ctx: ModuleCtx): void; } -export const PANEL_TABS: PanelTab[] = [ - todoTab, - commandsTab, - { - mode: "draft", - module: "draft", - buffer: "panel", - labelKey: "panel.tabDraft", - titleKey: "panel.tabDraftTitle", - }, - wikiTab, - searchTab, -]; +export const PANEL_TABS: PanelTab[] = [todoTab, commandsTab, searchTab]; diff --git a/src/panel-tiles.css b/src/panel-tiles.css index eb6564c..2c50484 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; Input-Stil wie im Archiv-Formular. */ + in-flow über der Liste. */ .todo-head { display: flex; justify-content: flex-end; @@ -311,364 +311,6 @@ 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 150px; - min-width: 0; - border-right: 1px solid var(--line); - padding-right: 8px; - position: sticky; - top: 0; -} -.wiki-main { - flex: 1; - min-width: 0; -} -.wiki-tree details > .wiki-tree-children { - margin-left: 10px; -} -.wiki-tree summary { - display: flex; - align-items: center; - gap: 2px; - list-style: none; - cursor: pointer; - padding: 2px 2px; - color: var(--muted); - font: - 500 11px/1.4 -apple-system, - system-ui, - sans-serif; - user-select: none; - -webkit-user-select: none; -} -.wiki-tree summary::-webkit-details-marker { - display: none; -} -.wiki-tree summary::before { - content: "▸"; - color: var(--faint); - font-size: 9px; - margin-right: 3px; -} -.wiki-tree details[open] > summary::before { - content: "▾"; -} -.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 summary.active, -.wiki-tree-root.active { - color: var(--accent); -} -.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: - 500 11px/1.5 -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 11px/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-Formular (Ordner/Dokument) oben in der Liste. */ -.wiki-form { - display: flex; - align-items: center; - gap: 6px; - background: var(--surface); - border: 1px solid var(--line-strong); - border-radius: 8px; - padding: 8px 10px; - margin: 10px 0 4px; -} -.wiki-form-title { - color: var(--muted); - font: - 500 11px/1.4 -apple-system, - system-ui, - sans-serif; - white-space: nowrap; -} -.wiki-form .wiki-tree-input { - flex: 1; - width: auto; - padding: 4px 8px; - font-size: 12px; -} -.wiki-form-submit, -.wiki-form-cancel { - border: none; - background: var(--line); - color: var(--text); - border-radius: 6px; - padding: 4px 10px; - font: - 500 11px/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); -} -.wiki-head { - display: flex; - align-items: baseline; - justify-content: space-between; - gap: 8px; -} -.wiki-head-left { - min-width: 0; -} -.wiki-head-title { - color: var(--text); - font: - 600 13px/1.3 -apple-system, - system-ui, - sans-serif; -} -.wiki-head-sub { - margin-top: 2px; - color: var(--faint); - font-family: "JetBrains Mono", Menlo, monospace; - font-size: 10px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} -.wiki-head-right { - color: var(--faint); - font: - 500 11px/1 -apple-system, - system-ui, - sans-serif; - white-space: nowrap; -} -/* Einstiegs-Knopf im Leerzustand. */ -.wiki-chip { - border: 1px solid var(--line); - 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: 7px 8px; - margin: 4px -8px 0; - border-radius: 8px; - cursor: pointer; -} -.wiki-doc:hover { - background: var(--tile); -} -.wiki-doc-line { - display: flex; - align-items: baseline; - gap: 8px; -} -.wiki-doc-title { - flex: 1; - min-width: 0; - color: var(--accent); - font: - 600 12px/1.4 -apple-system, - system-ui, - sans-serif; -} -.wiki-doc-date { - color: var(--faint); - font-family: "JetBrains Mono", Menlo, monospace; - font-size: 10px; -} -.wiki-doc-desc { - margin-top: 2px; - color: var(--muted); - font: - 400 11px/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); - 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 deleted file mode 100644 index 31de2ba..0000000 --- a/src/panel-view.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -// 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 deleted file mode 100644 index f6cc719..0000000 --- a/src/panel-view.ts +++ /dev/null @@ -1,271 +0,0 @@ -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 00a3113..69c5f92 100644 --- a/src/panel-window.css +++ b/src/panel-window.css @@ -185,12 +185,6 @@ body { margin: 0 6px; 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; @@ -199,13 +193,6 @@ 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; @@ -249,118 +236,10 @@ 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-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] { +.panel-btn[hidden] { display: none; } diff --git a/src/panel-wiring.ts b/src/panel-wiring.ts index 5256c3b..c3c1879 100644 --- a/src/panel-wiring.ts +++ b/src/panel-wiring.ts @@ -1,14 +1,11 @@ /// Gemeinsame Panel-Verdrahtung für das angedockte Panel (terminal.ts) und -/// 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`. +/// 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. 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"; @@ -16,12 +13,9 @@ 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( @@ -29,59 +23,18 @@ export async function wirePanel( onIncoming?: () => void, ): Promise { const titleEl = document.querySelector(".panel-title") as HTMLElement; - const [enabled, defaultLang, draft] = await Promise.all([ - invoke("enabled_modules", { project }), - invoke("spellcheck_lang"), - invoke("buffer_read", { project, buffer: "panel" }), - ]); + const enabled = await invoke("enabled_modules", { project }); const tabs = PANEL_TABS.filter((tab) => enabled.includes(tab.module)); - // 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, - 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, - }); + const ctx: ModuleCtx = { project, toast: panelToast }; // Tab-Leiste und Container aus der Registry. Die Container-IDs // (`-content`) sind zugleich die CSS-Anker der Ansichten; die - // Container reihen sich hinter #panel-content ein. + // Container reihen sich hinter der Kopfzeile (.panel-head) ein. const tabsEl = document.getElementById("panel-tabs")!; const views = new Map(); const modeTabs: ModeTab[] = []; - let anchor = document.getElementById("panel-content")!; + let anchor = document.querySelector(".panel-head") as HTMLElement; for (const tab of tabs) { const btn = document.createElement("button"); btn.className = "panel-btn"; @@ -94,15 +47,12 @@ export async function wirePanel( sep.className = "tab-sep"; tabsEl.append(sep); } - let content: HTMLElement | null = null; - if (tab.init) { - content = document.createElement("div"); - content.id = `${tab.mode}-content`; - content.hidden = true; - anchor.after(content); - anchor = content; - views.set(tab.mode, tab.init(content, ctx)); - } + 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)); modeTabs.push({ mode: tab.mode, btn, @@ -113,43 +63,34 @@ export async function wirePanel( : undefined, }); } - const mode = initPanelMode({ - tabs: modeTabs, - draftEls: [ - document.getElementById("panel-content")!, - ...document.querySelectorAll(".draft-only"), - ], - titleEl, - flush: () => void view.flush(), - }); + // 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); + } - // 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:")); + const mode = initPanelMode({ tabs: modeTabs, titleEl }); - 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 - .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?.(); - }); - }), + 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?.(); + }); + }), ); - return { view, views, mode, draft }; + return { views, mode }; } diff --git a/src/panel.ts b/src/panel.ts index 6b8326c..ba2b105 100644 --- a/src/panel.ts +++ b/src/panel.ts @@ -6,14 +6,11 @@ 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, t } from "./messages"; +import { applyI18n } from "./messages"; -// 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. +// Abgelöstes Panel-Fenster: dieselben Modul-Tabs wie das angedockte Panel, +// gespeist aus denselben Puffer-Update-Events. applyI18n(); const project = new URLSearchParams(location.search).get("project")!; const win = getCurrentWebviewWindow(); @@ -99,52 +96,20 @@ applyTheme(picked); document.documentElement.style.background = picked.header; document.body.style.background = picked.header; -const { view, views, mode, draft } = await wirePanel(project); +const { views, mode } = await wirePanel(project); // Beim Ablösen kommt der dort aktive Tab als URL-Parameter mit; ohne ihn -// (Kaltstart) gilt die Default-Logik: ohne Entwurf die ToDo-Liste (falls -// gewählt und nicht leer), sonst die Befehle. +// (Kaltstart): ToDo-Liste, falls gewählt und nicht leer, sonst der erste Tab. const initialMode = new URLSearchParams(location.search).get("mode"); const todos = views.get("todo"); -const cmds = views.get("commands"); +const first = views.keys().next().value; if (initialMode) { mode.to(initialMode); -} else if (!draft.trim()) { - if (todos && !todos.empty()) mode.to("todo"); - else if (cmds && !cmds.empty()) mode.to("commands"); +} else if (todos && !todos.empty()) { + mode.to("todo"); +} else if (first) { + mode.to(first); } -// 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()); - // „Andocken": angedocktes Panel wieder einblenden, dann dieses Fenster schließen. document.getElementById("panel-dock")!.addEventListener("click", async () => { await emit("panel-attached"); diff --git a/src/search-view.test.ts b/src/search-view.test.ts index f0d8c71..a5f537a 100644 --- a/src/search-view.test.ts +++ b/src/search-view.test.ts @@ -20,8 +20,7 @@ function setup() { const run = JSON.stringify({ query: "arch", tag: null, - home: "/tmp/archiv", - hits: [{ relpath: "a/2026-01-01_0000-x.md", title: "X", snippet: "ein **arch**iv" }], + hits: [{ note_id: "abc123def", title: "X" }], }); describe("initSearchView", () => { @@ -74,19 +73,18 @@ describe("initSearchView", () => { expect(onSearch).toHaveBeenCalledWith("panel arch"); }); - it("rendert Treffer; Klick liefert den absoluten Pfad", () => { + it("rendert Treffer; Klick liefert die noteId", () => { 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("/tmp/archiv/a/2026-01-01_0000-x.md"); + expect(onOpen).toHaveBeenCalledWith("abc123def"); }); it("zeigt „Keine Treffer“ bei leerem Ergebnis", () => { const { view } = setup(); - view.set(JSON.stringify({ query: "nix", tag: "adr", home: "/a", hits: [] })); + view.set(JSON.stringify({ query: "nix", tag: "adr", 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 5299215..5d76b78 100644 --- a/src/search-view.ts +++ b/src/search-view.ts @@ -1,22 +1,20 @@ /// Kachel-Ansicht der Archiv-Suchtreffer: rendert die Suchtreffer-Datei -/// (search_archive im MCP-Server) als klickbare Kacheln — Klick reicht den -/// absoluten Pfad des Treffers an onOpen. Titel/Snippet/Pfad sind Fremdtext und -/// gehen nie durch innerHTML; die `**…**`-Marker im Snippet werden per -/// Split in -Elemente übersetzt. +/// (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. import { t } from "./messages"; import { renderTile } from "./tiles"; interface Hit { - relpath: string; + note_id: string; title: string; - snippet: string; } interface SearchRun { query: string; tag?: string | null; - home: string; hits: Hit[]; } @@ -27,7 +25,7 @@ export interface SearchView { export function initSearchView( container: HTMLElement, - onOpen: (path: string) => void, + onOpen: (noteId: string) => void, onSearch: (query: string) => void, ): SearchView { let count = 0; @@ -41,7 +39,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 - // FTS-Syntax (Phrasen, OR/NOT). + // Trilium-Suchsyntax (Phrasen, Operatoren). let pending: number | undefined; input.addEventListener("input", () => { clearTimeout(pending); @@ -73,21 +71,6 @@ 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"); @@ -105,10 +88,9 @@ export function initSearchView( cls: "hit-tile", parts: [ { cls: "hit-title", text: hit.title }, - snippetEl(hit.snippet), - { cls: "hit-path", text: hit.relpath }, + { cls: "hit-path", text: hit.note_id }, ], - onClick: () => onOpen(`${run.home}/${hit.relpath}`), + onClick: () => onOpen(hit.note_id), }), ); } diff --git a/src/terminal-window.css b/src/terminal-window.css index 705b268..502c5ff 100644 --- a/src/terminal-window.css +++ b/src/terminal-window.css @@ -265,12 +265,6 @@ 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; @@ -279,13 +273,6 @@ 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; @@ -329,118 +316,10 @@ 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-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] { +.panel-btn[hidden] { display: none; } diff --git a/src/terminal.ts b/src/terminal.ts index 43fd649..d537a87 100644 --- a/src/terminal.ts +++ b/src/terminal.ts @@ -15,8 +15,7 @@ import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; import { listen } from "@tauri-apps/api/event"; import { wirePanel } from "./panel-wiring"; import { applyTheme, THEMES } from "./themes"; -import { flash, panelToast } from "./tiles"; -import { initArchiveForm } from "./archive-form"; +import { panelToast } from "./tiles"; import { applyI18n, t } from "./messages"; // Debug-Instrumentierung: Fehler auf der Seite anzeigen und ins Dev-Log spiegeln. @@ -189,10 +188,10 @@ window.addEventListener("resize", () => fit.fit()); await invoke("term_start", { project, rows: term.rows, cols: term.cols }); term.focus(); -// --- 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`). +// --- 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`). const panel = document.getElementById("panel")!; const splitter = document.getElementById("splitter")!; @@ -212,10 +211,20 @@ function hidePanel() { // Gemeinsame Verdrahtung (Views, Modi, Update-Events); jedes eingehende // Update blendet das angedockte Panel ein, solange es nicht abgelöst ist. -const { view, views, mode } = await wirePanel(project, () => { +const { 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"); @@ -265,39 +274,6 @@ document.getElementById("panel-hide")!.addEventListener("click", () => { 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 deleted file mode 100644 index 3eda9ed..0000000 --- a/src/wiki-view.test.ts +++ /dev/null @@ -1,246 +0,0 @@ -// Wiki-Ansicht: Ordner-Baum links, flache Inhaltsliste rechts, Zeilen-Aktionen -// (öffnen, umbenennen, löschen), Ordner-Umbenennen, Leerzustände. -import { describe, expect, it, vi } from "vitest"; -import { initWikiView } from "./wiki-view"; - -function setup() { - document.body.innerHTML = `
`; - const openDoc = vi.fn(); - const openWiki = vi.fn(); - const actions = { - rename: vi.fn(), - remove: vi.fn(), - moveFolder: vi.fn(), - createFolder: vi.fn(), - createDoc: vi.fn(), - }; - const view = initWikiView(document.getElementById("w")!, { - openDoc, - openWiki, - actions, - }); - return { view, openDoc, openWiki, actions }; -} - -const doc = (relpath: string, name: string, extra: object = {}) => ({ - relpath, - name, - title: name.toUpperCase(), - description: null, - tags: [], - date: "2026-07-19", - backlinks: 0, - ...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")], - }, - ], -}); - -describe("initWikiView — Baum und Liste", () => { - it("Baum zeigt nur Ordner, Wurzel ist gewählt und listet ihren Inhalt", () => { - const { view } = setup(); - view.set(page); - // Baum: Wurzel-Knopf + Ordner (keine Dokumentzeilen im Baum). - expect(document.querySelector(".wiki-tree-root")!.className).toContain("active"); - const folders = [...document.querySelectorAll(".wiki-tree .wiki-tree-name")].map( - (e) => e.textContent, - ); - expect(folders).toEqual(["konzepte", "panel"]); - // Liste rechts: nur der direkte Wurzel-Inhalt. - expect(document.querySelector(".wiki-head-title")!.textContent).toBe("Archiv"); - expect(document.querySelector(".wiki-head-right")!.textContent).toBe("1 Dokument"); - const rows = [...document.querySelectorAll(".wiki-doc .wiki-doc-title")].map( - (e) => e.textContent, - ); - expect(rows).toEqual(["WURZEL-DOC"]); - expect(document.querySelector(".wiki-doc-back")!.textContent).toBe("↩ 2"); - expect(document.querySelector(".wiki-doc-desc")!.textContent).toBe("Beschreibung"); - }); - - it("Ordner-Klick wählt aus und zeigt dessen flache Liste", () => { - const { view } = setup(); - view.set(page); - const names = document.querySelectorAll(".wiki-tree .wiki-tree-name"); - names[0].click(); // konzepte - expect(document.querySelector(".wiki-head-title")!.textContent).toBe("konzepte"); - expect(document.querySelector(".wiki-head-sub")!.textContent).toBe( - "/tmp/archiv/konzepte", - ); - const rows = [...document.querySelectorAll(".wiki-doc .wiki-doc-title")].map( - (e) => e.textContent, - ); - // Nur direkter Inhalt — das Dokument aus konzepte/panel erscheint nicht. - expect(rows).toEqual(["NEU"]); - expect( - document.querySelector(".wiki-tree summary.active .wiki-tree-name")!.textContent, - ).toBe("konzepte"); - }); - - it("Dokument-Klick öffnet über den absoluten Pfad", () => { - const { view, openDoc } = setup(); - view.set(page); - document.querySelector(".wiki-doc")!.click(); - expect(openDoc).toHaveBeenCalledWith("/tmp/archiv/2026-07-19_1000-wurzel-doc.md"); - }); - - it("Auswahl übersteht ein Puffer-Update, verschwundener Ordner fällt zur Wurzel", () => { - const { view } = setup(); - view.set(page); - document.querySelectorAll(".wiki-tree .wiki-tree-name")[0].click(); - view.set(page); // Update: Auswahl bleibt - expect(document.querySelector(".wiki-head-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-head-title")!.textContent).toBe("Archiv"); - }); -}); - -describe("initWikiView — Aktionen", () => { - it("Stift an der Zeile: Inline-Umbenennen mit Enter, ohne das Dokument zu öffnen", () => { - const { view, actions, openDoc } = setup(); - view.set(page); - const row = document.querySelector(".wiki-doc")!; - row.querySelector(".cmd-edit")!.click(); - const input = row.querySelector(".wiki-tree-input")!; - expect(input.value).toBe("wurzel-doc"); - input.value = "Neuer Name"; - input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); - expect(actions.rename).toHaveBeenCalledWith( - "2026-07-19_1000-wurzel-doc.md", - "Neuer Name", - ); - expect(openDoc).not.toHaveBeenCalled(); - }); - - it("Escape stellt die Zeile wieder her", () => { - const { view, actions } = setup(); - view.set(page); - document.querySelector(".wiki-doc .cmd-edit")!.click(); - const input = document.querySelector(".wiki-doc .wiki-tree-input")!; - input.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); - expect(document.querySelector(".wiki-doc .wiki-doc-title")!.textContent).toBe( - "WURZEL-DOC", - ); - expect(actions.rename).not.toHaveBeenCalled(); - }); - - it("Löschen meldet mit relpath, ohne das Dokument zu öffnen", () => { - const { view, actions, openDoc } = setup(); - view.set(page); - document.querySelector(".wiki-doc .cmd-del")!.click(); - expect(actions.remove).toHaveBeenCalledWith("2026-07-19_1000-wurzel-doc.md"); - expect(openDoc).not.toHaveBeenCalled(); - }); - - it("Plus im Listenkopf: Formular legt Dokument im gewählten Ordner an", () => { - const { view, actions, openDoc } = setup(); - view.set(page); - document.querySelectorAll(".wiki-tree .wiki-tree-name")[0].click(); - document.querySelector(".wiki-head .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(); - expect(openDoc).not.toHaveBeenCalled(); - }); - - it("Abbrechen und Escape schließen das Formular ohne Aktion", () => { - const { view, actions } = setup(); - view.set(page); - document.querySelector(".wiki-head .wiki-add")!.click(); - document.querySelector(".wiki-form-cancel")!.click(); - expect(document.querySelector(".wiki-form")).toBeNull(); - - document.querySelector(".wiki-head .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("Plus im Baum: Formular legt Ordner an, Pfad mit gewähltem Ordner vorbefüllt", () => { - const { view, actions } = setup(); - view.set(page); - document.querySelectorAll(".wiki-tree .wiki-tree-name")[0].click(); - document.querySelector(".wiki-tree-head .wiki-add")!.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("Ordner-Stift: Inline-Eingabe des Pfads, Enter meldet moveFolder", () => { - const { view, actions } = setup(); - view.set(page); - const summary = document.querySelector(".wiki-tree summary")!; - summary.querySelector(".cmd-edit")!.click(); - const input = summary.querySelector(".wiki-tree-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"); - }); -}); - -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 bietet den Übersichts-Einstieg", () => { - const { view, openWiki } = setup(); - view.set(""); - expect(view.empty()).toBe(true); - document.querySelector(".wiki-empty .wiki-chip")!.click(); - expect(openWiki).toHaveBeenCalledWith("tag:"); - view.set(page); - expect(view.empty()).toBe(false); - }); -}); diff --git a/src/wiki-view.ts b/src/wiki-view.ts deleted file mode 100644 index 4da564f..0000000 --- a/src/wiki-view.ts +++ /dev/null @@ -1,370 +0,0 @@ -/// Wiki-Ansicht des Panels: Ordner-Navigator übers Archiv. Links der Baum — -/// nur Ordner, oberster Knoten ist die Archiv-Wurzel; Klick auf einen Ordner -/// zeigt rechts die flache Liste seines direkten Inhalts. Klick auf eine -/// Dokumentzeile lädt das Dokument in den Dokument-Tab (wie ein -/// Suchtreffer); an der Zeile: umbenennen (inline) und löschen, am -/// Ordnerknoten: umbenennen. Titel, Beschreibungen und Pfade sind Fremdtext -/// und gehen nie durch innerHTML. - -import { t } from "./messages"; -import { deleteAction, editAction } from "./tiles"; - -interface DocEntry { - relpath: string; - name: string; - title: string; - description?: string | null; - tags: string[]; - date?: string | null; - backlinks: number; -} - -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 { - /// Dokument in den Dokument-Tab laden (absoluter Pfad, wie Suchtreffer). - openDoc(path: string): void; - /// Wiki-Ziel laden (`tag:` = Übersicht in den Puffer, Einstiegs-Chip). - openWiki(name: string): void; - actions: WikiActions; -} - -export interface WikiView { - set(text: string): void; - /// Noch keine Seite im Puffer (Session-Start)? - empty(): boolean; -} - -/// Verschachtelter Ordner-Knoten, aus den Ordnerpfaden gebaut. -interface TreeNode { - children: Map; -} - -function buildTree(folders: Folder[]): TreeNode { - const root: TreeNode = { children: new Map() }; - for (const folder of folders) { - if (!folder.name) continue; - let node = root; - for (const part of folder.name.split("/")) { - if (!node.children.has(part)) { - node.children.set(part, { children: new Map() }); - } - node = node.children.get(part)!; - } - } - return root; -} - -export function initWikiView(container: HTMLElement, cb: WikiCallbacks): WikiView { - /// Zugeklappte Ordner und gewählter Ordner ("" = Wurzel) — überleben - /// Puffer-Updates innerhalb der Ansicht. - const closed = new Set(); - let selected = ""; - let current: Page | null = null; - - // ---------- Baum-Spalte (nur Ordner) ---------- - - 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"); - if (full === selected) sum.classList.add("active"); - const label = document.createElement("span"); - label.className = "wiki-tree-name"; - label.textContent = name; - // Name wählt den Ordner aus (ohne zu klappen); der Pfeil davor klappt. - label.addEventListener("click", (e) => { - e.preventDefault(); - select(full); - }); - const rename = editAction(t("wiki.folderRename"), () => { - const input = document.createElement("input"); - input.className = "wiki-tree-input"; - input.value = full; - sum.replaceChildren(input); - input.focus(); - input.select(); - input.addEventListener("keydown", (e) => { - e.stopPropagation(); - if (e.key === "Enter") { - cb.actions.moveFolder(full, input.value.trim()); - } else if (e.key === "Escape") { - sum.replaceChildren(label, rename); - } - }); - input.addEventListener("click", (e) => e.preventDefault()); - }); - rename.addEventListener("click", (e) => { - e.preventDefault(); - e.stopPropagation(); - }); - sum.append(label, rename); - det.append(sum, renderChildren(node, full)); - return det; - } - - function renderChildren(node: TreeNode, path: string): HTMLElement { - const box = document.createElement("div"); - box.className = "wiki-tree-children"; - 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(p: Page): 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("")); - const add = document.createElement("button"); - add.className = "wiki-add"; - add.title = t("wiki.newFolder"); - add.textContent = "+"; - add.addEventListener("click", () => - openForm(t("wiki.newFolder"), t("wiki.folderPath"), selected ? `${selected}/` : "", (v) => - cb.actions.createFolder(v), - ), - ); - head.append(root, add); - aside.append(head, renderChildren(buildTree(p.folders), "")); - return aside; - } - - // ---------- Anlege-Formular (Ordner/Dokument) ---------- - - /// Kleines Formular oben in der Liste: ein Textfeld, Anlegen/Abbrechen; - /// Enter legt an, Escape schließt. Leere oder ungültige Eingaben meldet - /// das Backend als Toast. - function openForm( - title: string, - placeholder: string, - initial: string, - onSubmit: (value: string) => void, - ) { - const main = container.querySelector(".wiki-main")!; - main.querySelector(".wiki-form")?.remove(); - 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 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(); - form.remove(); - onSubmit(value); - }; - submit.addEventListener("click", fire); - cancel.addEventListener("click", () => form.remove()); - form.addEventListener("keydown", (e) => { - if (e.key === "Enter") fire(); - else if (e.key === "Escape") form.remove(); - }); - form.append(caption, input, submit, cancel); - main.querySelector(".wiki-head")!.after(form); - input.focus(); - } - - // ---------- Liste rechts: direkter Ordnerinhalt ---------- - - function docRow(p: Page, doc: DocEntry): HTMLElement { - const row = document.createElement("div"); - row.className = "wiki-doc"; - const line = document.createElement("div"); - line.className = "wiki-doc-line"; - const title = document.createElement("div"); - title.className = "wiki-doc-title"; - title.textContent = doc.title; - line.append(title); - if (doc.backlinks) { - const back = document.createElement("div"); - back.className = "wiki-doc-back"; - back.title = t("wiki.backlinks"); - back.textContent = `↩ ${doc.backlinks}`; - line.append(back); - } - if (doc.date) { - const date = document.createElement("div"); - date.className = "wiki-doc-date"; - date.textContent = doc.date; - line.append(date); - } - const acts = document.createElement("div"); - acts.className = "wiki-doc-actions"; - const rename = editAction(t("wiki.renameDoc"), () => { - const input = document.createElement("input"); - input.className = "wiki-tree-input"; - input.value = doc.name; - row.replaceChildren(input); - input.focus(); - input.select(); - input.addEventListener("keydown", (e) => { - if (e.key === "Enter") { - cb.actions.rename(doc.relpath, input.value); - } else if (e.key === "Escape" && current) { - renderMain(current); - } - }); - input.addEventListener("click", (e) => e.stopPropagation()); - }); - acts.append(rename, deleteAction(t("wiki.deleteDoc"), () => cb.actions.remove(doc.relpath))); - for (const btn of acts.querySelectorAll("button")) { - btn.addEventListener("click", (e) => e.stopPropagation()); - } - line.append(acts); - row.append(line); - if (doc.description) { - const desc = document.createElement("div"); - desc.className = "wiki-doc-desc"; - desc.textContent = doc.description; - row.append(desc); - } - row.addEventListener("click", () => cb.openDoc(`${p.home}/${doc.relpath}`)); - return row; - } - - function renderMain(p: Page) { - const main = container.querySelector(".wiki-main")!; - main.textContent = ""; - const docs = p.folders.find((f) => f.name === selected)?.docs ?? []; - - const head = document.createElement("div"); - head.className = "wiki-head"; - const left = document.createElement("div"); - left.className = "wiki-head-left"; - const title = document.createElement("div"); - title.className = "wiki-head-title"; - title.textContent = selected ? selected.split("/").pop()! : t("wiki.archive"); - const sub = document.createElement("div"); - sub.className = "wiki-head-sub"; - sub.textContent = selected ? `${p.home}/${selected}` : p.home; - left.append(title, sub); - const count = document.createElement("div"); - count.className = "wiki-head-right"; - count.textContent = t(docs.length === 1 ? "wiki.docOne" : "wiki.docMany", { - count: docs.length, - }); - 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), - ), - ); - head.append(left, count, add); - main.append(head); - - if (docs.length === 0) { - 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; - } - for (const doc of docs) main.append(docRow(p, doc)); - } - - function select(folder: string) { - selected = folder; - if (current) render(current); - } - - function render(p: Page) { - container.textContent = ""; - const layout = document.createElement("div"); - layout.className = "wiki-layout"; - const main = document.createElement("div"); - main.className = "wiki-main"; - layout.append(renderTree(p), main); - container.append(layout); - renderMain(p); - } - - /// Leerer Puffer: Einstieg statt leerer Fläche. - function renderIntro() { - const empty = document.createElement("div"); - empty.className = "wiki-empty"; - const line = document.createElement("strong"); - line.textContent = t("wiki.noPage"); - empty.append(line); - const open = document.createElement("button"); - open.className = "wiki-chip"; - open.textContent = t("wiki.openOverview"); - open.addEventListener("click", () => cb.openWiki("tag:")); - empty.append(open); - container.append(empty); - } - - let loaded = false; - return { - set(text: string) { - container.textContent = ""; - loaded = !!text.trim(); - if (!loaded) { - current = null; - renderIntro(); - return; - } - current = JSON.parse(text); - // Der gewählte Ordner kann nach Umbenennen/Löschen weg sein — dann - // zurück zur Wurzel. Zwischenordner (nur Unterordner, keine eigenen - // Dokumente) bleiben wählbar. - const exists = current!.folders.some( - (f) => f.name === selected || f.name.startsWith(`${selected}/`), - ); - if (selected && !exists) { - selected = ""; - } - render(current!); - }, - empty: () => !loaded, - }; -} diff --git a/terminal.html b/terminal.html index 42b1d0c..8683f02 100644 --- a/terminal.html +++ b/terminal.html @@ -27,10 +27,10 @@