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
+199
View File
@@ -0,0 +1,199 @@
/// 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";
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 =
'<svg width="14" height="14" viewBox="0 0 16 16"><rect x="5.5" y="5.5" width="8" height="8" rx="1.5" /><path d="M10.5 3.2V3A1.5 1.5 0 0 0 9 1.5H3A1.5 1.5 0 0 0 1.5 3v6A1.5 1.5 0 0 0 3 10.5h.2" /></svg>';
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 =
'<svg width="14" height="14" viewBox="0 0 16 16"><path d="M2.5 4.5h11" /><path d="M5.5 4.5V3a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1v1.5" /><path d="M4 4.5l.7 8.6a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9l.7-8.6" /></svg>';
btn.addEventListener("click", onClick);
return btn;
}
export function initCommandsView(
container: HTMLElement,
onDelete: (line: number, entry: number) => 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) => c.cmd).join("\n"));
flash(all, "copied");
});
head.append(all);
}
block.append(head);
cmds.forEach((entry, j) => {
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 = entry.cmd;
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(() => entry.cmd), deleteBtn(() => onDelete(i, j)));
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;
}) {
let mode: PanelMode = "draft";
let draftTitle = "";
const tabs = [...opts.tabsEl.querySelectorAll<HTMLElement>("[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 !== "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;
}
for (const t of tabs) t.addEventListener("click", () => to(t.dataset.mode as PanelMode));
apply();
return { to, current: () => mode };
}
+430
View File
@@ -0,0 +1,430 @@
/* Kachel- und Wiki-Ansichten des Panels (Befehls-History, Archiv-Suchtreffer,
Archiv-Wiki) — gemeinsam für das angedockte Panel (terminal.html) und das
abgelöste Fenster (panel.html); von beiden Entry-Points importiert. */
#commands-content {
flex: 1;
min-height: 0;
overflow: auto;
padding: 12px 14px;
}
#commands-content::-webkit-scrollbar {
width: 8px;
}
#commands-content::-webkit-scrollbar-thumb {
background: #313244;
border-radius: 4px;
}
.cmd-sep {
margin: 16px 0 8px;
padding-top: 8px;
border-top: 1px solid #313244;
color: #6c7086;
font:
500 11px/1 -apple-system,
system-ui,
sans-serif;
user-select: none;
-webkit-user-select: none;
}
.cmd-rec {
margin-bottom: 12px;
}
.cmd-rec-head {
display: flex;
align-items: center;
justify-content: space-between;
color: #6c7086;
font:
500 11px/1 -apple-system,
system-ui,
sans-serif;
user-select: none;
-webkit-user-select: none;
}
.cmd-all {
border: none;
background: transparent;
color: #a6adc8;
font: inherit;
border-radius: 6px;
padding: 4px 6px;
}
.cmd-all:hover {
background: #313244;
color: #cdd6f4;
}
.cmd-all.copied {
color: #a6e3a1;
}
.cmd-del:hover {
color: #f38ba8;
}
.cmd-tile {
display: flex;
align-items: flex-start;
gap: 6px;
background: #11111b;
border: 1px solid #313244;
border-radius: 8px;
padding: 8px 6px 8px 10px;
margin-top: 6px;
}
.cmd-body {
flex: 1;
min-width: 0;
}
.cmd-text {
font-family: "JetBrains Mono", Menlo, monospace;
font-size: 12px;
line-height: 1.45;
white-space: pre-wrap;
word-break: break-all;
user-select: text;
-webkit-user-select: text;
cursor: text;
}
.cmd-note {
margin-top: 4px;
color: #a6adc8;
font:
400 11px/1.4 -apple-system,
system-ui,
sans-serif;
}
#search-content {
flex: 1;
min-height: 0;
overflow: auto;
padding: 12px 14px;
}
.hit-search {
margin-bottom: 8px;
}
.hit-search input {
width: 100%;
box-sizing: border-box;
background: #11111b;
border: 1px solid #313244;
border-radius: 6px;
padding: 6px 10px;
color: #cdd6f4;
font:
400 12px/1.4 -apple-system,
system-ui,
sans-serif;
outline: none;
}
.hit-search input:focus {
border-color: #45475a;
}
.hit-search input::placeholder {
color: #6c7086;
}
#search-content::-webkit-scrollbar {
width: 8px;
}
#search-content::-webkit-scrollbar-thumb {
background: #313244;
border-radius: 4px;
}
.hit-head {
color: #6c7086;
font:
500 11px/1 -apple-system,
system-ui,
sans-serif;
user-select: none;
-webkit-user-select: none;
}
.hit-tile {
background: #11111b;
border: 1px solid #313244;
border-radius: 8px;
padding: 8px 10px;
margin-top: 6px;
cursor: pointer;
}
.hit-tile:hover {
border-color: #45475a;
}
.hit-title {
font:
600 12px/1.4 -apple-system,
system-ui,
sans-serif;
}
.hit-snippet {
margin-top: 4px;
color: #a6adc8;
font:
400 11px/1.5 -apple-system,
system-ui,
sans-serif;
}
.hit-snippet mark {
background: transparent;
color: #f9e2af;
font-weight: 600;
}
.hit-path {
margin-top: 4px;
color: #6c7086;
font-family: "JetBrains Mono", Menlo, monospace;
font-size: 10px;
}
/* Wiki-Ansicht: Übersichts-/Schlagwort-Seiten und Dokumente. */
#wiki-content {
flex: 1;
min-height: 0;
overflow: auto;
padding: 14px 16px;
}
#wiki-content::-webkit-scrollbar {
width: 8px;
}
#wiki-content::-webkit-scrollbar-thumb {
background: #313244;
border-radius: 4px;
}
.wiki-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.wiki-head-left {
min-width: 0;
}
.wiki-head-title {
color: #cdd6f4;
font:
600 13px/1.3 -apple-system,
system-ui,
sans-serif;
}
.wiki-head-sub {
margin-top: 2px;
color: #6c7086;
font-family: "JetBrains Mono", Menlo, monospace;
font-size: 10px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.wiki-head-right {
color: #6c7086;
font:
500 11px/1 -apple-system,
system-ui,
sans-serif;
white-space: nowrap;
}
.wiki-chips {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin: 10px 0 4px;
}
.wiki-chip {
border: 1px solid #313244;
background: transparent;
color: #a6adc8;
border-radius: 999px;
padding: 2px 8px;
font:
500 10.5px/1.5 -apple-system,
system-ui,
sans-serif;
}
.wiki-chip:hover {
background: #313244;
color: #cdd6f4;
}
.wiki-chip.active {
background: #313244;
border-color: #45475a;
color: #89b4fa;
}
.wiki-folder {
margin: 14px 0 2px;
padding-top: 10px;
border-top: 1px solid #313244;
color: #6c7086;
font:
500 10px/1 -apple-system,
system-ui,
sans-serif;
letter-spacing: 0.08em;
text-transform: uppercase;
user-select: none;
-webkit-user-select: none;
}
.wiki-doc {
padding: 7px 8px;
margin: 4px -8px 0;
border-radius: 8px;
cursor: pointer;
}
.wiki-doc:hover {
background: #11111b;
}
.wiki-doc-line {
display: flex;
align-items: baseline;
gap: 8px;
}
.wiki-doc-title {
flex: 1;
min-width: 0;
color: #89b4fa;
font:
600 12px/1.4 -apple-system,
system-ui,
sans-serif;
}
.wiki-doc-date {
color: #6c7086;
font-family: "JetBrains Mono", Menlo, monospace;
font-size: 10px;
}
.wiki-doc-desc {
margin-top: 2px;
color: #a6adc8;
font:
400 11px/1.5 -apple-system,
system-ui,
sans-serif;
}
.wiki-doc-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 5px;
}
.wiki-empty {
margin-top: 18px;
color: #a6adc8;
font:
400 12px/1.6 -apple-system,
system-ui,
sans-serif;
}
.wiki-empty strong {
display: block;
margin-bottom: 6px;
color: #cdd6f4;
font-weight: 600;
}
.wiki-doc-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
}
.wiki-back {
border: none;
background: transparent;
color: #a6adc8;
font:
500 11px/1 -apple-system,
system-ui,
sans-serif;
border-radius: 6px;
padding: 4px 6px;
}
.wiki-back:hover {
background: #313244;
color: #cdd6f4;
}
.wiki-body {
font-size: 12.5px;
line-height: 1.55;
user-select: text;
-webkit-user-select: text;
cursor: text;
}
.wiki-body h1,
.wiki-body h2,
.wiki-body h3 {
line-height: 1.25;
margin: 1em 0 0.4em;
}
.wiki-body h1 {
font-size: 1.4em;
}
.wiki-body h2 {
font-size: 1.2em;
}
.wiki-body h3 {
font-size: 1.05em;
}
.wiki-body :first-child {
margin-top: 0;
}
.wiki-body p,
.wiki-body ul,
.wiki-body ol,
.wiki-body pre,
.wiki-body blockquote {
margin: 0 0 0.7em;
}
.wiki-body code {
font-family: "JetBrains Mono", Menlo, monospace;
font-size: 0.9em;
background: #313244;
border-radius: 4px;
padding: 1px 5px;
}
.wiki-body pre {
background: #11111b;
border-radius: 8px;
padding: 12px 14px;
overflow: auto;
}
.wiki-body pre code {
background: none;
padding: 0;
}
.wiki-body blockquote {
border-left: 3px solid #313244;
padding-left: 12px;
color: #a6adc8;
}
.wiki-body a {
color: #89b4fa;
}
.wiki-backlinks {
margin-top: 14px;
padding-top: 8px;
border-top: 1px solid #313244;
color: #6c7086;
font:
500 11px/1.6 -apple-system,
system-ui,
sans-serif;
}
.wiki-backlinks a {
color: #89b4fa;
text-decoration: none;
}
/* Sichtbare Fehlermeldung (panelToast), oben rechts über dem Panel-Inhalt. */
.panel-toast {
position: fixed;
top: 44px;
right: 12px;
z-index: 10;
max-width: 320px;
background: #11111b;
border: 1px solid #f38ba8;
color: #f38ba8;
border-radius: 8px;
padding: 8px 12px;
font:
500 11px/1.5 -apple-system,
system-ui,
sans-serif;
}
+58 -8
View File
@@ -1,8 +1,13 @@
import { marked } from "marked";
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
import { flash } from "./commands-view";
export interface PanelView {
set(text: string): void;
raw(): string;
/// Schreibt eine offene Inhalts-Bearbeitung zurück (und beendet sie);
/// aufgelöst, sobald der Entwurf gespeichert ist.
flush(): Promise<void>;
}
/// Verkabelt einen Panel-Inhaltsbereich: MD/Roh-Umschalter und Copy-Button.
@@ -22,7 +27,7 @@ function stripInlineMd(s: string): string {
}
/// Titel für den Panel-Kopf: erste Überschrift (# …) oder sonst erste
/// nicht-leere Zeile, ohne Inline-Markdown; „Entwurf" bei leerem Text.
/// nicht-leere Zeile, ohne Inline-Markdown; „Dokument" bei leerem Text.
function firstLine(text: string): string {
let fallback = "";
for (const line of text.split("\n")) {
@@ -32,7 +37,7 @@ function firstLine(text: string): string {
if (t.startsWith("#") && h) return stripInlineMd(h);
if (!fallback) fallback = t;
}
return fallback ? stripInlineMd(fallback) : "Entwurf";
return fallback ? stripInlineMd(fallback) : "Dokument";
}
/// Ersetzt die erste Überschrift (bzw. legt eine an) im Rohtext durch `title`.
@@ -52,6 +57,45 @@ function setHeading(text: string, title: string): string {
return `# ${title}\n`;
}
/// Macht `[[name]]`-Wikilinks im gerendertem Markdown klickbar. Läuft über die
/// Textknoten des DOM statt über den Rohtext, damit Vorkommen in Code-Spans
/// und Code-Blöcken (z. B. bash `[[ -f x ]]`) unangetastet bleiben.
export function linkWikiRefs(root: HTMLElement, onClick: (name: string) => void) {
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
const nodes: Text[] = [];
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
const t = n as Text;
if (!t.textContent?.includes("[[")) continue;
if (t.parentElement?.closest("code, pre, a")) continue;
nodes.push(t);
}
for (const node of nodes) {
const parts = node.textContent!.split(/\[\[([^\]]+)\]\]/g);
if (parts.length < 3) continue;
const frag = document.createDocumentFragment();
parts.forEach((part, i) => {
if (i % 2) {
// `[[ziel]]` oder `[[ziel|label]]`.
const sep = part.indexOf("|");
const target = sep < 0 ? part : part.slice(0, sep);
const label = sep < 0 ? part : part.slice(sep + 1);
const a = document.createElement("a");
a.href = "#";
a.className = "wiki";
a.textContent = label;
a.addEventListener("click", (e) => {
e.preventDefault();
onClick(target);
});
frag.append(a);
} else if (part) {
frag.append(part);
}
});
node.replaceWith(frag);
}
}
export function initPanelView(opts: {
content: HTMLElement;
copyBtn: HTMLElement;
@@ -64,7 +108,9 @@ export function initPanelView(opts: {
defaultLang?: string;
/// Wird mit dem neuen Rohtext aufgerufen, wenn Titel oder Inhalt geändert
/// wurden.
onCommit?: (text: string) => void;
onCommit?: (text: string) => void | Promise<void>;
/// Klick auf einen `[[name]]`-Wikilink im gerenderten Markdown.
onWikiLink?: (name: string) => void;
}): PanelView {
let rawText = "";
let rendered = true;
@@ -72,11 +118,13 @@ export function initPanelView(opts: {
// gepuffert statt angewandt.
let editing = false;
let pending: string | null = null;
let flushEditor: () => Promise<void> = async () => {};
function draw() {
if (rendered) {
opts.content.className = "md";
opts.content.innerHTML = marked.parse(rawText, { async: false });
if (opts.onWikiLink) linkWikiRefs(opts.content, opts.onWikiLink);
} else {
opts.content.className = "raw";
opts.content.textContent = rawText;
@@ -90,9 +138,8 @@ export function initPanelView(opts: {
});
opts.copyBtn.addEventListener("click", async () => {
await navigator.clipboard.writeText(rawText);
opts.copyBtn.classList.add("copied");
setTimeout(() => opts.copyBtn.classList.remove("copied"), 1200);
await writeText(rawText);
flash(opts.copyBtn, "copied");
});
// Titel-Edit: Edit-Button macht den Titel editierbar, Enter/Blur schreibt die
@@ -172,11 +219,13 @@ export function initPanelView(opts: {
editBtn.classList.add("active");
editor.focus();
};
const save = () => {
flushEditor = async () => {
if (!editing) return;
const v = editor.value;
leave();
opts.onCommit!(v); // schreibt Datei -> panel-update -> set() rendert
await opts.onCommit!(v); // schreibt Datei -> panel-update -> set() rendert
};
const save = () => void flushEditor();
const cancel = () => {
const p = pending;
leave();
@@ -216,5 +265,6 @@ export function initPanelView(opts: {
applyText(text);
},
raw: () => rawText,
flush: () => flushEditor(),
};
}
+117
View File
@@ -0,0 +1,117 @@
/// Gemeinsame Panel-Verdrahtung für das angedockte Panel (terminal.ts) und
/// das abgelöste Fenster (panel.ts): Entwurfs-Ansicht, Befehls- und
/// Such-Kacheln, Modus-Umschalter, Wikilink-Klick und die drei Update-Events.
/// Die Element-IDs sind in beiden HTML-Dateien identisch; die Draft-Controls
/// trägt das Markup als `.draft-only`.
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { initPanelView, type PanelView } from "./panel-view";
import {
initCommandsView,
initPanelMode,
panelToast,
type CommandsView,
type PanelMode,
} from "./commands-view";
import { initSearchView } from "./search-view";
import { initWikiView } from "./wiki-view";
import "./panel-tiles.css";
export interface PanelWiring {
view: PanelView;
cmdView: CommandsView;
mode: { to(m: PanelMode): void; current(): PanelMode };
/// Entwurfstext beim Start (für die Anfangs-Modus-Entscheidung).
draft: string;
}
export async function wirePanel(
project: string,
onIncoming?: () => void,
): Promise<PanelWiring> {
const titleEl = document.querySelector(".panel-title") as HTMLElement;
const [defaultLang, draft, cmds, search, wiki] = await Promise.all([
invoke<string>("spellcheck_lang"),
invoke<string>("panel_read", { project }),
invoke<string>("commands_read", { project }),
invoke<string>("search_read", { project }),
invoke<string>("wiki_read", { project }),
]);
// Jeder Wiki-Sprung (Wikilink, Chip, Suchtreffer) geht als ein Invoke an den
// Kern; das Ergebnis kommt über den Wiki-Puffer und `wiki-update` zurück.
const openWiki = (name: string) => invoke("wiki_open", { project, name });
const view = initPanelView({
content: document.getElementById("panel-content")!,
copyBtn: document.getElementById("panel-copy")!,
modeBtn: document.getElementById("panel-mode")!,
titleEl,
editBtn: document.getElementById("panel-title-edit")!,
editContentBtn: document.getElementById("panel-content-edit")!,
langSelect: document.getElementById("panel-lang") as HTMLSelectElement,
defaultLang,
onCommit: (text) => invoke("panel_set", { project, text }),
onWikiLink: openWiki,
});
const cmdView = initCommandsView(document.getElementById("commands-content")!, (line, entry) =>
invoke("commands_delete", { project, line, entry }),
);
const searchView = initSearchView(
document.getElementById("search-content")!,
(relpath) => openWiki(relpath.split("/").pop()!.replace(/\.md$/, "")),
(query) =>
void invoke("search_run", { project, query }).catch((e) =>
panelToast(`Suche fehlgeschlagen: ${e}`),
),
);
const wikiView = initWikiView(document.getElementById("wiki-content")!, openWiki);
const mode = initPanelMode({
tabsEl: document.getElementById("panel-tabs")!,
draftEls: [
document.getElementById("panel-content")!,
...document.querySelectorAll<HTMLElement>(".draft-only"),
],
commandsContent: document.getElementById("commands-content")!,
searchContent: document.getElementById("search-content")!,
wikiContent: document.getElementById("wiki-content")!,
titleEl,
flush: () => void view.flush(),
});
view.set(draft);
cmdView.set(cmds);
searchView.set(search);
wikiView.set(wiki);
// Wiki-Tab bei leerem Puffer (Session-Start): Übersicht direkt laden.
document
.querySelector<HTMLElement>('#panel-tabs [data-mode="wiki"]')!
.addEventListener("click", () => {
if (wikiView.empty()) void openWiki("tag:");
});
await listen<string>("panel-update", (e) => {
view.set(e.payload);
mode.to("draft");
onIncoming?.();
});
await listen<string>("commands-update", (e) => {
cmdView.set(e.payload);
mode.to("commands");
onIncoming?.();
});
await listen<string>("search-update", (e) => {
searchView.set(e.payload);
mode.to("search");
onIncoming?.();
});
await listen<string>("wiki-update", (e) => {
wikiView.set(e.payload);
mode.to("wiki");
onIncoming?.();
});
return { view, cmdView, mode, draft };
}
+48 -23
View File
@@ -2,36 +2,59 @@ import "@fontsource/jetbrains-mono/400.css";
import "@fontsource/jetbrains-mono/500.css";
import { invoke } from "@tauri-apps/api/core";
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
import { listen, emit } from "@tauri-apps/api/event";
import { initPanelView } from "./panel-view";
import { emit } from "@tauri-apps/api/event";
import { wirePanel } from "./panel-wiring";
import { flash, panelToast } from "./commands-view";
import { THEMES } from "./themes";
// Abgelöstes Panel-Fenster: liest den aktuellen Entwurf einmal ein und folgt
// danach denselben `panel-update`-Events wie das angedockte Panel.
// danach denselben Update-Events wie das angedockte Panel; startet in
// „Befehle", wenn es (noch) keinen Entwurf gibt.
const project = new URLSearchParams(location.search).get("project")!;
const win = getCurrentWebviewWindow();
const view = initPanelView({
content: document.getElementById("panel-content")!,
copyBtn: document.getElementById("panel-copy")!,
modeBtn: document.getElementById("panel-mode")!,
titleEl: document.querySelector(".panel-title") as HTMLElement,
editBtn: document.getElementById("panel-title-edit")!,
editContentBtn: document.getElementById("panel-content-edit")!,
langSelect: document.getElementById("panel-lang") as HTMLSelectElement,
defaultLang: await invoke<string>("spellcheck_lang"),
onCommit: (text) => invoke("panel_set", { project, text }),
});
// Dekorationsloses Fenster (Linux): Plattform-Flag + Resize-Zonen wie im
// Terminal-Fenster; macOS behält die native Deko.
const isMac = /Mac|Macintosh/.test(navigator.userAgent);
document.documentElement.dataset.platform = isMac ? "mac" : "other";
if (!isMac) {
document
.getElementById("win-min")!
.addEventListener("click", () => win.minimize());
document
.getElementById("win-max")!
.addEventListener("click", () => win.toggleMaximize());
for (const g of document.querySelectorAll<HTMLElement>(".grip")) {
g.addEventListener("mousedown", (e) => {
e.preventDefault();
win.startResizeDragging(g.dataset.dir as never);
});
}
}
view.set(await invoke<string>("panel_read", { project }));
// Farben ans Theme koppeln — derselbe Look wie das angedockte Panel im
// Terminal-Fenster (die CSS-Defaults sind Mocha).
interface Project {
name: string;
terminal: { theme: string | null };
}
const projects = await invoke<Project[]>("list_projects");
const picked = THEMES[projects.find((p) => p.name === project)?.terminal.theme ?? "mocha"];
document.documentElement.style.background = picked.header;
document.body.style.background = picked.header;
document.body.style.color = picked.xterm.foreground;
const topbar = document.querySelector(".panel-topbar") as HTMLElement;
topbar.style.background = picked.header;
topbar.style.borderBottomColor = picked.border;
const head = document.querySelector(".panel-head") as HTMLElement;
head.style.background = picked.header;
head.style.borderBottomColor = picked.border;
await listen<string>("panel-update", (e) => view.set(e.payload));
const { view, cmdView, mode, draft } = await wirePanel(project);
if (!draft.trim() && !cmdView.empty()) mode.to("commands");
// Archivieren: wie im angedockten Panel — Ordner nehmen oder per Dialog wählen.
const archiveBtn = document.getElementById("panel-archive")!;
function flashBtn(btn: HTMLElement, cls: string) {
btn.classList.add(cls);
setTimeout(() => btn.classList.remove(cls), 1400);
}
archiveBtn.addEventListener("click", async () => {
try {
const configured = await invoke<string | null>("panel_archive_dir_cmd", {
@@ -44,11 +67,13 @@ archiveBtn.addEventListener("click", async () => {
if (!chosen) return;
dir = chosen as string;
}
await view.flush(); // offene Bearbeitung erst speichern — archiviert, was zu sehen ist
const path = await invoke<string>("panel_archive_cmd", { project, dir });
flashBtn(archiveBtn, "copied");
flash(archiveBtn, "copied", 1400);
invoke("reveal_path_cmd", { path });
} catch {
flashBtn(archiveBtn, "error");
} catch (e) {
flash(archiveBtn, "error", 1400);
panelToast(`Archivieren fehlgeschlagen: ${e}`);
}
});
+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,
};
}
+17 -378
View File
@@ -10,7 +10,9 @@ import "@fontsource/jetbrains-mono/700.css";
import { invoke } from "@tauri-apps/api/core";
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
import { listen } from "@tauri-apps/api/event";
import { initPanelView } from "./panel-view";
import { wirePanel } from "./panel-wiring";
import { THEMES } from "./themes";
import { flash, panelToast } from "./commands-view";
// Debug-Instrumentierung: Fehler auf der Seite anzeigen und ins Dev-Log spiegeln.
function showError(msg: string) {
@@ -57,362 +59,6 @@ if (!isMac) {
// Fensterhintergrund beim Öffnen kommt aus terminal.rs (theme_background);
// dort nicht gepflegte Themes fallen auf Mocha zurück (nur kurzer Moment
// bis die Webview lädt).
const THEMES: Record<
string,
{ header: string; border: string; xterm: Record<string, string> }
> = {
mocha: {
header: "#181825",
border: "#313244",
xterm: {
background: "#1e1e2e",
foreground: "#cdd6f4",
cursor: "#f5e0dc",
cursorAccent: "#1e1e2e",
selectionBackground: "#585b7080",
black: "#45475a",
red: "#f38ba8",
green: "#a6e3a1",
yellow: "#f9e2af",
blue: "#89b4fa",
magenta: "#f5c2e7",
cyan: "#94e2d5",
white: "#bac2de",
brightBlack: "#585b70",
brightRed: "#f38ba8",
brightGreen: "#a6e3a1",
brightYellow: "#f9e2af",
brightBlue: "#89b4fa",
brightMagenta: "#f5c2e7",
brightCyan: "#94e2d5",
brightWhite: "#a6adc8",
},
},
dracula: {
header: "#21222c",
border: "#44475a",
xterm: {
background: "#282a36",
foreground: "#f8f8f2",
cursor: "#f8f8f2",
cursorAccent: "#282a36",
selectionBackground: "#44475a80",
black: "#21222c",
red: "#ff5555",
green: "#50fa7b",
yellow: "#f1fa8c",
blue: "#bd93f9",
magenta: "#ff79c6",
cyan: "#8be9fd",
white: "#f8f8f2",
brightBlack: "#6272a4",
brightRed: "#ff6e6e",
brightGreen: "#69ff94",
brightYellow: "#ffffa5",
brightBlue: "#d6acff",
brightMagenta: "#ff92df",
brightCyan: "#a4ffff",
brightWhite: "#ffffff",
},
},
"solarized-dark": {
header: "#073642",
border: "#586e75",
xterm: {
background: "#002b36",
foreground: "#839496",
cursor: "#839496",
cursorAccent: "#002b36",
selectionBackground: "#07364280",
black: "#073642",
red: "#dc322f",
green: "#859900",
yellow: "#b58900",
blue: "#268bd2",
magenta: "#d33682",
cyan: "#2aa198",
white: "#eee8d5",
brightBlack: "#586e75",
brightRed: "#cb4b16",
brightGreen: "#859900",
brightYellow: "#657b83",
brightBlue: "#839496",
brightMagenta: "#6c71c4",
brightCyan: "#93a1a1",
brightWhite: "#fdf6e3",
},
},
gruvbox: {
header: "#1d2021",
border: "#3c3836",
xterm: {
background: "#282828",
foreground: "#ebdbb2",
cursor: "#ebdbb2",
cursorAccent: "#282828",
selectionBackground: "#3c383680",
black: "#282828",
red: "#cc241d",
green: "#98971a",
yellow: "#d79921",
blue: "#458588",
magenta: "#b16286",
cyan: "#689d6a",
white: "#a89984",
brightBlack: "#928374",
brightRed: "#fb4934",
brightGreen: "#b8bb26",
brightYellow: "#fabd2f",
brightBlue: "#83a598",
brightMagenta: "#d3869b",
brightCyan: "#8ec07c",
brightWhite: "#ebdbb2",
},
},
"one-dark": {
header: "#21252b",
border: "#3e4451",
xterm: {
background: "#282c34",
foreground: "#abb2bf",
cursor: "#abb2bf",
cursorAccent: "#282c34",
selectionBackground: "#3e445180",
black: "#282c34",
red: "#e06c75",
green: "#98c379",
yellow: "#e5c07b",
blue: "#61afef",
magenta: "#c678dd",
cyan: "#56b6c2",
white: "#abb2bf",
brightBlack: "#5c6370",
brightRed: "#e06c75",
brightGreen: "#98c379",
brightYellow: "#e5c07b",
brightBlue: "#61afef",
brightMagenta: "#c678dd",
brightCyan: "#56b6c2",
brightWhite: "#ffffff",
},
},
"solarized-light": {
header: "#eee8d5",
border: "#93a1a1",
xterm: {
background: "#fdf6e3",
foreground: "#657b83",
cursor: "#657b83",
cursorAccent: "#fdf6e3",
selectionBackground: "#eee8d5b0",
black: "#073642",
red: "#dc322f",
green: "#859900",
yellow: "#b58900",
blue: "#268bd2",
magenta: "#d33682",
cyan: "#2aa198",
white: "#eee8d5",
brightBlack: "#002b36",
brightRed: "#cb4b16",
brightGreen: "#586e75",
brightYellow: "#657b83",
brightBlue: "#839496",
brightMagenta: "#6c71c4",
brightCyan: "#93a1a1",
brightWhite: "#fdf6e3",
},
},
"catppuccin-latte": {
header: "#e6e9ef",
border: "#ccd0da",
xterm: {
background: "#eff1f5",
foreground: "#4c4f69",
cursor: "#dc8a78",
cursorAccent: "#eff1f5",
selectionBackground: "#acb0be80",
black: "#5c5f77",
red: "#d20f39",
green: "#40a02b",
yellow: "#df8e1d",
blue: "#1e66f5",
magenta: "#ea76cb",
cyan: "#179299",
white: "#acb0be",
brightBlack: "#6c6f85",
brightRed: "#d20f39",
brightGreen: "#40a02b",
brightYellow: "#df8e1d",
brightBlue: "#1e66f5",
brightMagenta: "#ea76cb",
brightCyan: "#179299",
brightWhite: "#bcc0cc",
},
},
"one-light": {
header: "#eaeaeb",
border: "#d4d4d4",
xterm: {
background: "#fafafa",
foreground: "#383a42",
cursor: "#526eff",
cursorAccent: "#fafafa",
selectionBackground: "#e5e5e6b0",
black: "#383a42",
red: "#e45649",
green: "#50a14f",
yellow: "#c18401",
blue: "#4078f2",
magenta: "#a626a4",
cyan: "#0184bc",
white: "#a0a1a7",
brightBlack: "#696c77",
brightRed: "#e45649",
brightGreen: "#50a14f",
brightYellow: "#c18401",
brightBlue: "#4078f2",
brightMagenta: "#a626a4",
brightCyan: "#0184bc",
brightWhite: "#ffffff",
},
},
nord: {
header: "#3b4252",
border: "#4c566a",
xterm: {
background: "#2e3440",
foreground: "#d8dee9",
cursor: "#d8dee9",
cursorAccent: "#2e3440",
selectionBackground: "#434c5e80",
black: "#3b4252",
red: "#bf616a",
green: "#a3be8c",
yellow: "#ebcb8b",
blue: "#81a1c1",
magenta: "#b48ead",
cyan: "#88c0d0",
white: "#e5e9f0",
brightBlack: "#4c566a",
brightRed: "#bf616a",
brightGreen: "#a3be8c",
brightYellow: "#ebcb8b",
brightBlue: "#81a1c1",
brightMagenta: "#b48ead",
brightCyan: "#8fbcbb",
brightWhite: "#eceff4",
},
},
"tokyo-night": {
header: "#16161e",
border: "#292e42",
xterm: {
background: "#1a1b26",
foreground: "#c0caf5",
cursor: "#c0caf5",
cursorAccent: "#1a1b26",
selectionBackground: "#28345780",
black: "#15161e",
red: "#f7768e",
green: "#9ece6a",
yellow: "#e0af68",
blue: "#7aa2f7",
magenta: "#bb9af7",
cyan: "#7dcfff",
white: "#a9b1d6",
brightBlack: "#414868",
brightRed: "#f7768e",
brightGreen: "#9ece6a",
brightYellow: "#e0af68",
brightBlue: "#7aa2f7",
brightMagenta: "#bb9af7",
brightCyan: "#7dcfff",
brightWhite: "#c0caf5",
},
},
monokai: {
header: "#1e1f1c",
border: "#49483e",
xterm: {
background: "#272822",
foreground: "#f8f8f2",
cursor: "#f8f8f2",
cursorAccent: "#272822",
selectionBackground: "#49483e80",
black: "#272822",
red: "#f92672",
green: "#a6e22e",
yellow: "#e6db74",
blue: "#66d9ef",
magenta: "#ae81ff",
cyan: "#a1efe4",
white: "#f8f8f2",
brightBlack: "#75715e",
brightRed: "#f92672",
brightGreen: "#a6e22e",
brightYellow: "#f4bf75",
brightBlue: "#66d9ef",
brightMagenta: "#ae81ff",
brightCyan: "#a1efe4",
brightWhite: "#f9f8f5",
},
},
"rose-pine": {
header: "#1f1d2e",
border: "#26233a",
xterm: {
background: "#191724",
foreground: "#e0def4",
cursor: "#e0def4",
cursorAccent: "#191724",
selectionBackground: "#403d5280",
black: "#26233a",
red: "#eb6f92",
green: "#31748f",
yellow: "#f6c177",
blue: "#9ccfd8",
magenta: "#c4a7e7",
cyan: "#ebbcba",
white: "#e0def4",
brightBlack: "#6e6a86",
brightRed: "#eb6f92",
brightGreen: "#31748f",
brightYellow: "#f6c177",
brightBlue: "#9ccfd8",
brightMagenta: "#c4a7e7",
brightCyan: "#ebbcba",
brightWhite: "#e0def4",
},
},
everforest: {
header: "#232a2e",
border: "#475258",
xterm: {
background: "#2d353b",
foreground: "#d3c6aa",
cursor: "#d3c6aa",
cursorAccent: "#2d353b",
selectionBackground: "#47525880",
black: "#475258",
red: "#e67e80",
green: "#a7c080",
yellow: "#dbbc7f",
blue: "#7fbbb3",
magenta: "#d699b6",
cyan: "#83c092",
white: "#d3c6aa",
brightBlack: "#859289",
brightRed: "#e67e80",
brightGreen: "#a7c080",
brightYellow: "#dbbc7f",
brightBlue: "#7fbbb3",
brightMagenta: "#d699b6",
brightCyan: "#83c092",
brightWhite: "#d3c6aa",
},
},
};
interface Project {
name: string;
@@ -538,7 +184,6 @@ term.focus();
// ein eigenes Fenster abgelöst (`panel-detached`).
const panel = document.getElementById("panel")!;
const splitter = document.getElementById("splitter")!;
const panelContent = document.getElementById("panel-content")!;
// Panelfarben ans Theme koppeln.
panel.style.background = picked.header;
@@ -547,18 +192,6 @@ splitter.style.background = picked.border;
(panel.querySelector(".panel-head") as HTMLElement).style.borderBottomColor =
picked.border;
const view = initPanelView({
content: panelContent,
copyBtn: document.getElementById("panel-copy")!,
modeBtn: document.getElementById("panel-mode")!,
titleEl: panel.querySelector(".panel-title") as HTMLElement,
editBtn: document.getElementById("panel-title-edit")!,
editContentBtn: document.getElementById("panel-content-edit")!,
langSelect: document.getElementById("panel-lang") as HTMLSelectElement,
defaultLang: await invoke<string>("spellcheck_lang"),
onCommit: (text) => invoke("panel_set", { project, text }),
});
let detached = false;
let hasContent = false;
@@ -573,11 +206,19 @@ function hidePanel() {
fit.fit();
}
await listen<string>("panel-update", (e) => {
view.set(e.payload);
// Gemeinsame Verdrahtung (Views, Modi, Update-Events); jedes eingehende
// Update blendet das angedockte Panel ein, solange es nicht abgelöst ist.
const { view } = await wirePanel(project, () => {
hasContent = true;
if (!detached) showPanel();
});
// Tab-Klick im Header öffnet das (angedockte) Panel, falls es zu ist.
document.getElementById("panel-tabs")!.addEventListener("click", () => {
hasContent = true;
if (!detached) showPanel();
});
await listen("panel-detached", () => {
detached = true;
hidePanel();
@@ -599,10 +240,6 @@ document.getElementById("panel-hide")!.addEventListener("click", hidePanel);
// Archivieren: konfigurierten Ordner nehmen, sonst per Dialog wählen (setzt ihn).
const archiveBtn = document.getElementById("panel-archive")!;
function flashBtn(btn: HTMLElement, cls: string) {
btn.classList.add(cls);
setTimeout(() => btn.classList.remove(cls), 1400);
}
archiveBtn.addEventListener("click", async () => {
try {
const configured = await invoke<string | null>("panel_archive_dir_cmd", {
@@ -615,11 +252,13 @@ archiveBtn.addEventListener("click", async () => {
if (!chosen) return;
dir = chosen as string;
}
await view.flush(); // offene Bearbeitung erst speichern — archiviert, was zu sehen ist
const path = await invoke<string>("panel_archive_cmd", { project, dir });
flashBtn(archiveBtn, "copied");
flash(archiveBtn, "copied", 1400);
invoke("reveal_path_cmd", { path });
} catch (e) {
flashBtn(archiveBtn, "error");
flash(archiveBtn, "error", 1400);
panelToast(`Archivieren fehlgeschlagen: ${e}`);
invoke("term_log", { msg: `archive: ${e}` });
}
});
+359
View File
@@ -0,0 +1,359 @@
/// Farbschemata der Fenster: Header-/Rahmenfarbe plus xterm-Theme —
/// genutzt vom Terminal-Fenster (Header, angedocktes Panel) und vom
/// abgelösten Panel-Fenster (panel.ts), damit beide gleich aussehen.
export const THEMES: Record<
string,
{ header: string; border: string; xterm: Record<string, string> }
> = {
mocha: {
header: "#181825",
border: "#313244",
xterm: {
background: "#1e1e2e",
foreground: "#cdd6f4",
cursor: "#f5e0dc",
cursorAccent: "#1e1e2e",
selectionBackground: "#585b7080",
black: "#45475a",
red: "#f38ba8",
green: "#a6e3a1",
yellow: "#f9e2af",
blue: "#89b4fa",
magenta: "#f5c2e7",
cyan: "#94e2d5",
white: "#bac2de",
brightBlack: "#585b70",
brightRed: "#f38ba8",
brightGreen: "#a6e3a1",
brightYellow: "#f9e2af",
brightBlue: "#89b4fa",
brightMagenta: "#f5c2e7",
brightCyan: "#94e2d5",
brightWhite: "#a6adc8",
},
},
dracula: {
header: "#21222c",
border: "#44475a",
xterm: {
background: "#282a36",
foreground: "#f8f8f2",
cursor: "#f8f8f2",
cursorAccent: "#282a36",
selectionBackground: "#44475a80",
black: "#21222c",
red: "#ff5555",
green: "#50fa7b",
yellow: "#f1fa8c",
blue: "#bd93f9",
magenta: "#ff79c6",
cyan: "#8be9fd",
white: "#f8f8f2",
brightBlack: "#6272a4",
brightRed: "#ff6e6e",
brightGreen: "#69ff94",
brightYellow: "#ffffa5",
brightBlue: "#d6acff",
brightMagenta: "#ff92df",
brightCyan: "#a4ffff",
brightWhite: "#ffffff",
},
},
"solarized-dark": {
header: "#073642",
border: "#586e75",
xterm: {
background: "#002b36",
foreground: "#839496",
cursor: "#839496",
cursorAccent: "#002b36",
selectionBackground: "#07364280",
black: "#073642",
red: "#dc322f",
green: "#859900",
yellow: "#b58900",
blue: "#268bd2",
magenta: "#d33682",
cyan: "#2aa198",
white: "#eee8d5",
brightBlack: "#586e75",
brightRed: "#cb4b16",
brightGreen: "#859900",
brightYellow: "#657b83",
brightBlue: "#839496",
brightMagenta: "#6c71c4",
brightCyan: "#93a1a1",
brightWhite: "#fdf6e3",
},
},
gruvbox: {
header: "#1d2021",
border: "#3c3836",
xterm: {
background: "#282828",
foreground: "#ebdbb2",
cursor: "#ebdbb2",
cursorAccent: "#282828",
selectionBackground: "#3c383680",
black: "#282828",
red: "#cc241d",
green: "#98971a",
yellow: "#d79921",
blue: "#458588",
magenta: "#b16286",
cyan: "#689d6a",
white: "#a89984",
brightBlack: "#928374",
brightRed: "#fb4934",
brightGreen: "#b8bb26",
brightYellow: "#fabd2f",
brightBlue: "#83a598",
brightMagenta: "#d3869b",
brightCyan: "#8ec07c",
brightWhite: "#ebdbb2",
},
},
"one-dark": {
header: "#21252b",
border: "#3e4451",
xterm: {
background: "#282c34",
foreground: "#abb2bf",
cursor: "#abb2bf",
cursorAccent: "#282c34",
selectionBackground: "#3e445180",
black: "#282c34",
red: "#e06c75",
green: "#98c379",
yellow: "#e5c07b",
blue: "#61afef",
magenta: "#c678dd",
cyan: "#56b6c2",
white: "#abb2bf",
brightBlack: "#5c6370",
brightRed: "#e06c75",
brightGreen: "#98c379",
brightYellow: "#e5c07b",
brightBlue: "#61afef",
brightMagenta: "#c678dd",
brightCyan: "#56b6c2",
brightWhite: "#ffffff",
},
},
"solarized-light": {
header: "#eee8d5",
border: "#93a1a1",
xterm: {
background: "#fdf6e3",
foreground: "#657b83",
cursor: "#657b83",
cursorAccent: "#fdf6e3",
selectionBackground: "#eee8d5b0",
black: "#073642",
red: "#dc322f",
green: "#859900",
yellow: "#b58900",
blue: "#268bd2",
magenta: "#d33682",
cyan: "#2aa198",
white: "#eee8d5",
brightBlack: "#002b36",
brightRed: "#cb4b16",
brightGreen: "#586e75",
brightYellow: "#657b83",
brightBlue: "#839496",
brightMagenta: "#6c71c4",
brightCyan: "#93a1a1",
brightWhite: "#fdf6e3",
},
},
"catppuccin-latte": {
header: "#e6e9ef",
border: "#ccd0da",
xterm: {
background: "#eff1f5",
foreground: "#4c4f69",
cursor: "#dc8a78",
cursorAccent: "#eff1f5",
selectionBackground: "#acb0be80",
black: "#5c5f77",
red: "#d20f39",
green: "#40a02b",
yellow: "#df8e1d",
blue: "#1e66f5",
magenta: "#ea76cb",
cyan: "#179299",
white: "#acb0be",
brightBlack: "#6c6f85",
brightRed: "#d20f39",
brightGreen: "#40a02b",
brightYellow: "#df8e1d",
brightBlue: "#1e66f5",
brightMagenta: "#ea76cb",
brightCyan: "#179299",
brightWhite: "#bcc0cc",
},
},
"one-light": {
header: "#eaeaeb",
border: "#d4d4d4",
xterm: {
background: "#fafafa",
foreground: "#383a42",
cursor: "#526eff",
cursorAccent: "#fafafa",
selectionBackground: "#e5e5e6b0",
black: "#383a42",
red: "#e45649",
green: "#50a14f",
yellow: "#c18401",
blue: "#4078f2",
magenta: "#a626a4",
cyan: "#0184bc",
white: "#a0a1a7",
brightBlack: "#696c77",
brightRed: "#e45649",
brightGreen: "#50a14f",
brightYellow: "#c18401",
brightBlue: "#4078f2",
brightMagenta: "#a626a4",
brightCyan: "#0184bc",
brightWhite: "#ffffff",
},
},
nord: {
header: "#3b4252",
border: "#4c566a",
xterm: {
background: "#2e3440",
foreground: "#d8dee9",
cursor: "#d8dee9",
cursorAccent: "#2e3440",
selectionBackground: "#434c5e80",
black: "#3b4252",
red: "#bf616a",
green: "#a3be8c",
yellow: "#ebcb8b",
blue: "#81a1c1",
magenta: "#b48ead",
cyan: "#88c0d0",
white: "#e5e9f0",
brightBlack: "#4c566a",
brightRed: "#bf616a",
brightGreen: "#a3be8c",
brightYellow: "#ebcb8b",
brightBlue: "#81a1c1",
brightMagenta: "#b48ead",
brightCyan: "#8fbcbb",
brightWhite: "#eceff4",
},
},
"tokyo-night": {
header: "#16161e",
border: "#292e42",
xterm: {
background: "#1a1b26",
foreground: "#c0caf5",
cursor: "#c0caf5",
cursorAccent: "#1a1b26",
selectionBackground: "#28345780",
black: "#15161e",
red: "#f7768e",
green: "#9ece6a",
yellow: "#e0af68",
blue: "#7aa2f7",
magenta: "#bb9af7",
cyan: "#7dcfff",
white: "#a9b1d6",
brightBlack: "#414868",
brightRed: "#f7768e",
brightGreen: "#9ece6a",
brightYellow: "#e0af68",
brightBlue: "#7aa2f7",
brightMagenta: "#bb9af7",
brightCyan: "#7dcfff",
brightWhite: "#c0caf5",
},
},
monokai: {
header: "#1e1f1c",
border: "#49483e",
xterm: {
background: "#272822",
foreground: "#f8f8f2",
cursor: "#f8f8f2",
cursorAccent: "#272822",
selectionBackground: "#49483e80",
black: "#272822",
red: "#f92672",
green: "#a6e22e",
yellow: "#e6db74",
blue: "#66d9ef",
magenta: "#ae81ff",
cyan: "#a1efe4",
white: "#f8f8f2",
brightBlack: "#75715e",
brightRed: "#f92672",
brightGreen: "#a6e22e",
brightYellow: "#f4bf75",
brightBlue: "#66d9ef",
brightMagenta: "#ae81ff",
brightCyan: "#a1efe4",
brightWhite: "#f9f8f5",
},
},
"rose-pine": {
header: "#1f1d2e",
border: "#26233a",
xterm: {
background: "#191724",
foreground: "#e0def4",
cursor: "#e0def4",
cursorAccent: "#191724",
selectionBackground: "#403d5280",
black: "#26233a",
red: "#eb6f92",
green: "#31748f",
yellow: "#f6c177",
blue: "#9ccfd8",
magenta: "#c4a7e7",
cyan: "#ebbcba",
white: "#e0def4",
brightBlack: "#6e6a86",
brightRed: "#eb6f92",
brightGreen: "#31748f",
brightYellow: "#f6c177",
brightBlue: "#9ccfd8",
brightMagenta: "#c4a7e7",
brightCyan: "#ebbcba",
brightWhite: "#e0def4",
},
},
everforest: {
header: "#232a2e",
border: "#475258",
xterm: {
background: "#2d353b",
foreground: "#d3c6aa",
cursor: "#d3c6aa",
cursorAccent: "#2d353b",
selectionBackground: "#47525880",
black: "#475258",
red: "#e67e80",
green: "#a7c080",
yellow: "#dbbc7f",
blue: "#7fbbb3",
magenta: "#d699b6",
cyan: "#83c092",
white: "#d3c6aa",
brightBlack: "#859289",
brightRed: "#e67e80",
brightGreen: "#a7c080",
brightYellow: "#dbbc7f",
brightBlue: "#7fbbb3",
brightMagenta: "#d699b6",
brightCyan: "#83c092",
brightWhite: "#d3c6aa",
},
},
};
+226
View File
@@ -0,0 +1,226 @@
/// Wiki-Ansicht des Panels: rendert den Wiki-Puffer (show_archive/wiki_open).
/// Übersichts- und Schlagwort-Seiten kommen als strukturierte Daten (Kopfzeile,
/// Schlagwort-Chips, Ordner-Sektionen mit Dokumentzeilen), Dokumente als
/// gerendertes Markdown mit Backlinks. Titel, Beschreibungen und Pfade sind
/// Fremdtext und gehen nie durch innerHTML; nur der Markdown-Rumpf läuft wie
/// im Entwurf durch marked.
import { marked } from "marked";
import { linkWikiRefs } from "./panel-view";
interface DocEntry {
name: string;
title: string;
description?: string | null;
tags: string[];
date?: string | null;
}
interface Folder {
name: string;
docs: DocEntry[];
}
interface Page {
kind: "page";
home: string;
tag?: string | null;
total: number;
tags: { name: string; count: number }[];
folders: Folder[];
}
interface DocPage {
kind: "doc";
home: string;
relpath: string;
name: string;
title: string;
tags: string[];
backlinks: string[];
markdown: string;
}
export interface WikiView {
set(text: string): void;
/// Noch keine Seite im Puffer (Session-Start)?
empty(): boolean;
}
export function initWikiView(
container: HTMLElement,
onLink: (name: string) => void,
): WikiView {
function chip(label: string, target: string, active = false): HTMLElement {
const b = document.createElement("button");
b.className = "wiki-chip" + (active ? " active" : "");
b.textContent = label;
b.addEventListener("click", (e) => {
e.stopPropagation();
onLink(target);
});
return b;
}
/// Kopfzeile einer Seite: Titel + Home-Pfad links, Dokumentzahl rechts.
function pageHead(p: Page): HTMLElement {
const head = document.createElement("div");
head.className = "wiki-head";
const left = document.createElement("div");
left.className = "wiki-head-left";
const title = document.createElement("div");
title.className = "wiki-head-title";
title.textContent = p.tag ? `#${p.tag}` : "Archiv";
const sub = document.createElement("div");
sub.className = "wiki-head-sub";
sub.textContent = p.home;
left.append(title, sub);
const count = document.createElement("div");
count.className = "wiki-head-right";
count.textContent = `${p.total} ${p.total === 1 ? "Dokument" : "Dokumente"}`;
head.append(left, count);
return head;
}
/// Schlagwort-Leiste: „Alle“ plus ein Chip pro Schlagwort mit Zähler; der
/// aktive Filter ist markiert.
function tagBar(p: Page): HTMLElement {
const bar = document.createElement("div");
bar.className = "wiki-chips";
bar.append(chip("Alle", "tag:", !p.tag));
for (const t of p.tags) {
bar.append(chip(`#${t.name} ${t.count}`, `tag:${t.name}`, p.tag === t.name));
}
return bar;
}
function docRow(doc: DocEntry): HTMLElement {
const row = document.createElement("div");
row.className = "wiki-doc";
const line = document.createElement("div");
line.className = "wiki-doc-line";
const title = document.createElement("div");
title.className = "wiki-doc-title";
title.textContent = doc.title;
line.append(title);
if (doc.date) {
const date = document.createElement("div");
date.className = "wiki-doc-date";
date.textContent = doc.date;
line.append(date);
}
row.append(line);
if (doc.description) {
const desc = document.createElement("div");
desc.className = "wiki-doc-desc";
desc.textContent = doc.description;
row.append(desc);
}
if (doc.tags.length) {
const tags = document.createElement("div");
tags.className = "wiki-doc-tags";
for (const t of doc.tags) tags.append(chip(`#${t}`, `tag:${t}`));
row.append(tags);
}
row.addEventListener("click", () => onLink(doc.name));
return row;
}
function renderPage(p: Page) {
container.append(pageHead(p));
if (p.tags.length) container.append(tagBar(p));
if (p.total === 0) {
const empty = document.createElement("div");
empty.className = "wiki-empty";
const line = document.createElement("strong");
line.textContent = p.tag ? `Keine Dokumente mit #${p.tag}.` : "Das Archiv ist leer.";
empty.append(line);
if (!p.tag) {
empty.append(
"Archivieren: Archiv-Button im Entwurf oder „archiviere das“ im Chat — mit Ordner, Beschreibung und Schlagwörtern.",
);
}
container.append(empty);
return;
}
for (const folder of p.folders) {
if (folder.name) {
const eyebrow = document.createElement("div");
eyebrow.className = "wiki-folder";
eyebrow.textContent = `${folder.name}/`;
container.append(eyebrow);
}
for (const doc of folder.docs) container.append(docRow(doc));
}
}
function renderDoc(d: DocPage) {
const head = document.createElement("div");
head.className = "wiki-doc-head";
const back = document.createElement("button");
back.className = "wiki-back";
back.textContent = " Archiv";
back.addEventListener("click", () => onLink("tag:"));
const path = document.createElement("div");
path.className = "wiki-head-sub";
path.textContent = d.relpath;
head.append(back, path);
container.append(head);
if (d.tags.length) {
const tags = document.createElement("div");
tags.className = "wiki-chips";
for (const t of d.tags) tags.append(chip(`#${t}`, `tag:${t}`));
container.append(tags);
}
const body = document.createElement("div");
body.className = "wiki-body";
body.innerHTML = marked.parse(d.markdown, { async: false });
linkWikiRefs(body, onLink);
container.append(body);
if (d.backlinks.length) {
const back = document.createElement("div");
back.className = "wiki-backlinks";
back.append("Verweise hierher: ");
d.backlinks.forEach((name, i) => {
if (i) back.append(" · ");
const a = document.createElement("a");
a.href = "#";
a.className = "wiki";
a.textContent = name;
a.addEventListener("click", (e) => {
e.preventDefault();
onLink(name);
});
back.append(a);
});
container.append(back);
}
}
/// Leerer Puffer: Einstieg statt leerer Fläche.
function renderIntro() {
const empty = document.createElement("div");
empty.className = "wiki-empty";
const line = document.createElement("strong");
line.textContent = "Keine Wiki-Seite geladen.";
empty.append(line);
empty.append(chip("Archiv-Übersicht öffnen", "tag:"));
container.append(empty);
}
let loaded = false;
return {
set(text: string) {
container.textContent = "";
loaded = !!text.trim();
if (!loaded) {
renderIntro();
return;
}
const data: Page | DocPage = JSON.parse(text);
if (data.kind === "doc") renderDoc(data);
else renderPage(data);
},
empty: () => !loaded,
};
}