286513def8
- 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)
129 lines
4.0 KiB
TypeScript
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,
|
|
};
|
|
}
|