Refinement-Runde Panel/Wiki: Bedienung, Themes, Archiv-Wahl, Frontend-Tests

- Slug transliteriert Umlaute/ß (fuer statt f-r); aktiver Tab mit Akzent +
  Unterstrich; kein aktiver Tab bei zugeklapptem Panel (mode.clear)
- Suche: Live-Update auch beim Löschen (<3 Zeichen: Hinweis), #tag-Filter in
  der Query, Treffer öffnen im Dokument-Tab (panel_load, ohne Frontmatter);
  Wiki-Sprung-Button im Dokument; Wiki-Fehler sichtbar als Toast
- Archiv-Formular am Archiv-Button (Ordner/Beschreibung/Schlagwörter);
  panel_archive_cmd nimmt Meta entgegen
- Wiki-Übersicht: Zuletzt-Sektion, Backlink-Zähler, Wurzel-Eyebrow
- Themes: Panel-Farben über CSS-Variablen, applyTheme leitet sie aus dem
  jeweiligen Theme ab (auch helle); gemeinsames themes.ts
- Fenster: Doppelklick maximiert; gelöstes Fenster merkt Größe/Position
  (Window-ACL: set-position/outer-position/inner-size ergänzt);
  open_panel_window async (GTK-Deadlock-Vorgabe); Panel-Fenster spiegelt
  JS-Fehler ins Dev-Log
- Archiv pro Projekt wähl-/abwählbar (Einstellungsdialog, setzt/entfernt
  Permissions); require_archive_home legt den Ordner an; ohne Archiv nur
  Befehle+Dokument im Panel
- Frontend-Tests: vitest + happy-dom, 27 Tests über Modus-, Such-, Wiki-,
  Editor- und Formular-Logik (npm test)
This commit is contained in:
marcus hinz
2026-07-19 17:08:05 +02:00
parent 81a663e020
commit 286513def8
27 changed files with 1653 additions and 190 deletions
+54
View File
@@ -0,0 +1,54 @@
// Archiv-Formular: Aufklappen, Meta-Aufbereitung, Tastatur.
import { beforeEach, describe, expect, it, vi } from "vitest";
import { initArchiveForm } from "./archive-form";
function setup() {
document.body.innerHTML = `<button id="btn">Archiv</button>`;
const onSubmit = vi.fn();
const form = initArchiveForm(document.getElementById("btn")!, onSubmit);
const root = document.querySelector<HTMLElement>(".archive-form")!;
const inputs = [...root.querySelectorAll("input")];
return { form, onSubmit, root, inputs };
}
describe("initArchiveForm", () => {
beforeEach(() => {
document.body.innerHTML = "";
});
it("klappt auf und zu", () => {
const { form, root } = setup();
expect(root.hidden).toBe(true);
form.toggle();
expect(root.hidden).toBe(false);
form.toggle();
expect(root.hidden).toBe(true);
});
it("liefert getrimmte Meta; Leeres wird undefined, Tags gesplittet", () => {
const { form, onSubmit, root, inputs } = setup();
form.toggle();
inputs[0].value = " konzepte/panel ";
inputs[1].value = "";
inputs[2].value = " adr, infra ,, ";
root.querySelector<HTMLElement>(".archive-form-submit")!.click();
expect(onSubmit).toHaveBeenCalledWith({
folder: "konzepte/panel",
description: undefined,
tags: ["adr", "infra"],
});
expect(root.hidden).toBe(true);
});
it("Enter schickt ab, Escape schließt ohne Abschicken", () => {
const { form, onSubmit, root } = setup();
form.toggle();
root.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
expect(root.hidden).toBe(true);
expect(onSubmit).not.toHaveBeenCalled();
form.toggle();
root.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
expect(onSubmit).toHaveBeenCalledOnce();
expect(root.hidden).toBe(true);
});
});
+68
View File
@@ -0,0 +1,68 @@
/// Archiv-Formular: klappt unter dem Archiv-Button auf und fragt Ordner,
/// Beschreibung und Schlagwörter ab (alles optional); Enter oder der Button
/// archivieren, Escape schließt. Gemeinsam für das angedockte Panel und das
/// abgelöste Fenster.
export interface ArchiveFormMeta {
folder?: string;
description?: string;
tags: string[];
}
export function initArchiveForm(
anchor: HTMLElement,
onSubmit: (meta: ArchiveFormMeta) => void,
): { toggle(): void } {
function field(placeholder: string): HTMLInputElement {
const i = document.createElement("input");
i.type = "text";
i.placeholder = placeholder;
return i;
}
const form = document.createElement("div");
form.className = "archive-form";
form.hidden = true;
const folder = field("Ordner — z. B. konzepte/panel");
const desc = field("Beschreibung");
const tags = field("Schlagwörter, kommagetrennt");
const submit = document.createElement("button");
submit.className = "archive-form-submit";
submit.textContent = "Archivieren";
form.append(folder, desc, tags, submit);
document.body.append(form);
const meta = (): ArchiveFormMeta => ({
folder: folder.value.trim() || undefined,
description: desc.value.trim() || undefined,
tags: tags.value
.split(",")
.map((t) => t.trim())
.filter(Boolean),
});
const close = () => {
form.hidden = true;
};
const open = () => {
const r = anchor.getBoundingClientRect();
form.style.top = `${r.bottom + 6}px`;
form.style.right = `${Math.max(8, window.innerWidth - r.right - 4)}px`;
form.hidden = false;
folder.focus();
};
const fire = () => {
onSubmit(meta());
close();
};
submit.addEventListener("click", fire);
form.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
fire();
} else if (e.key === "Escape") {
close();
}
});
return { toggle: () => (form.hidden ? open() : close()) };
}
+105
View File
@@ -0,0 +1,105 @@
// Modus-Logik (Tabs) und Kachel-Ansicht der Befehls-History.
import { beforeEach, describe, expect, it, vi } from "vitest";
import { initCommandsView, initPanelMode } from "./commands-view";
function modeSetup() {
document.body.innerHTML = `
<div id="tabs">
<button data-mode="commands">Befehle</button>
<button data-mode="draft">Dokument</button>
<button data-mode="wiki">Wiki</button>
<button data-mode="search">Suche</button>
</div>
<div id="title">Mein Titel</div>
<div id="draft-el"></div>
<div id="cc" hidden></div>
<div id="sc" hidden></div>
<div id="wc" hidden></div>`;
const el = (id: string) => document.getElementById(id)!;
const flush = vi.fn();
const mode = initPanelMode({
tabsEl: el("tabs"),
draftEls: [el("draft-el")],
commandsContent: el("cc"),
searchContent: el("sc"),
wikiContent: el("wc"),
titleEl: el("title"),
flush,
});
const tab = (m: string) =>
document.querySelector<HTMLElement>(`[data-mode="${m}"]`)!;
return { mode, flush, el, tab };
}
describe("initPanelMode", () => {
it("startet im Dokument-Modus mit aktivem Tab", () => {
const { el, tab } = modeSetup();
expect(el("draft-el").hidden).toBe(false);
expect(el("cc").hidden).toBe(true);
expect(tab("draft").classList.contains("active")).toBe(true);
});
it("wechselt Sichtbarkeit, Titel und aktive Markierung", () => {
const { mode, flush, el, tab } = modeSetup();
mode.to("commands");
expect(flush).toHaveBeenCalledOnce();
expect(el("cc").hidden).toBe(false);
expect(el("draft-el").hidden).toBe(true);
expect(el("title").textContent).toBe("Befehle");
expect(tab("commands").classList.contains("active")).toBe(true);
expect(tab("draft").classList.contains("active")).toBe(false);
mode.to("draft");
expect(el("title").textContent).toBe("Mein Titel");
});
it("Tab-Klick wechselt den Modus", () => {
const { el, tab } = modeSetup();
tab("wiki").click();
expect(el("wc").hidden).toBe(false);
expect(el("draft-el").hidden).toBe(true);
});
it("clear hebt die Auswahl auf; danach wählt to() wieder aus", () => {
const { mode, el, tab } = modeSetup();
mode.clear();
expect(document.querySelectorAll(".active").length).toBe(0);
expect(mode.current()).toBe(null);
mode.to("draft");
expect(tab("draft").classList.contains("active")).toBe(true);
expect(el("title").textContent).toBe("Mein Titel");
});
});
describe("initCommandsView", () => {
beforeEach(() => {
document.body.innerHTML = `<div id="c"></div>`;
});
const jsonl = [
JSON.stringify({ ts: 1700000000, session: true }),
JSON.stringify({
ts: 1700000100,
commands: [{ cmd: "ls -la", note: "Liste" }, { cmd: "pwd" }],
}),
].join("\n");
it("rendert Kacheln, Session-Trenner und meldet empty korrekt", () => {
const view = initCommandsView(document.getElementById("c")!, () => {});
view.set(jsonl);
expect(view.empty()).toBe(false);
expect(document.querySelectorAll(".cmd-tile").length).toBe(2);
expect(document.querySelectorAll(".cmd-sep").length).toBe(1);
expect(document.querySelector(".cmd-text")!.textContent).toBe("ls -la");
view.set("");
expect(view.empty()).toBe(true);
});
it("meldet Löschen mit Record- und Eintrags-Index", () => {
const onDelete = vi.fn();
const view = initCommandsView(document.getElementById("c")!, onDelete);
view.set(jsonl);
const dels = document.querySelectorAll<HTMLElement>(".cmd-del");
dels[1].click(); // zweite Kachel im Record 1
expect(onDelete).toHaveBeenCalledWith(1, 1);
});
});
+12 -3
View File
@@ -171,7 +171,8 @@ export function initPanelMode(opts: {
titleEl: HTMLElement;
flush: () => void;
}) {
let mode: PanelMode = "draft";
// `null` = kein Tab aktiv (Panel zugeklappt).
let mode: PanelMode | null = "draft";
let draftTitle = "";
const tabs = [...opts.tabsEl.querySelectorAll<HTMLElement>("[data-mode]")];
@@ -181,7 +182,7 @@ export function initPanelMode(opts: {
opts.wikiContent.hidden = mode !== "wiki";
for (const el of opts.draftEls) el.hidden = mode !== "draft";
for (const t of tabs) t.classList.toggle("active", t.dataset.mode === mode);
if (mode !== "draft") opts.titleEl.textContent = LABEL[mode];
if (mode && mode !== "draft") opts.titleEl.textContent = LABEL[mode];
}
function to(m: PanelMode) {
@@ -193,7 +194,15 @@ export function initPanelMode(opts: {
if (m === "draft") opts.titleEl.textContent = draftTitle;
}
/// Auswahl aufheben (Panel zugeklappt) — der nächste to()-Aufruf oder
/// Tab-Klick wählt wieder aus.
function clear() {
if (mode === "draft") draftTitle = opts.titleEl.textContent || "Dokument";
mode = null;
for (const t of tabs) t.classList.remove("active");
}
for (const t of tabs) t.addEventListener("click", () => to(t.dataset.mode as PanelMode));
apply();
return { to, current: () => mode };
return { to, clear, current: () => mode };
}
+42
View File
@@ -158,6 +158,7 @@ interface TerminalSettings {
title: string;
todo: boolean;
workDirs: string[];
archiveHome: string | null;
}
const settings = ref<TerminalSettings | null>(null);
@@ -168,6 +169,9 @@ async function openSettings(p: Project) {
const workDirs = await invoke<string[]>("project_work_dirs", {
project: p.name,
});
const archiveHome = await invoke<string | null>("panel_archive_dir_cmd", {
project: p.name,
});
settings.value = {
name: p.name,
path: p.path,
@@ -176,6 +180,7 @@ async function openSettings(p: Project) {
title: p.terminal.title ?? "",
todo,
workDirs,
archiveHome,
};
} catch (e) {
error.value = String(e);
@@ -222,6 +227,33 @@ async function removeWorkDir(dir: string) {
}
}
// Archiv-Home schreibt wie die Arbeitsordner direkt (Config + Permissions);
// ohne Archiv zeigt das Panel nur Befehle und Dokument. Greift ab dem
// nächsten Session-Start.
async function chooseArchive() {
const s = settings.value!;
const dir = await open({ directory: true, multiple: false });
if (typeof dir !== "string") return;
try {
await invoke("set_archive_home_cmd", { project: s.name, dir });
s.archiveHome = await invoke<string | null>("panel_archive_dir_cmd", {
project: s.name,
});
} catch (e) {
error.value = String(e);
}
}
async function clearArchive() {
const s = settings.value!;
try {
await invoke("clear_archive_home_cmd", { project: s.name });
s.archiveHome = null;
} catch (e) {
error.value = String(e);
}
}
async function pickIcon() {
const file = await open({
multiple: false,
@@ -649,6 +681,16 @@ onUnmounted(() => window.clearInterval(timer));
<button @click="addWorkDir">{{ $t("projects.addWorkDir") }}</button>
</span>
</label>
<label class="field">
{{ $t("projects.archive") }}
<span class="icon-row">
<span class="icon-path">{{ settings.archiveHome ?? $t("projects.archiveNone") }}</span>
<button @click="chooseArchive">{{ $t("projects.changeDir") }}</button>
<button v-if="settings.archiveHome" @click="clearArchive">
{{ $t("projects.remove") }}
</button>
</span>
</label>
<label class="field">
{{ $t("projects.todo") }}
<span class="checkline">
+4
View File
@@ -54,6 +54,8 @@ const de = {
addFolder: "Projekt importieren …",
addFolderTitle: "Bestehenden Ordner als Projekt aufnehmen",
addWorkDir: "Ordner hinzufügen …",
archive: "Archiv",
archiveNone: "kein Archiv — Panel ohne Wiki und Suche",
changeDir: "Ändern …",
wizardTitle: "Neues Projekt",
name: "Name",
@@ -178,6 +180,8 @@ const en: typeof de = {
addFolder: "Import project …",
addFolderTitle: "Add an existing folder as a project",
addWorkDir: "Add folder …",
archive: "Archive",
archiveNone: "no archive — panel without wiki and search",
changeDir: "Change …",
wizardTitle: "New project",
name: "Name",
+120 -58
View File
@@ -12,14 +12,14 @@
width: 8px;
}
#commands-content::-webkit-scrollbar-thumb {
background: #313244;
background: var(--line);
border-radius: 4px;
}
.cmd-sep {
margin: 16px 0 8px;
padding-top: 8px;
border-top: 1px solid #313244;
color: #6c7086;
border-top: 1px solid var(--line);
color: var(--faint);
font:
500 11px/1 -apple-system,
system-ui,
@@ -34,7 +34,7 @@
display: flex;
align-items: center;
justify-content: space-between;
color: #6c7086;
color: var(--faint);
font:
500 11px/1 -apple-system,
system-ui,
@@ -45,27 +45,27 @@
.cmd-all {
border: none;
background: transparent;
color: #a6adc8;
color: var(--muted);
font: inherit;
border-radius: 6px;
padding: 4px 6px;
}
.cmd-all:hover {
background: #313244;
color: #cdd6f4;
background: var(--line);
color: var(--text);
}
.cmd-all.copied {
color: #a6e3a1;
color: var(--ok);
}
.cmd-del:hover {
color: #f38ba8;
color: var(--err);
}
.cmd-tile {
display: flex;
align-items: flex-start;
gap: 6px;
background: #11111b;
border: 1px solid #313244;
background: var(--tile);
border: 1px solid var(--line);
border-radius: 8px;
padding: 8px 6px 8px 10px;
margin-top: 6px;
@@ -86,7 +86,7 @@
}
.cmd-note {
margin-top: 4px;
color: #a6adc8;
color: var(--muted);
font:
400 11px/1.4 -apple-system,
system-ui,
@@ -104,11 +104,11 @@
.hit-search input {
width: 100%;
box-sizing: border-box;
background: #11111b;
border: 1px solid #313244;
background: var(--tile);
border: 1px solid var(--line);
border-radius: 6px;
padding: 6px 10px;
color: #cdd6f4;
color: var(--text);
font:
400 12px/1.4 -apple-system,
system-ui,
@@ -116,20 +116,20 @@
outline: none;
}
.hit-search input:focus {
border-color: #45475a;
border-color: var(--line-strong);
}
.hit-search input::placeholder {
color: #6c7086;
color: var(--faint);
}
#search-content::-webkit-scrollbar {
width: 8px;
}
#search-content::-webkit-scrollbar-thumb {
background: #313244;
background: var(--line);
border-radius: 4px;
}
.hit-head {
color: #6c7086;
color: var(--faint);
font:
500 11px/1 -apple-system,
system-ui,
@@ -138,15 +138,15 @@
-webkit-user-select: none;
}
.hit-tile {
background: #11111b;
border: 1px solid #313244;
background: var(--tile);
border: 1px solid var(--line);
border-radius: 8px;
padding: 8px 10px;
margin-top: 6px;
cursor: pointer;
}
.hit-tile:hover {
border-color: #45475a;
border-color: var(--line-strong);
}
.hit-title {
font:
@@ -156,7 +156,7 @@
}
.hit-snippet {
margin-top: 4px;
color: #a6adc8;
color: var(--muted);
font:
400 11px/1.5 -apple-system,
system-ui,
@@ -164,12 +164,12 @@
}
.hit-snippet mark {
background: transparent;
color: #f9e2af;
color: var(--warn);
font-weight: 600;
}
.hit-path {
margin-top: 4px;
color: #6c7086;
color: var(--faint);
font-family: "JetBrains Mono", Menlo, monospace;
font-size: 10px;
}
@@ -185,7 +185,7 @@
width: 8px;
}
#wiki-content::-webkit-scrollbar-thumb {
background: #313244;
background: var(--line);
border-radius: 4px;
}
.wiki-head {
@@ -198,7 +198,7 @@
min-width: 0;
}
.wiki-head-title {
color: #cdd6f4;
color: var(--text);
font:
600 13px/1.3 -apple-system,
system-ui,
@@ -206,7 +206,7 @@
}
.wiki-head-sub {
margin-top: 2px;
color: #6c7086;
color: var(--faint);
font-family: "JetBrains Mono", Menlo, monospace;
font-size: 10px;
overflow: hidden;
@@ -214,7 +214,7 @@
white-space: nowrap;
}
.wiki-head-right {
color: #6c7086;
color: var(--faint);
font:
500 11px/1 -apple-system,
system-ui,
@@ -228,9 +228,9 @@
margin: 10px 0 4px;
}
.wiki-chip {
border: 1px solid #313244;
border: 1px solid var(--line);
background: transparent;
color: #a6adc8;
color: var(--muted);
border-radius: 999px;
padding: 2px 8px;
font:
@@ -239,19 +239,19 @@
sans-serif;
}
.wiki-chip:hover {
background: #313244;
color: #cdd6f4;
background: var(--line);
color: var(--text);
}
.wiki-chip.active {
background: #313244;
border-color: #45475a;
color: #89b4fa;
background: var(--line);
border-color: var(--line-strong);
color: var(--accent);
}
.wiki-folder {
margin: 14px 0 2px;
padding-top: 10px;
border-top: 1px solid #313244;
color: #6c7086;
border-top: 1px solid var(--line);
color: var(--faint);
font:
500 10px/1 -apple-system,
system-ui,
@@ -268,7 +268,7 @@
cursor: pointer;
}
.wiki-doc:hover {
background: #11111b;
background: var(--tile);
}
.wiki-doc-line {
display: flex;
@@ -278,20 +278,20 @@
.wiki-doc-title {
flex: 1;
min-width: 0;
color: #89b4fa;
color: var(--accent);
font:
600 12px/1.4 -apple-system,
system-ui,
sans-serif;
}
.wiki-doc-date {
color: #6c7086;
color: var(--faint);
font-family: "JetBrains Mono", Menlo, monospace;
font-size: 10px;
}
.wiki-doc-desc {
margin-top: 2px;
color: #a6adc8;
color: var(--muted);
font:
400 11px/1.5 -apple-system,
system-ui,
@@ -305,7 +305,7 @@
}
.wiki-empty {
margin-top: 18px;
color: #a6adc8;
color: var(--muted);
font:
400 12px/1.6 -apple-system,
system-ui,
@@ -314,7 +314,7 @@
.wiki-empty strong {
display: block;
margin-bottom: 6px;
color: #cdd6f4;
color: var(--text);
font-weight: 600;
}
.wiki-doc-head {
@@ -327,7 +327,7 @@
.wiki-back {
border: none;
background: transparent;
color: #a6adc8;
color: var(--muted);
font:
500 11px/1 -apple-system,
system-ui,
@@ -336,8 +336,8 @@
padding: 4px 6px;
}
.wiki-back:hover {
background: #313244;
color: #cdd6f4;
background: var(--line);
color: var(--text);
}
.wiki-body {
font-size: 12.5px;
@@ -374,12 +374,12 @@
.wiki-body code {
font-family: "JetBrains Mono", Menlo, monospace;
font-size: 0.9em;
background: #313244;
background: var(--line);
border-radius: 4px;
padding: 1px 5px;
}
.wiki-body pre {
background: #11111b;
background: var(--tile);
border-radius: 8px;
padding: 12px 14px;
overflow: auto;
@@ -389,28 +389,90 @@
padding: 0;
}
.wiki-body blockquote {
border-left: 3px solid #313244;
border-left: 3px solid var(--line);
padding-left: 12px;
color: #a6adc8;
color: var(--muted);
}
.wiki-body a {
color: #89b4fa;
color: var(--accent);
}
.wiki-backlinks {
margin-top: 14px;
padding-top: 8px;
border-top: 1px solid #313244;
color: #6c7086;
border-top: 1px solid var(--line);
color: var(--faint);
font:
500 11px/1.6 -apple-system,
system-ui,
sans-serif;
}
.wiki-backlinks a {
color: #89b4fa;
color: var(--accent);
text-decoration: none;
}
/* 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;
@@ -418,9 +480,9 @@
right: 12px;
z-index: 10;
max-width: 320px;
background: #11111b;
border: 1px solid #f38ba8;
color: #f38ba8;
background: var(--tile);
border: 1px solid var(--err);
color: var(--err);
border-radius: 8px;
padding: 8px 12px;
font:
+68
View File
@@ -0,0 +1,68 @@
// Dokument-Ansicht: Wikilink-Verlinkung im DOM und Editor-Flush.
import { describe, expect, it, vi } from "vitest";
import { initPanelView, linkWikiRefs } from "./panel-view";
describe("linkWikiRefs", () => {
it("verlinkt [[ziel]] und [[ziel|Label]], Code bleibt unangetastet", () => {
const root = document.createElement("div");
root.innerHTML =
"<p>Siehe [[adr-logging]] und [[notiz|die Notiz]].</p>" +
"<pre><code>if [[ -f x ]]; then</code></pre>";
const onClick = vi.fn();
linkWikiRefs(root, onClick);
const links = [...root.querySelectorAll<HTMLElement>("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<void>) {
document.body.innerHTML = `
<div id="content"></div>
<button id="copy"></button>
<button id="mode"></button>
<button id="edit"></button>`;
const view = initPanelView({
content: document.getElementById("content")!,
copyBtn: document.getElementById("copy")!,
modeBtn: document.getElementById("mode")!,
editContentBtn: document.getElementById("edit")!,
onCommit,
});
const editor = document.querySelector<HTMLTextAreaElement>(".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");
});
});
+42 -9
View File
@@ -21,7 +21,7 @@ import "./panel-tiles.css";
export interface PanelWiring {
view: PanelView;
cmdView: CommandsView;
mode: { to(m: PanelMode): void; current(): PanelMode };
mode: { to(m: PanelMode): void; clear(): void; current(): PanelMode | null };
/// Entwurfstext beim Start (für die Anfangs-Modus-Entscheidung).
draft: string;
}
@@ -31,17 +31,37 @@ export async function wirePanel(
onIncoming?: () => void,
): Promise<PanelWiring> {
const titleEl = document.querySelector(".panel-title") as HTMLElement;
const [defaultLang, draft, cmds, search, wiki] = await Promise.all([
const [defaultLang, draft, cmds, search, wiki, archiveHome] = await Promise.all([
invoke<string>("spellcheck_lang"),
invoke<string>("panel_read", { project }),
invoke<string>("commands_read", { project }),
invoke<string>("search_read", { project }),
invoke<string>("wiki_read", { project }),
invoke<string | null>("panel_archive_dir_cmd", { project }),
]);
// Jeder Wiki-Sprung (Wikilink, Chip, Suchtreffer) geht als ein Invoke an den
// Kern; das Ergebnis kommt über den Wiki-Puffer und `wiki-update` zurück.
const openWiki = (name: string) => invoke("wiki_open", { project, name });
// Ohne Archiv (in den Projekt-Einstellungen abgewählt) gibt es nur Befehle
// und Dokument — Wiki-/Suche-Tabs und die Archiv-Werkzeuge verschwinden.
if (!archiveHome) {
for (const sel of [
'#panel-tabs [data-mode="wiki"]',
'#panel-tabs [data-mode="search"]',
"#panel-archive",
"#panel-wiki-jump",
]) {
const el = document.querySelector<HTMLElement>(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) =>
invoke("wiki_open", { project, name }).catch((e) => panelToast(String(e)));
const view = initPanelView({
content: document.getElementById("panel-content")!,
@@ -58,13 +78,21 @@ export async function wirePanel(
const cmdView = initCommandsView(document.getElementById("commands-content")!, (line, entry) =>
invoke("commands_delete", { project, line, entry }),
);
// Treffer-Klick lädt das Dokument in den Dokument-Tab (dort editier- und
// archivierbar); der Sprung ins Wiki geht von dort aus.
const searchView = initSearchView(
document.getElementById("search-content")!,
(relpath) => openWiki(relpath.split("/").pop()!.replace(/\.md$/, "")),
(query) =>
void invoke("search_run", { project, query }).catch((e) =>
(path) =>
void invoke("panel_load", { project, path }).catch((e) => panelToast(String(e))),
(raw) => {
// `#tag`-Tokens filtern aufs Schlagwort, der Rest ist die Volltext-Query.
const words = raw.split(/\s+/).filter(Boolean);
const tag = words.find((w) => w.startsWith("#"))?.slice(1) ?? null;
const query = words.filter((w) => !w.startsWith("#")).join(" ");
void invoke("search_run", { project, query, tag }).catch((e) =>
panelToast(`Suche fehlgeschlagen: ${e}`),
),
);
},
);
const wikiView = initWikiView(document.getElementById("wiki-content")!, openWiki);
const mode = initPanelMode({
@@ -92,6 +120,11 @@ export async function wirePanel(
if (wikiView.empty()) void openWiki("tag:");
});
// Sprung Dokument → Wiki: löst den angezeigten Titel gegen das Archiv auf.
document
.getElementById("panel-wiki-jump")!
.addEventListener("click", () => void openWiki(titleEl.textContent || ""));
await listen<string>("panel-update", (e) => {
view.set(e.payload);
mode.to("draft");
+65 -12
View File
@@ -2,10 +2,12 @@ import "@fontsource/jetbrains-mono/400.css";
import "@fontsource/jetbrains-mono/500.css";
import { invoke } from "@tauri-apps/api/core";
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
import { PhysicalPosition, PhysicalSize } from "@tauri-apps/api/dpi";
import { emit } from "@tauri-apps/api/event";
import { wirePanel } from "./panel-wiring";
import { flash, panelToast } from "./commands-view";
import { THEMES } from "./themes";
import { initArchiveForm } from "./archive-form";
import { applyTheme, THEMES } from "./themes";
// Abgelöstes Panel-Fenster: liest den aktuellen Entwurf einmal ein und folgt
// danach denselben Update-Events wie das angedockte Panel; startet in
@@ -13,6 +15,23 @@ import { THEMES } from "./themes";
const project = new URLSearchParams(location.search).get("project")!;
const win = getCurrentWebviewWindow();
// Debug-Instrumentierung wie im Terminal-Fenster: Fehler auf der Seite
// anzeigen und ins Dev-Log spiegeln — ein toter Start ist sonst unsichtbar.
function showError(msg: string) {
const el = document.createElement("pre");
el.style.cssText =
"color:#f38ba8;padding:16px;font:12px Menlo,monospace;white-space:pre-wrap";
el.textContent = msg;
document.body.appendChild(el);
invoke("term_log", { msg: `panel: ${msg}` });
}
window.addEventListener("error", (e) =>
showError(`error: ${e.message}\n${e.error?.stack ?? ""}`),
);
window.addEventListener("unhandledrejection", (e) =>
showError(`rejection: ${e.reason}\n${e.reason?.stack ?? ""}`),
);
// Dekorationsloses Fenster (Linux): Plattform-Flag + Resize-Zonen wie im
// Terminal-Fenster; macOS behält die native Deko.
const isMac = /Mac|Macintosh/.test(navigator.userAgent);
@@ -32,6 +51,37 @@ if (!isMac) {
}
}
// Doppelklick auf die Kopfleiste maximiert (Fenster-Konvention).
document.querySelector(".panel-topbar")!.addEventListener("dblclick", (e) => {
if ((e.target as HTMLElement).hasAttribute("data-tauri-drag-region")) {
win.toggleMaximize();
}
});
// Fenstergeometrie merken und beim nächsten Öffnen wiederherstellen; unter
// Wayland vergibt der Compositor die Position, dann greift nur die Größe.
const GEO_KEY = `panel-geometry:${project}`;
const savedGeo = localStorage.getItem(GEO_KEY);
if (savedGeo) {
const g = JSON.parse(savedGeo);
await win.setSize(new PhysicalSize(g.w, g.h));
await win.setPosition(new PhysicalPosition(g.x, g.y));
}
let geoTimer: number | undefined;
const saveGeo = () => {
clearTimeout(geoTimer);
geoTimer = window.setTimeout(async () => {
const pos = await win.outerPosition();
const size = await win.innerSize();
localStorage.setItem(
GEO_KEY,
JSON.stringify({ x: pos.x, y: pos.y, w: size.width, h: size.height }),
);
}, 300);
};
await win.onMoved(saveGeo);
await win.onResized(saveGeo);
// Farben ans Theme koppeln — derselbe Look wie das angedockte Panel im
// Terminal-Fenster (die CSS-Defaults sind Mocha).
interface Project {
@@ -40,23 +90,19 @@ interface Project {
}
const projects = await invoke<Project[]>("list_projects");
const picked = THEMES[projects.find((p) => p.name === project)?.terminal.theme ?? "mocha"];
applyTheme(picked);
// Fensterhintergrund des Panel-Fensters ist die Kopf-Fläche, nicht das
// Terminal-Dunkel.
document.documentElement.style.background = picked.header;
document.body.style.background = picked.header;
document.body.style.color = picked.xterm.foreground;
document.body.style.borderColor = picked.border;
const topbar = document.querySelector(".panel-topbar") as HTMLElement;
topbar.style.background = picked.header;
topbar.style.borderBottomColor = picked.border;
const head = document.querySelector(".panel-head") as HTMLElement;
head.style.background = picked.header;
head.style.borderBottomColor = picked.border;
const { view, cmdView, mode, draft } = await wirePanel(project);
if (!draft.trim() && !cmdView.empty()) mode.to("commands");
// Archivieren: wie im angedockten Panel — Ordner nehmen oder per Dialog wählen.
// Archivieren: wie im angedockten Panel — Formular aufklappen, Abschicken
// wählt notfalls erst das Archiv-Home per Dialog.
const archiveBtn = document.getElementById("panel-archive")!;
archiveBtn.addEventListener("click", async () => {
const archiveForm = initArchiveForm(archiveBtn, async (meta) => {
try {
const configured = await invoke<string | null>("panel_archive_dir_cmd", {
project,
@@ -69,7 +115,13 @@ archiveBtn.addEventListener("click", async () => {
dir = chosen as string;
}
await view.flush(); // offene Bearbeitung erst speichern — archiviert, was zu sehen ist
const path = await invoke<string>("panel_archive_cmd", { project, dir });
const path = await invoke<string>("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) {
@@ -77,6 +129,7 @@ archiveBtn.addEventListener("click", async () => {
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 () => {
+101
View File
@@ -0,0 +1,101 @@
// Suchfeld-Logik (Live-Suche, Debounce, Schwelle) und Treffer-Kacheln.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { initSearchView } from "./search-view";
function setup() {
document.body.innerHTML = `<div id="s"></div>`;
const onOpen = vi.fn();
const onSearch = vi.fn();
const view = initSearchView(document.getElementById("s")!, onOpen, onSearch);
const input = document.querySelector<HTMLInputElement>(".hit-search input")!;
const type = (v: string) => {
input.value = v;
input.dispatchEvent(new Event("input"));
};
const enter = () =>
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter" }));
return { view, onOpen, onSearch, input, type, enter };
}
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" }],
});
describe("initSearchView", () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it("sucht ab 3 Zeichen nach 300 ms mit Präfix-Stern", () => {
const { onSearch, type } = setup();
type("arch");
expect(onSearch).not.toHaveBeenCalled();
vi.advanceTimersByTime(300);
expect(onSearch).toHaveBeenCalledWith("arch*");
});
it("hängt an #tag-Tokens keinen Stern an", () => {
const { onSearch, type } = setup();
type("panel #adr");
vi.advanceTimersByTime(300);
expect(onSearch).toHaveBeenCalledWith("panel #adr");
});
it("entprellt: nur die letzte Eingabe zählt", () => {
const { onSearch, type } = setup();
type("arc");
vi.advanceTimersByTime(200);
type("arch");
vi.advanceTimersByTime(300);
expect(onSearch).toHaveBeenCalledOnce();
expect(onSearch).toHaveBeenCalledWith("arch*");
});
it("räumt beim Löschen unter 3 Zeichen auf und zeigt den Hinweis", () => {
const { view, onSearch, type } = setup();
view.set(run);
expect(document.querySelectorAll(".hit-tile").length).toBe(1);
type("ar");
expect(onSearch).not.toHaveBeenCalled();
expect(document.querySelectorAll(".hit-tile").length).toBe(0);
expect(document.querySelector(".hit-head")!.textContent).toContain(
"Mindestens 3 Zeichen",
);
type("");
expect(document.querySelector(".hit-head")).toBe(null);
});
it("Enter sucht sofort und wörtlich", () => {
const { onSearch, type, enter } = setup();
type("panel arch");
enter();
expect(onSearch).toHaveBeenCalledWith("panel arch");
});
it("rendert Treffer; Klick liefert den absoluten Pfad", () => {
const { view, onOpen } = setup();
view.set(run);
const tile = document.querySelector<HTMLElement>(".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");
});
it("zeigt „Keine Treffer“ bei leerem Ergebnis", () => {
const { view } = setup();
view.set(JSON.stringify({ query: "nix", tag: "adr", home: "/a", hits: [] }));
expect(document.querySelector(".hit-head")!.textContent).toBe(
"Keine Treffer für „nix“ · #adr",
);
expect(view.empty()).toBe(true);
});
it("übernimmt die Query eines fremden Suchlaufs ins Feld", () => {
const { view, input } = setup();
view.set(run);
expect(input.value).toBe("arch");
});
});
+26 -9
View File
@@ -1,6 +1,6 @@
/// Kachel-Ansicht der Archiv-Suchtreffer: rendert die Suchtreffer-Datei
/// (search_archive im MCP-Server) als klickbare Kacheln — Klick reicht den
/// relpath des Treffers an onOpen. Titel/Snippet/Pfad sind Fremdtext und
/// absoluten Pfad des Treffers an onOpen. Titel/Snippet/Pfad sind Fremdtext und
/// gehen nie durch innerHTML; die `**…**`-Marker im Snippet werden per
/// Split in <mark>-Elemente übersetzt.
@@ -24,7 +24,7 @@ export interface SearchView {
export function initSearchView(
container: HTMLElement,
onOpen: (relpath: string) => void,
onOpen: (path: string) => void,
onSearch: (query: string) => void,
): SearchView {
let count = 0;
@@ -34,16 +34,30 @@ export function initSearchView(
bar.className = "hit-search";
const input = document.createElement("input");
input.type = "search";
input.placeholder = "Archiv durchsuchen";
input.placeholder = "Archiv durchsuchen — #tag filtert";
// 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*).
// Enter sucht sofort und wörtlich — für exakte FTS-Syntax (Phrasen, OR/NOT).
// 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).
let pending: number | undefined;
input.addEventListener("input", () => {
clearTimeout(pending);
const q = input.value.trim();
if (q.length < 3) return;
const live = /[\p{L}\p{N}]$/u.test(q) ? `${q}*` : q;
if (q.length < 3) {
// Beim Löschen unter die Schwelle: alte Treffer weg, kurzer Hinweis.
count = 0;
results.textContent = "";
if (q) {
const head = document.createElement("div");
head.className = "hit-head";
head.textContent = "Mindestens 3 Zeichen — oder Enter.";
results.append(head);
}
return;
}
const last = q.split(/\s+/).pop()!;
const live =
!last.startsWith("#") && /[\p{L}\p{N}]$/u.test(q) ? `${q}*` : q;
pending = window.setTimeout(() => onSearch(live), 300);
});
input.addEventListener("keydown", (e) => {
@@ -77,7 +91,10 @@ export function initSearchView(
head.className = "hit-head";
const what = run.query.trim() ? `${run.query}` : "";
const tag = run.tag ? `#${run.tag}` : "";
head.textContent = `${run.hits.length} Treffer ${[what, tag].filter(Boolean).join(" · ")}`;
const scope = [what, tag].filter(Boolean).join(" · ");
head.textContent = run.hits.length
? `${run.hits.length} Treffer ${scope}`
: `Keine Treffer für ${scope}`;
results.append(head);
for (const hit of run.hits) {
const tile = document.createElement("div");
@@ -89,7 +106,7 @@ export function initSearchView(
path.className = "hit-path";
path.textContent = hit.relpath;
tile.append(title, snippetEl(hit.snippet), path);
tile.addEventListener("click", () => onOpen(hit.relpath));
tile.addEventListener("click", () => onOpen(`${run.home}/${hit.relpath}`));
results.append(tile);
}
}
+37 -22
View File
@@ -11,8 +11,9 @@ import { invoke } from "@tauri-apps/api/core";
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
import { listen } from "@tauri-apps/api/event";
import { wirePanel } from "./panel-wiring";
import { THEMES } from "./themes";
import { applyTheme, THEMES } from "./themes";
import { flash, panelToast } from "./commands-view";
import { initArchiveForm } from "./archive-form";
// Debug-Instrumentierung: Fehler auf der Seite anzeigen und ins Dev-Log spiegeln.
function showError(msg: string) {
@@ -56,6 +57,13 @@ if (!isMac) {
}
}
// Doppelklick auf die Kopfleiste maximiert (Fenster-Konvention).
document.querySelector("header")!.addEventListener("dblclick", (e) => {
if ((e.target as HTMLElement).hasAttribute("data-tauri-drag-region")) {
win.toggleMaximize();
}
});
// Fensterhintergrund beim Öffnen kommt aus terminal.rs (theme_background);
// dort nicht gepflegte Themes fallen auf Mocha zurück (nur kurzer Moment
// bis die Webview lädt).
@@ -90,14 +98,8 @@ if (cfg?.terminal.icon) {
const picked = THEMES[cfg?.terminal.theme ?? "mocha"];
const theme = picked.xterm;
// Seitenfarben ans Theme anpassen (Defaults in terminal.html sind Mocha).
document.documentElement.style.background = theme.background;
document.body.style.background = theme.background;
document.body.style.borderColor = picked.border;
const header = document.querySelector("header") as HTMLElement;
header.style.background = picked.header;
header.style.borderBottomColor = picked.border;
header.style.color = theme.foreground;
// Seitenfarben ans Theme anpassen — alles Weitere hängt an den CSS-Variablen.
applyTheme(picked);
// Globale Schriftgröße (settings.json: terminalFontSize), ein Wert für alle.
const DEFAULT_FONT_SIZE = 13;
@@ -186,13 +188,6 @@ term.focus();
const panel = document.getElementById("panel")!;
const splitter = document.getElementById("splitter")!;
// Panelfarben ans Theme koppeln.
panel.style.background = picked.header;
panel.style.color = theme.foreground;
splitter.style.background = picked.border;
(panel.querySelector(".panel-head") as HTMLElement).style.borderBottomColor =
picked.border;
let detached = false;
let hasContent = false;
@@ -209,39 +204,52 @@ function hidePanel() {
// Gemeinsame Verdrahtung (Views, Modi, Update-Events); jedes eingehende
// Update blendet das angedockte Panel ein, solange es nicht abgelöst ist.
const { view } = await wirePanel(project, () => {
const { view, mode } = await wirePanel(project, () => {
hasContent = true;
if (!detached) showPanel();
});
// Panel startet zugeklappt — kein Tab aktiv, bis eine Ansicht geöffnet wird.
mode.clear();
// Tab-Klick im Header öffnet das (angedockte) Panel, falls es zu ist.
document.getElementById("panel-tabs")!.addEventListener("click", () => {
const headerTabs = document.getElementById("panel-tabs")!;
headerTabs.addEventListener("click", () => {
hasContent = true;
if (!detached) showPanel();
});
// Solange das Panel abgelöst ist, hat das eigene Fenster die Tabs — die im
// Header verschwinden.
await listen("panel-detached", () => {
detached = true;
headerTabs.hidden = true;
hidePanel();
mode.clear();
});
await listen("panel-attached", () => {
detached = false;
headerTabs.hidden = false;
if (hasContent) showPanel();
});
// Abgelöstes Fenster geschlossen (per ✕ oder OS): angedocktes Panel bleibt
// ausgeblendet, aber der nächste Entwurf darf es wieder einblenden.
await listen("panel-window-closed", () => {
detached = false;
headerTabs.hidden = false;
});
document
.getElementById("panel-detach")!
.addEventListener("click", () => invoke("open_panel_window", { project }));
document.getElementById("panel-hide")!.addEventListener("click", hidePanel);
document.getElementById("panel-hide")!.addEventListener("click", () => {
hidePanel();
mode.clear();
});
// Archivieren: konfigurierten Ordner nehmen, sonst per Dialog wählen (setzt ihn).
// 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")!;
archiveBtn.addEventListener("click", async () => {
const archiveForm = initArchiveForm(archiveBtn, async (meta) => {
try {
const configured = await invoke<string | null>("panel_archive_dir_cmd", {
project,
@@ -254,7 +262,13 @@ archiveBtn.addEventListener("click", async () => {
dir = chosen as string;
}
await view.flush(); // offene Bearbeitung erst speichern — archiviert, was zu sehen ist
const path = await invoke<string>("panel_archive_cmd", { project, dir });
const path = await invoke<string>("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) {
@@ -263,6 +277,7 @@ archiveBtn.addEventListener("click", async () => {
invoke("term_log", { msg: `archive: ${e}` });
}
});
archiveBtn.addEventListener("click", () => archiveForm.toggle());
// Splitter: Panelbreite ziehen.
splitter.addEventListener("mousedown", (e) => {
+25
View File
@@ -1,6 +1,31 @@
/// Farbschemata der Fenster: Header-/Rahmenfarbe plus xterm-Theme —
/// genutzt vom Terminal-Fenster (Header, angedocktes Panel) und vom
/// abgelösten Panel-Fenster (panel.ts), damit beide gleich aussehen.
/// Überträgt ein Theme auf die CSS-Variablen der Seite; die Defaults in den
/// HTML-Styleblöcken sind Mocha. Fehlt einem Theme ein Farbwert, bleibt der
/// Mocha-Default stehen.
export function applyTheme(picked: (typeof THEMES)[string]) {
const x = picked.xterm;
const vars: Record<string, string | undefined> = {
"--bg": x.background,
"--surface": picked.header,
"--tile": x.background,
"--line": picked.border,
"--line-strong": x.black,
"--text": x.foreground,
"--muted": x.brightWhite,
"--faint": x.brightBlack,
"--accent": x.blue,
"--ok": x.green,
"--warn": x.yellow,
"--err": x.red,
};
for (const [k, v] of Object.entries(vars)) {
if (v) document.documentElement.style.setProperty(k, v);
}
}
export const THEMES: Record<
string,
{ header: string; border: string; xterm: Record<string, string> }
+147
View File
@@ -0,0 +1,147 @@
// Wiki-Ansicht: Übersichts-/Schlagwort-Seiten, Dokumentseite, Leerzustände.
import { beforeEach, 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 doc = (name: string, extra: object = {}) => ({
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: [
{ name: "adr", count: 2 },
{ name: "infra", count: 1 },
],
recent: [doc("neu")],
folders: [
{ name: "", docs: [doc("wurzel-doc", { backlinks: 2, tags: ["adr"] })] },
{ name: "konzepte", docs: [doc("neu"), doc("alt")] },
],
});
describe("initWikiView — Seiten", () => {
it("rendert Kopf, Chips, Zuletzt- und Ordner-Sektionen", () => {
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(
(e) => e.textContent,
);
expect(eyebrows).toEqual(["Zuletzt", "Wurzel", "konzepte/"]);
expect(document.querySelector(".wiki-doc-back")!.textContent).toBe("↩ 2");
});
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", () => {
const { view } = setup();
view.set(
JSON.stringify({
kind: "page",
home: "/a",
tag: "adr",
total: 0,
tags: [{ name: "adr", count: 1 }],
recent: [],
folders: [],
}),
);
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.",
);
});
it("leeres Archiv erklärt das Archivieren", () => {
const { view } = setup();
view.set(
JSON.stringify({
kind: "page",
home: "/a",
tag: null,
total: 0,
tags: [],
recent: [],
folders: [],
}),
);
expect(document.querySelector(".wiki-empty")!.textContent).toContain(
"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();
view.set("");
expect(view.empty()).toBe(true);
document.querySelector<HTMLElement>(".wiki-empty .wiki-chip")!.click();
expect(onLink).toHaveBeenCalledWith("tag:");
view.set(page);
expect(view.empty()).toBe(false);
});
});
+21
View File
@@ -14,6 +14,7 @@ interface DocEntry {
description?: string | null;
tags: string[];
date?: string | null;
backlinks: number;
}
interface Folder {
@@ -27,6 +28,7 @@ interface Page {
tag?: string | null;
total: number;
tags: { name: string; count: number }[];
recent: DocEntry[];
folders: Folder[];
}
@@ -103,6 +105,13 @@ export function initWikiView(
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 = "Verweise hierher";
back.textContent = `${doc.backlinks}`;
line.append(back);
}
if (doc.date) {
const date = document.createElement("div");
date.className = "wiki-doc-date";
@@ -143,12 +152,24 @@ export function initWikiView(
container.append(empty);
return;
}
if (p.recent.length) {
const eyebrow = document.createElement("div");
eyebrow.className = "wiki-folder";
eyebrow.textContent = "Zuletzt";
container.append(eyebrow);
for (const doc of p.recent) container.append(docRow(doc));
}
for (const folder of p.folders) {
if (folder.name) {
const eyebrow = document.createElement("div");
eyebrow.className = "wiki-folder";
eyebrow.textContent = `${folder.name}/`;
container.append(eyebrow);
} else if (p.recent.length) {
const eyebrow = document.createElement("div");
eyebrow.className = "wiki-folder";
eyebrow.textContent = "Wurzel";
container.append(eyebrow);
}
for (const doc of folder.docs) container.append(docRow(doc));
}