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
+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);
}
}