Files
ai-control/src/wiki-view.test.ts
T
marcus hinz 9de58d43e8 Archiv als eigenes Fenster: Trilium raus, Notizbaum mit In-Place-Bearbeitung
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.
2026-07-26 14:44:06 +02:00

373 lines
15 KiB
TypeScript

// 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 = `<div id="w"></div>`;
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<HTMLElement>(".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<HTMLElement>(".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<HTMLElement>(".wiki-note-info-pop")!;
expect(pop.hidden).toBe(true);
document.querySelector<HTMLElement>('.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<HTMLElement>(".wiki-tree-doc")!.click(); // WURZEL-DOC
await flush();
document.querySelector<HTMLElement>(".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<HTMLElement>(".wiki-tree-doc")!.click();
await flush();
document.querySelector<HTMLElement>(".wiki-note-actions .panel-btn")!.click();
await flush();
const area = document.querySelector<HTMLTextAreaElement>(".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<HTMLElement>(".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<HTMLElement>(".wiki-tree-doc")!.click();
await flush();
document.querySelector<HTMLElement>(".wiki-note-actions .panel-btn")!.click();
await flush();
document.querySelector<HTMLElement>(".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<HTMLElement>(".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<HTMLElement>(".wiki-tree-doc")!.click(); // WURZEL-DOC
await flush();
document.querySelector<HTMLElement>(".wiki-tree summary .wiki-tree-name")!.click(); // konzepte
expect(document.querySelector(".wiki-note-title")!.textContent).toBe("konzepte");
document.querySelector<HTMLElement>(".wiki-note-back")!.click();
await flush();
expect(document.querySelector(".wiki-note-title")!.textContent).toBe("WURZEL-DOC");
document.querySelector<HTMLElement>(".wiki-note-back")!.click();
expect(document.querySelector(".wiki-note-title")!.textContent).toBe("Archiv");
// Verlauf leer: Knopf deaktiviert.
expect(
document.querySelector<HTMLButtonElement>(".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<HTMLElement>(".wiki-tree-doc")!.click();
await flush();
document.querySelector<HTMLElement>(".wiki-note-title.editable")!.click();
const input = document.querySelector<HTMLInputElement>(".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<HTMLElement>(".wiki-tree-doc")!.click();
await flush();
document.querySelector<HTMLElement>(".wiki-note-title.editable")!.click();
const input = document.querySelector<HTMLInputElement>(".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<HTMLElement>(".wiki-tree-doc")!.click();
await flush();
document.querySelector<HTMLElement>(".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<HTMLElement>(".wiki-tree summary .wiki-tree-name")!.click();
document.querySelector<HTMLElement>(".wiki-note-actions .wiki-add")!.click();
const form = document.querySelector<HTMLElement>(".wiki-form")!;
expect(form.querySelector(".wiki-form-title")!.textContent).toBe("Neues Dokument");
const input = form.querySelector<HTMLInputElement>("input")!;
input.value = "Deploy Notiz";
form.querySelector<HTMLElement>(".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<HTMLElement>(".wiki-note-actions .wiki-add")!.click();
document.querySelector<HTMLElement>(".wiki-form-cancel")!.click();
expect(document.querySelector(".wiki-form")).toBeNull();
document.querySelector<HTMLElement>(".wiki-note-actions .wiki-add")!.click();
document
.querySelector<HTMLElement>(".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<HTMLElement>(".wiki-tree summary")!;
summary.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true }));
const items = [...document.querySelectorAll<HTMLElement>(".wiki-menu-item")];
items.find((i) => i.textContent === "Neuer Ordner")!.click();
const input = document.querySelector<HTMLInputElement>(".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<HTMLElement>(".wiki-tree summary")!;
summary.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true }));
const items = [...document.querySelectorAll<HTMLElement>(".wiki-menu-item")];
items.find((i) => i.textContent === "Ordnerpfad ändern")!.click();
const input = document.querySelector<HTMLInputElement>(".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<HTMLElement>(".wiki-tree-doc")!
.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true }));
const items = [...document.querySelectorAll<HTMLElement>(".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);
});
});