/// Kachel-Ansicht der Command-History: rendert die JSONL-Datei /// (write_commands im MCP-Server) als kopierbare Kacheln, Neuestes oben, /// mit Zeitmarken und Session-Trennern. Löschen einer Kachel geht als /// onDelete(line, entry) an den Aufrufer; der neue Stand kommt über den /// Watcher zurück. DOM wird per createElement gebaut — Befehle sind /// Fremdtext und gehen nie durch innerHTML. import { writeText } from "@tauri-apps/plugin-clipboard-manager"; /// Entfernt Bidi- und Zero-Width-Steuerzeichen (U+200B–200F, U+202A–202E, /// U+2060–2064, U+2066–2069, U+FEFF) aus Befehlstexten. function stripInvisibles(s: string): string { return s.replace(/[\u200B-\u200F\u202A-\u202E\u2060-\u2064\u2066-\u2069\uFEFF]/g, ""); } interface CommandEntry { cmd: string; note?: string; } interface Record { ts: number; session?: boolean; commands?: CommandEntry[]; } export interface CommandsView { set(text: string): void; empty(): boolean; } function fmtTime(ts: number): string { const d = new Date(ts * 1000); const hm = d.toLocaleTimeString("de", { hour: "2-digit", minute: "2-digit" }); return d.toDateString() === new Date().toDateString() ? hm : `${d.toLocaleDateString("de")} ${hm}`; } /// Kurzes visuelles Feedback (copied/error) — der eine Flash-Helper fürs /// ganze Panel. export function flash(el: HTMLElement, cls: string, ms = 1200) { el.classList.add(cls); setTimeout(() => el.classList.remove(cls), ms); } /// Sichtbare Fehlermeldung im Panel: kurz eingeblendete Zeile oben rechts. export function panelToast(msg: string) { const t = document.createElement("div"); t.className = "panel-toast"; t.textContent = msg; document.body.append(t); setTimeout(() => t.remove(), 5000); } function copyBtn(text: () => string): HTMLButtonElement { const btn = document.createElement("button"); btn.className = "panel-btn cmd-copy"; btn.title = "Befehl kopieren"; btn.innerHTML = ''; btn.addEventListener("click", async () => { await writeText(text()); flash(btn, "copied"); }); return btn; } function deleteBtn(onClick: () => void): HTMLButtonElement { const btn = document.createElement("button"); btn.className = "panel-btn cmd-del"; btn.title = "Aus der History entfernen"; btn.innerHTML = ''; btn.addEventListener("click", onClick); return btn; } export function initCommandsView( container: HTMLElement, onDelete: (line: number, entry: number, cmd: string) => void, ): CommandsView { let count = 0; function render(records: Record[]) { container.textContent = ""; // Neuestes oben: rückwärts durch die Records; ein Session-Marker trennt // die davor liegenden (älteren) Einträge ab und steht daher unter ihnen. // Der Record-Index entspricht der nicht-leeren JSONL-Zeile — er // adressiert den Eintrag beim Löschen. for (let i = records.length - 1; i >= 0; i--) { const rec = records[i]; if (rec.session) { const sep = document.createElement("div"); sep.className = "cmd-sep"; sep.textContent = `Session · ${fmtTime(rec.ts)}`; container.append(sep); continue; } const cmds = rec.commands ?? []; if (!cmds.length) continue; const block = document.createElement("div"); block.className = "cmd-rec"; const head = document.createElement("div"); head.className = "cmd-rec-head"; const time = document.createElement("span"); time.textContent = fmtTime(rec.ts); head.append(time); if (cmds.length > 1) { const all = document.createElement("button"); all.className = "cmd-all"; all.textContent = "Alle kopieren"; all.addEventListener("click", async () => { await writeText(cmds.map((c) => stripInvisibles(c.cmd)).join("\n")); flash(all, "copied"); }); head.append(all); } block.append(head); cmds.forEach((entry, j) => { // Unsichtbare Steuerzeichen (Bidi, Zero-Width) aus Anzeige UND // Kopie halten — sonst sieht der Nutzer einen anderen Befehl, als // die Zwischenablage enthält. Der Lösch-Abgleich läuft weiter über // den Original-String. const visible = stripInvisibles(entry.cmd); const tile = document.createElement("div"); tile.className = "cmd-tile"; const body = document.createElement("div"); body.className = "cmd-body"; const cmd = document.createElement("div"); cmd.className = "cmd-text"; cmd.textContent = visible; body.append(cmd); if (entry.note) { const note = document.createElement("div"); note.className = "cmd-note"; note.textContent = entry.note; body.append(note); } tile.append(body, copyBtn(() => visible), deleteBtn(() => onDelete(i, j, entry.cmd))); block.append(tile); }); container.append(block); } } return { set(text: string) { const records: Record[] = []; for (const line of text.split("\n")) { if (!line.trim()) continue; records.push(JSON.parse(line)); } count = records.filter((r) => r.commands?.length).length; render(records); }, empty: () => count === 0, }; } export type PanelMode = "draft" | "commands" | "search" | "wiki"; const LABEL: { [m in PanelMode]: string } = { draft: "Dokument", commands: "Befehle", search: "Suche", wiki: "Wiki", }; /// Vier Tabs Entwurf / Befehle / Suche / Wiki: blenden Entwurfs-Inhalt samt /// zugehöriger Kopf-Controls gegen die jeweilige Ansicht aus. Ein Wechsel bei /// offener Inhalts-Bearbeitung speichert den Entwurf (flush) statt zu /// blockieren. export function initPanelMode(opts: { tabsEl: HTMLElement; draftEls: HTMLElement[]; commandsContent: HTMLElement; searchContent: HTMLElement; wikiContent: HTMLElement; titleEl: HTMLElement; flush: () => void; }) { // `null` = kein Tab aktiv (Panel zugeklappt). let mode: PanelMode | null = "draft"; let draftTitle = ""; const tabs = [...opts.tabsEl.querySelectorAll("[data-mode]")]; function apply() { opts.commandsContent.hidden = mode !== "commands"; opts.searchContent.hidden = mode !== "search"; 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 && mode !== "draft") opts.titleEl.textContent = LABEL[mode]; } function to(m: PanelMode) { if (m === mode) return; opts.flush(); if (mode === "draft") draftTitle = opts.titleEl.textContent || "Dokument"; mode = m; apply(); 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, clear, current: () => mode }; }