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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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()) };
|
||||
}
|
||||
@@ -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
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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 () => {
|
||||
|
||||
@@ -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
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -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> }
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user