Archiv-Navigator: Baum nur Ordner + flache Inhaltsliste (Zeilen: öffnen/umbenennen/löschen), Ordner/Dokument anlegen (Dateiname ohne Stempel, Datum im Frontmatter), Write-back geöffneter Archiv-Dokumente über Quell-Verknüpfung; Ablösen übergibt aktiven Tab

This commit is contained in:
marcus hinz
2026-07-23 21:17:41 +02:00
parent 1523856ea7
commit f58b9887c7
26 changed files with 1362 additions and 468 deletions
+181 -82
View File
@@ -1,15 +1,29 @@
// Wiki-Ansicht: Übersichts-/Schlagwort-Seiten, Dokumentseite, Leerzustände.
import { beforeEach, describe, expect, it, vi } from "vitest";
// 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 = `<div id="w"></div>`;
const onLink = vi.fn();
const view = initWikiView(document.getElementById("w")!, onLink);
return { view, onLink };
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 = (name: string, extra: object = {}) => ({
const doc = (relpath: string, name: string, extra: object = {}) => ({
relpath,
name,
title: name.toUpperCase(),
description: null,
@@ -24,67 +38,185 @@ const page = JSON.stringify({
home: "/tmp/archiv",
tag: null,
total: 3,
tags: [
{ name: "adr", count: 2 },
{ name: "infra", count: 1 },
],
recent: [doc("neu")],
tags: [],
folders: [
{ name: "", docs: [doc("wurzel-doc", { backlinks: 2, tags: ["adr"] })] },
{ name: "konzepte", docs: [doc("neu"), doc("alt")] },
{
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 — Seiten", () => {
it("rendert Kopf, Chips, Zuletzt- und Ordner-Sektionen", () => {
describe("initWikiView — Baum und Liste", () => {
it("Baum zeigt nur Ordner, Wurzel ist gewählt und listet ihren Inhalt", () => {
const { view } = setup();
view.set(page);
expect(document.querySelector(".wiki-head-title")!.textContent).toBe("Archiv");
expect(document.querySelector(".wiki-head-right")!.textContent).toBe("3 Dokumente");
const chips = [...document.querySelectorAll(".wiki-chips .wiki-chip")].map(
(c) => c.textContent,
);
expect(chips).toEqual(["Alle", "#adr 2", "#infra 1"]);
const eyebrows = [...document.querySelectorAll(".wiki-folder")].map(
// 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(eyebrows).toEqual(["Zuletzt", "Wurzel", "konzepte/"]);
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("Dokumentzeile öffnet das Dokument, Tag-Chip die Tag-Seite", () => {
const { view, onLink } = setup();
view.set(page);
const rows = document.querySelectorAll<HTMLElement>(".wiki-doc");
rows[1].click(); // Wurzel-Dokument (nach der Zuletzt-Zeile)
expect(onLink).toHaveBeenCalledWith("wurzel-doc");
rows[1].querySelector<HTMLElement>(".wiki-doc-tags .wiki-chip")!.click();
expect(onLink).toHaveBeenCalledWith("tag:adr");
document.querySelector<HTMLElement>(".wiki-chips .wiki-chip")!.click();
expect(onLink).toHaveBeenCalledWith("tag:");
});
it("Tag-Seite: Titel, aktiver Chip, Leerfall", () => {
it("Ordner-Klick wählt aus und zeigt dessen flache Liste", () => {
const { view } = setup();
view.set(page);
const names = document.querySelectorAll<HTMLElement>(".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<HTMLElement>(".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<HTMLElement>(".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: "/a",
tag: "adr",
total: 0,
tags: [{ name: "adr", count: 1 }],
recent: [],
folders: [],
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("#adr");
const active = document.querySelector(".wiki-chip.active")!;
expect(active.textContent).toBe("#adr 1");
expect(document.querySelector(".wiki-empty strong")!.textContent).toBe(
"Keine Dokumente mit #adr.",
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<HTMLElement>(".wiki-doc")!;
row.querySelector<HTMLElement>(".cmd-edit")!.click();
const input = row.querySelector<HTMLInputElement>(".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<HTMLElement>(".wiki-doc .cmd-edit")!.click();
const input = document.querySelector<HTMLInputElement>(".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<HTMLElement>(".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<HTMLElement>(".wiki-tree .wiki-tree-name")[0].click();
document.querySelector<HTMLElement>(".wiki-head .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();
expect(openDoc).not.toHaveBeenCalled();
});
it("Abbrechen und Escape schließen das Formular ohne Aktion", () => {
const { view, actions } = setup();
view.set(page);
document.querySelector<HTMLElement>(".wiki-head .wiki-add")!.click();
document.querySelector<HTMLElement>(".wiki-form-cancel")!.click();
expect(document.querySelector(".wiki-form")).toBeNull();
document.querySelector<HTMLElement>(".wiki-head .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("Plus im Baum: Formular legt Ordner an, Pfad mit gewähltem Ordner vorbefüllt", () => {
const { view, actions } = setup();
view.set(page);
document.querySelectorAll<HTMLElement>(".wiki-tree .wiki-tree-name")[0].click();
document.querySelector<HTMLElement>(".wiki-tree-head .wiki-add")!.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("Ordner-Stift: Inline-Eingabe des Pfads, Enter meldet moveFolder", () => {
const { view, actions } = setup();
view.set(page);
const summary = document.querySelector<HTMLElement>(".wiki-tree summary")!;
summary.querySelector<HTMLElement>(".cmd-edit")!.click();
const input = summary.querySelector<HTMLInputElement>(".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(
@@ -94,7 +226,6 @@ describe("initWikiView — Seiten", () => {
tag: null,
total: 0,
tags: [],
recent: [],
folders: [],
}),
);
@@ -102,45 +233,13 @@ describe("initWikiView — Seiten", () => {
"Das Archiv ist leer.",
);
});
});
describe("initWikiView — Dokument und Leerzustand", () => {
beforeEach(() => {
document.body.innerHTML = "";
});
it("rendert Markdown, Rücksprung, Tags und Backlinks", () => {
const { view, onLink } = setup();
view.set(
JSON.stringify({
kind: "doc",
home: "/a",
relpath: "konzepte/2026-07-19_1000-x.md",
name: "x",
title: "X",
tags: ["adr"],
backlinks: ["anderes-doc"],
markdown: "# Überschrift\n\nText mit [[ziel|Label]].\n",
}),
);
expect(document.querySelector(".wiki-body h1")!.textContent).toBe("Überschrift");
const wiki = document.querySelector<HTMLElement>(".wiki-body a.wiki")!;
expect(wiki.textContent).toBe("Label");
wiki.click();
expect(onLink).toHaveBeenCalledWith("ziel");
document.querySelector<HTMLElement>(".wiki-back")!.click();
expect(onLink).toHaveBeenCalledWith("tag:");
const back = document.querySelector<HTMLElement>(".wiki-backlinks a")!;
back.click();
expect(onLink).toHaveBeenCalledWith("anderes-doc");
});
it("leerer Puffer bietet den Übersichts-Einstieg", () => {
const { view, onLink } = setup();
const { view, openWiki } = setup();
view.set("");
expect(view.empty()).toBe(true);
document.querySelector<HTMLElement>(".wiki-empty .wiki-chip")!.click();
expect(onLink).toHaveBeenCalledWith("tag:");
expect(openWiki).toHaveBeenCalledWith("tag:");
view.set(page);
expect(view.empty()).toBe(false);
});