Archiv-Wiki im Panel: vier Tabs, FTS5-Suche, strukturierbares Archiv

- Archiv-Home pro Projekt (archiveHome in ai-control.json); archive_panel
  mit Ordner, Beschreibung und Schlagwörtern (Frontmatter + Zeitstempel-Stem)
- Archiv-Index (archive_index.rs): Scan mit Frontmatter, Wikilinks,
  Backlinks; Wiki-Seiten als strukturierte Daten (Übersicht/Tag-Seiten)
- FTS5-Suche (archive_search.rs, rusqlite bundled): in-memory-Index pro
  Anfrage; MCP search_archive und Panel-Suchfeld (search_run) teilen die Engine
- Panel: vier Puffer/Ansichten (Dokument, Befehle, Suche, Wiki) mit Tabs im
  Fenster-Header; gemeinsame Verdrahtung panel-wiring.ts, Kachel-CSS,
  Wiki-View mit Tag-Chips, Ordner-Sektionen und Backlinks
- Archivieren speichert offene Bearbeitung (flush); Tab-Wechsel speichert
  statt zu blockieren; Fehler sichtbar als Panel-Toast
- Abgelöstes Fenster: rahmenlos (Linux), eigene Kopfleiste mit Tabs und
  Fensterknöpfen, Theme-Kopplung über gemeinsames themes.ts
- MCP-Tools show_commands, show_archive; Befehls-History mit Kachel-Löschen
This commit is contained in:
marcus hinz
2026-07-19 15:25:40 +02:00
parent d4d455fa08
commit 994d5070f0
29 changed files with 3471 additions and 571 deletions
+97
View File
@@ -0,0 +1,97 @@
/// 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
/// 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: (relpath: 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 — Enter startet";
input.addEventListener("keydown", (e) => {
if (e.key === "Enter" && input.value.trim()) 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}` : "";
head.textContent = `${run.hits.length} Treffer ${[what, tag].filter(Boolean).join(" · ")}`;
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(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,
};
}