Panel: Entwürfe archivieren, Titel aus Überschrift, Titel-Edit

- Archiv pro Projekt: Panel-Button „Archivieren" und MCP-Tool archive_panel
  legen den Entwurf als <YYYY-MM-DD_HHMM>-<slug>.md mit Frontmatter im
  Archiv-Ordner ab. Ordner in ai-control.json (archiveDir) + als
  additionalDirectories/Edit in .claude/settings.json (scanbar). Kein Ordner:
  Panel öffnet Ordner-Dialog, Zuruf nutzt Argument dir.
- Panel-Titel zeigt die erste Überschrift des Entwurfs (Inline-Markdown
  gestrippt), Fallback „Entwurf"; Bleistift-Button macht ihn editierbar,
  schreibt die Überschrift via panel_set zurück.
- Panel-Kopf zweizeilig: Titel + Edit oben, Aktionen rechtsbündig darunter.
- AI_CONTROL_PROJECT als PTY-Env für den MCP-Server.
This commit is contained in:
marcus.hinz
2026-07-11 13:40:17 +02:00
parent 8aa758ae64
commit e2e9992b1c
8 changed files with 508 additions and 44 deletions
+85
View File
@@ -7,10 +7,59 @@ export interface PanelView {
/// Verkabelt einen Panel-Inhaltsbereich: MD/Roh-Umschalter und Copy-Button.
/// Der Rohtext bleibt die Quelle für „Kopieren"; die Ansicht rendert Markdown.
/// Entfernt Inline-Markdown (Emphasis, Code, Links), damit der Plain-Text-Titel
/// nicht die rohen Marker zeigt.
function stripInlineMd(s: string): string {
return s
.replace(/`([^`]*)`/g, "$1")
.replace(/\*\*([^*]+)\*\*/g, "$1")
.replace(/\*([^*]+)\*/g, "$1")
.replace(/__([^_]+)__/g, "$1")
.replace(/_([^_]+)_/g, "$1")
.replace(/~~([^~]+)~~/g, "$1")
.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
.trim();
}
/// Titel für den Panel-Kopf: erste Überschrift (# …) oder sonst erste
/// nicht-leere Zeile, ohne Inline-Markdown; „Entwurf" bei leerem Text.
function firstLine(text: string): string {
let fallback = "";
for (const line of text.split("\n")) {
const t = line.trim();
if (!t) continue;
const h = t.replace(/^#+/, "").trim();
if (t.startsWith("#") && h) return stripInlineMd(h);
if (!fallback) fallback = t;
}
return fallback ? stripInlineMd(fallback) : "Entwurf";
}
/// Ersetzt die erste Überschrift (bzw. legt eine an) im Rohtext durch `title`.
function setHeading(text: string, title: string): string {
const lines = text.split("\n");
for (let i = 0; i < lines.length; i++) {
const t = lines[i].trim();
if (!t) continue;
if (t.startsWith("#")) {
const hashes = (t.match(/^#+/) as RegExpMatchArray)[0];
lines[i] = `${hashes} ${title}`;
} else {
lines.splice(i, 0, `# ${title}`, "");
}
return lines.join("\n");
}
return `# ${title}\n`;
}
export function initPanelView(opts: {
content: HTMLElement;
copyBtn: HTMLElement;
modeBtn: HTMLElement;
titleEl?: HTMLElement;
editBtn?: HTMLElement;
/// Wird mit dem neuen Rohtext aufgerufen, wenn der Titel geändert wurde.
onCommit?: (text: string) => void;
}): PanelView {
let rawText = "";
let rendered = true;
@@ -42,10 +91,46 @@ export function initPanelView(opts: {
}, 1200);
});
// Titel-Edit: Edit-Button macht den Titel editierbar, Enter/Blur schreibt die
// geänderte Überschrift zurück, Escape verwirft.
const titleEl = opts.titleEl;
if (opts.editBtn && titleEl && opts.onCommit) {
const commit = () => {
if (titleEl.getAttribute("contenteditable") !== "true") return;
titleEl.removeAttribute("contenteditable");
const nt = (titleEl.textContent || "").trim();
if (nt && nt !== firstLine(rawText)) opts.onCommit!(setHeading(rawText, nt));
else titleEl.textContent = firstLine(rawText);
};
opts.editBtn.addEventListener("click", () => {
titleEl.setAttribute("contenteditable", "true");
titleEl.focus();
const r = document.createRange();
r.selectNodeContents(titleEl);
const sel = window.getSelection();
sel?.removeAllRanges();
sel?.addRange(r);
});
titleEl.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
titleEl.blur();
} else if (e.key === "Escape") {
titleEl.textContent = firstLine(rawText);
titleEl.removeAttribute("contenteditable");
titleEl.blur();
}
});
titleEl.addEventListener("blur", commit);
}
draw();
return {
set(text: string) {
rawText = text;
if (titleEl && titleEl.getAttribute("contenteditable") !== "true") {
titleEl.textContent = firstLine(text);
}
draw();
},
raw: () => rawText,
+30
View File
@@ -14,12 +14,42 @@ 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")!,
onCommit: (text) => invoke("panel_set", { project, text }),
});
view.set(await invoke<string>("panel_read", { project }));
await listen<string>("panel-update", (e) => view.set(e.payload));
// Archivieren: wie im angedockten Panel — Ordner nehmen oder per Dialog wählen.
const archiveBtn = document.getElementById("panel-archive")!;
archiveBtn.addEventListener("click", async () => {
const orig = archiveBtn.textContent;
try {
const configured = await invoke<string | null>("panel_archive_dir_cmd", {
project,
});
let dir: string | undefined;
if (!configured) {
const { open } = await import("@tauri-apps/plugin-dialog");
const chosen = await open({ directory: true, title: "Archiv-Ordner wählen" });
if (!chosen) return;
dir = chosen as string;
}
await invoke<string>("panel_archive_cmd", { project, dir });
archiveBtn.textContent = "Archiviert";
archiveBtn.classList.add("copied");
} catch {
archiveBtn.textContent = "Fehler";
}
setTimeout(() => {
archiveBtn.textContent = orig;
archiveBtn.classList.remove("copied");
}, 1400);
});
// „Andocken": Fenster schließen — terminal.rs meldet `panel-attached`, das
// angedockte Panel im Terminal-Fenster erscheint wieder.
document
+31
View File
@@ -551,6 +551,9 @@ 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")!,
onCommit: (text) => invoke("panel_set", { project, text }),
});
let detached = false;
@@ -586,6 +589,34 @@ document
.addEventListener("click", () => invoke("open_panel_window", { project }));
document.getElementById("panel-hide")!.addEventListener("click", hidePanel);
// Archivieren: konfigurierten Ordner nehmen, sonst per Dialog wählen (setzt ihn).
const archiveBtn = document.getElementById("panel-archive")!;
archiveBtn.addEventListener("click", async () => {
const orig = archiveBtn.textContent;
try {
const configured = await invoke<string | null>("panel_archive_dir_cmd", {
project,
});
let dir: string | undefined;
if (!configured) {
const { open } = await import("@tauri-apps/plugin-dialog");
const chosen = await open({ directory: true, title: "Archiv-Ordner wählen" });
if (!chosen) return;
dir = chosen as string;
}
await invoke<string>("panel_archive_cmd", { project, dir });
archiveBtn.textContent = "Archiviert";
archiveBtn.classList.add("copied");
} catch (e) {
archiveBtn.textContent = "Fehler";
invoke("term_log", { msg: `archive: ${e}` });
}
setTimeout(() => {
archiveBtn.textContent = orig;
archiveBtn.classList.remove("copied");
}, 1400);
});
// Splitter: Panelbreite ziehen.
splitter.addEventListener("mousedown", (e) => {
e.preventDefault();