Files
ai-control/src/search-view.ts
T
marcus hinz 286513def8 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)
2026-07-19 17:08:05 +02:00

129 lines
4.0 KiB
TypeScript

/// Kachel-Ansicht der Archiv-Suchtreffer: rendert die Suchtreffer-Datei
/// (search_archive im MCP-Server) als klickbare Kacheln — Klick reicht den
/// 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.
interface Hit {
relpath: string;
title: string;
snippet: string;
}
interface SearchRun {
query: string;
tag?: string | null;
home: string;
hits: Hit[];
}
export interface SearchView {
set(text: string): void;
empty(): boolean;
}
export function initSearchView(
container: HTMLElement,
onOpen: (path: string) => void,
onSearch: (query: string) => void,
): SearchView {
let count = 0;
// Suchfeld oben, Treffer darunter; das Feld bleibt über Updates hinweg stehen.
const bar = document.createElement("div");
bar.className = "hit-search";
const input = document.createElement("input");
input.type = "search";
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*) —
// 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) {
// 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) => {
if (e.key === "Enter" && input.value.trim()) {
clearTimeout(pending);
onSearch(input.value.trim());
}
});
bar.append(input);
const results = document.createElement("div");
container.append(bar, results);
function snippetEl(snippet: string): HTMLElement {
const div = document.createElement("div");
div.className = "hit-snippet";
snippet.split("**").forEach((part, i) => {
if (i % 2) {
const m = document.createElement("mark");
m.textContent = part;
div.append(m);
} else if (part) {
div.append(document.createTextNode(part));
}
});
return div;
}
function render(run: SearchRun) {
results.textContent = "";
const head = document.createElement("div");
head.className = "hit-head";
const what = run.query.trim() ? `${run.query}` : "";
const tag = run.tag ? `#${run.tag}` : "";
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");
tile.className = "hit-tile";
const title = document.createElement("div");
title.className = "hit-title";
title.textContent = hit.title;
const path = document.createElement("div");
path.className = "hit-path";
path.textContent = hit.relpath;
tile.append(title, snippetEl(hit.snippet), path);
tile.addEventListener("click", () => onOpen(`${run.home}/${hit.relpath}`));
results.append(tile);
}
}
return {
set(text: string) {
if (!text.trim()) {
count = 0;
results.textContent = "";
return;
}
const run: SearchRun = JSON.parse(text);
count = run.hits.length;
if (document.activeElement !== input) input.value = run.query;
render(run);
},
empty: () => count === 0,
};
}