From e2e9992b1c6be0b5fd6be14fd32b0f541e82b718 Mon Sep 17 00:00:00 2001 From: "marcus.hinz" Date: Sat, 11 Jul 2026 13:40:17 +0200 Subject: [PATCH] =?UTF-8?q?Panel:=20Entw=C3=BCrfe=20archivieren,=20Titel?= =?UTF-8?q?=20aus=20=C3=9Cberschrift,=20Titel-Edit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Archiv pro Projekt: Panel-Button „Archivieren" und MCP-Tool archive_panel legen den Entwurf als -.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. --- panel.html | 48 +++++++-- src-tauri/src/lib.rs | 214 +++++++++++++++++++++++++++++++++++++- src-tauri/src/mcp.rs | 88 ++++++++++++---- src-tauri/src/terminal.rs | 8 ++ src/panel-view.ts | 85 +++++++++++++++ src/panel.ts | 30 ++++++ src/terminal.ts | 31 ++++++ terminal.html | 48 +++++++-- 8 files changed, 508 insertions(+), 44 deletions(-) diff --git a/panel.html b/panel.html index c12ee15..24e22b6 100644 --- a/panel.html +++ b/panel.html @@ -18,11 +18,10 @@ } .panel-head { flex: none; - height: 38px; display: flex; - align-items: center; - gap: 6px; - padding: 0 8px 0 14px; + flex-direction: column; + gap: 3px; + padding: 7px 8px 9px 14px; background: #181825; border-bottom: 1px solid #313244; font: @@ -32,10 +31,33 @@ user-select: none; -webkit-user-select: none; } + .panel-titlerow { + display: flex; + align-items: center; + gap: 2px; + min-width: 0; + } + .panel-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 2px; + } .panel-title { - margin-right: auto; + flex: 0 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; color: #a6adc8; } + .panel-title[contenteditable="true"] { + overflow: visible; + text-overflow: clip; + outline: none; + color: inherit; + cursor: text; + } .panel-btn { border: none; background: transparent; @@ -131,11 +153,17 @@ -
- Entwurf - - - +
+
+ Entwurf + +
+
+ + + + +
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 00a3c80..7419359 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -72,6 +72,160 @@ pub(crate) fn panel_file(project: &str) -> PathBuf { Paths::real().panels_dir().join(format!("{project}.md")) } +// ---------- Archiv (Panel-Entwürfe persistieren) ---------- + +/// Konfigurierter Archiv-Ordner des Projekts (ai-control.json: archiveDir), +/// Home-expandiert; None, wenn nicht gesetzt. +pub(crate) fn project_archive_dir(project: &str) -> Option { + let paths = Paths::real(); + let dir = read_project_config_in(&paths, project).ok()?.archive_dir?; + Some(expand_home(&paths, &dir)) +} + +/// Setzt den Archiv-Ordner: ai-control.json (~-relativ) und ein Eintrag in +/// permissions.additionalDirectories + Edit der Projekt-settings.json, damit +/// claude das Archiv später lesen/scannen darf. +pub(crate) fn set_project_archive_dir(project: &str, dir: &str) -> Result<(), String> { + let paths = Paths::real(); + let expanded = expand_home(&paths, dir); + fs::create_dir_all(&expanded).map_err(|e| format!("{}: {e}", expanded.display()))?; + let contracted = contract_home(&paths, &expanded); + let mut cfg = read_project_config_in(&paths, project)?; + cfg.archive_dir = Some(contracted.clone()); + write_project_config_in(&paths, project, &cfg)?; + add_archive_permission(&paths, project, &contracted) +} + +/// Trägt den Archiv-Ordner idempotent in additionalDirectories + Edit-Allow ein. +fn add_archive_permission(paths: &Paths, project: &str, dir: &str) -> Result<(), String> { + let sp = settings_path(&project_dir(paths, project)?); + let mut v: serde_json::Value = if sp.is_file() { + serde_json::from_str(&fs::read_to_string(&sp).map_err(|e| e.to_string())?) + .map_err(|e| format!("{}: {e}", sp.display()))? + } else { + serde_json::json!({}) + }; + let root = v.as_object_mut().ok_or("settings.json ist kein Objekt")?; + let perms = root + .entry("permissions") + .or_insert_with(|| serde_json::json!({})) + .as_object_mut() + .ok_or("permissions ist kein Objekt")?; + let dirs = perms + .entry("additionalDirectories") + .or_insert_with(|| serde_json::json!([])) + .as_array_mut() + .ok_or("additionalDirectories ist kein Array")?; + if !dirs.iter().any(|d| d.as_str() == Some(dir)) { + dirs.push(serde_json::json!(dir)); + } + let edit = format!("Edit({dir}/**)"); + let allow = perms + .entry("allow") + .or_insert_with(|| serde_json::json!([])) + .as_array_mut() + .ok_or("allow ist kein Array")?; + if !allow.iter().any(|p| p.as_str() == Some(&edit)) { + allow.push(serde_json::json!(edit)); + } + let parent = sp.parent().ok_or("settings.json ohne Elternordner")?; + fs::create_dir_all(parent).map_err(|e| e.to_string())?; + let raw = serde_json::to_string_pretty(&v).map_err(|e| e.to_string())?; + fs::write(&sp, raw + "\n").map_err(|e| format!("{}: {e}", sp.display())) +} + +/// Archiviert den aktuellen Panel-Inhalt des Projekts als Markdown-Datei mit +/// Frontmatter im Archiv-Ordner. `dir_override` setzt den Ordner zugleich +/// (Terminal-Fallback ohne Dialog). Liefert den geschriebenen Pfad. +pub(crate) fn archive_panel_content( + project: &str, + dir_override: Option<&str>, +) -> Result { + if let Some(d) = dir_override { + set_project_archive_dir(project, d)?; + } + let dir = project_archive_dir(project).ok_or("kein Archiv-Ordner gesetzt")?; + let text = fs::read_to_string(panel_file(project)).unwrap_or_default(); + if text.trim().is_empty() { + return Err("Panel ist leer — nichts zu archivieren".into()); + } + fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?; + + let secs = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|e| e.to_string())? + .as_secs(); + let (stamp, iso) = utc_stamp(secs); + let title = first_line(&text); + let path = dir.join(format!("{stamp}-{}.md", slugify(&title))); + let doc = format!( + "---\ntitle: \"{}\"\nproject: {project}\ncreated: {iso}\nsource: ai-control\n---\n\n{}\n", + title.replace('"', "'"), + text.trim_end(), + ); + fs::write(&path, doc).map_err(|e| format!("{}: {e}", path.display()))?; + Ok(path) +} + +/// Titelzeile: erste Überschrift (## …) oder sonst erste nicht-leere Zeile. +fn first_line(text: &str) -> String { + let mut fallback: Option<&str> = None; + for line in text.lines() { + let t = line.trim(); + if t.is_empty() { + continue; + } + let h = t.trim_start_matches('#').trim(); + if t.starts_with('#') && !h.is_empty() { + return h.to_string(); + } + fallback.get_or_insert(t); + } + fallback.unwrap_or("entwurf").to_string() +} + +/// Dateinamen-tauglicher Slug (ascii, klein, Bindestriche, max. 60 Zeichen). +fn slugify(s: &str) -> String { + let mut out = String::new(); + for c in s.chars() { + if c.is_ascii_alphanumeric() { + out.push(c.to_ascii_lowercase()); + } else if !out.ends_with('-') { + out.push('-'); + } + } + let out: String = out.trim_matches('-').chars().take(60).collect(); + if out.is_empty() { + "entwurf".into() + } else { + out + } +} + +/// UTC aus Epoch-Sekunden: (Dateistempel `YYYY-MM-DD_HHMM`, ISO `…Z`). +fn utc_stamp(secs: u64) -> (String, String) { + let (h, mi, s) = (secs / 3600 % 24, secs / 60 % 60, secs % 60); + let (y, m, d) = civil_from_days((secs / 86400) as i64); + ( + format!("{y:04}-{m:02}-{d:02}_{h:02}{mi:02}"), + format!("{y:04}-{m:02}-{d:02}T{h:02}:{mi:02}:{s:02}Z"), + ) +} + +/// Kalenderdatum aus Tagen seit 1970-01-01 (Howard-Hinnant-Algorithmus). +fn civil_from_days(z: i64) -> (i64, u32, u32) { + let z = z + 719468; + let era = (if z >= 0 { z } else { z - 146096 }) / 146097; + let doe = z - era * 146097; + let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; + let y = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = (doy - (153 * mp + 2) / 5 + 1) as u32; + let m = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32; + (if m <= 2 { y + 1 } else { y }, m, d) +} + // ---------- Projekt-Registry ---------- /// Pfad unterhalb von Home als "~/…" schreiben — Registry-Einträge im @@ -167,6 +321,13 @@ struct ProjectConfig { pool: Option, #[serde(default, skip_serializing_if = "TerminalConfig::is_empty")] terminal: TerminalConfig, + /// Zielordner fürs Archivieren von Panel-Entwürfen (~-relativ gespeichert). + #[serde( + default, + rename = "archiveDir", + skip_serializing_if = "Option::is_none" + )] + archive_dir: Option, } #[derive(Serialize, Deserialize, Default, Clone)] @@ -550,7 +711,7 @@ fn create_project_full_in( reg.insert(name.to_string(), dir.clone()); save_registry(paths, ®)?; - let cfg = ProjectConfig { pool: pool.map(str::to_string), terminal }; + let cfg = ProjectConfig { pool: pool.map(str::to_string), terminal, archive_dir: None }; if cfg.pool.is_some() || !cfg.terminal.is_empty() { write_project_config_in(paths, name, &cfg)?; } @@ -789,7 +950,7 @@ fn assign_pool_in(paths: &Paths, project: &str, pool: &str) -> Result<(), String fn unassign_pool_in(paths: &Paths, project: &str) -> Result<(), String> { let mut cfg = read_project_config_in(paths, project)?; cfg.pool = None; - if cfg.terminal.is_empty() { + if cfg.terminal.is_empty() && cfg.archive_dir.is_none() { let cfg_path = project_config_path(paths, project)?; return fs::remove_file(&cfg_path).map_err(|e| format!("{}: {e}", cfg_path.display())); } @@ -2094,6 +2255,20 @@ fn quit_app(app: tauri::AppHandle) { app.exit(0); } +/// Konfigurierter Archiv-Ordner des Projekts (für den Panel-Button: entscheidet, +/// ob der Ordner-Dialog nötig ist). +#[tauri::command] +fn panel_archive_dir_cmd(project: String) -> Option { + project_archive_dir(&project).map(|p| p.display().to_string()) +} + +/// Archiviert den aktuellen Panel-Entwurf. `dir` (aus dem Ordner-Dialog) setzt +/// den Archiv-Ordner zugleich; ohne `dir` muss er konfiguriert sein. +#[tauri::command] +fn panel_archive_cmd(project: String, dir: Option) -> Result { + archive_panel_content(&project, dir.as_deref()).map(|p| p.display().to_string()) +} + /// D-Bus-Relay für die GNOME-Extension: Show()/Hide() steuern das Popup-Fenster. /// Die Extension ruft Show() beim Panel-Klick und positioniert das Fenster selbst. #[cfg(target_os = "linux")] @@ -2324,6 +2499,7 @@ fn main_builder() -> tauri::Builder { /// Regular, dadurch Dock-Icon und Cmd-Tab-Eintrag pro Terminal. fn terminal_builder(project: String) -> tauri::Builder { tauri::Builder::default() + .plugin(tauri_plugin_dialog::init()) .manage(terminal::Terminals::default()) .setup(move |app| { let cfg = terminal_config(&project)?; @@ -2349,7 +2525,10 @@ fn terminal_builder(project: String) -> tauri::Builder { terminal::term_write, terminal::term_resize, terminal::panel_read, + terminal::panel_set, terminal::open_panel_window, + panel_archive_dir_cmd, + panel_archive_cmd, // Header im Terminal-Prozess: Projektliste, Icon und Pool-Name. list_projects, project_icon, @@ -2429,6 +2608,37 @@ mod tests { fs::metadata(path).unwrap().permissions().mode() & 0o777 } + // -- Archiv-Helfer -- + + #[test] + fn utc_stamp_bekannte_zeit() { + // 2026-07-11 (20645 Tage seit Epoch) um 11:14:15 UTC. + let secs = 20_645u64 * 86400 + 11 * 3600 + 14 * 60 + 15; + let (stamp, iso) = utc_stamp(secs); + assert_eq!(stamp, "2026-07-11_1114"); + assert_eq!(iso, "2026-07-11T11:14:15Z"); + } + + #[test] + fn civil_from_days_referenz() { + assert_eq!(civil_from_days(0), (1970, 1, 1)); + assert_eq!(civil_from_days(20_645), (2026, 7, 11)); + } + + #[test] + fn slugify_grenzen() { + assert_eq!(slugify("ADR: Logging vereinheitlichen"), "adr-logging-vereinheitlichen"); + assert_eq!(slugify(" "), "entwurf"); + assert_eq!(slugify("###"), "entwurf"); + } + + #[test] + fn first_line_ueberschrift_oder_erste_zeile() { + assert_eq!(first_line("\n\n# Titel\n\nText"), "Titel"); + assert_eq!(first_line("Kein Heading\nzweite"), "Kein Heading"); + assert_eq!(first_line(" \n"), "entwurf"); + } + // -- Pool anlegen -- #[test] diff --git a/src-tauri/src/mcp.rs b/src-tauri/src/mcp.rs index 97a85f4..dc24a3e 100644 --- a/src-tauri/src/mcp.rs +++ b/src-tauri/src/mcp.rs @@ -58,25 +58,48 @@ fn handle(method: &str, req: &Value) -> Option { })) } "tools/list" => Some(json!({ - "tools": [{ - "name": "write_panel", - "description": - "Legt einen längeren Entwurf (ADR, E-Mail, Dokument, Spezifikation, \ - Commit-Message, Textbaustein) im ai-control-Panel neben dem Terminal \ - ab, wo er mit der Maus selektierbar und über einen Button kopierbar \ - ist. Statt den Text zusätzlich als Fließtext auszugeben, dieses Tool \ - aufrufen und im Chat nur kurz bestätigen.", - "inputSchema": { - "type": "object", - "properties": { - "text": { - "type": "string", - "description": "Vollständiger Entwurf als Markdown-Rohtext.", - } + "tools": [ + { + "name": "write_panel", + "description": + "Legt einen längeren Entwurf (ADR, E-Mail, Dokument, Spezifikation, \ + Commit-Message, Textbaustein) im ai-control-Panel neben dem Terminal \ + ab, wo er mit der Maus selektierbar und über einen Button kopierbar \ + ist. Statt den Text zusätzlich als Fließtext auszugeben, dieses Tool \ + aufrufen und im Chat nur kurz bestätigen.", + "inputSchema": { + "type": "object", + "properties": { + "text": { + "type": "string", + "description": "Vollständiger Entwurf als Markdown-Rohtext.", + } + }, + "required": ["text"], }, - "required": ["text"], }, - }], + { + "name": "archive_panel", + "description": + "Archiviert den aktuell im Panel liegenden Entwurf dauerhaft als \ + Markdown-Datei im Archiv-Ordner des Projekts. Auf Wunsch nutzen \ + (Nutzer sagt etwa „archiviere das“). Ist kein Ordner konfiguriert, \ + den Zielpfad im \ + Argument `dir` mitgeben (der Nutzer nennt ihn); ohne `dir` und ohne \ + konfigurierten Ordner meldet das Tool das zurück.", + "inputSchema": { + "type": "object", + "properties": { + "dir": { + "type": "string", + "description": + "Optionaler Archiv-Ordner. Wird gesetzt und für künftige \ + Archivierungen gemerkt.", + } + }, + }, + }, + ], })), "tools/call" => Some(call_tool(req)), "ping" => Some(json!({})), @@ -85,13 +108,17 @@ fn handle(method: &str, req: &Value) -> Option { } fn call_tool(req: &Value) -> Value { - let name = req["params"]["name"].as_str().unwrap_or(""); - if name != "write_panel" { - return json!({ - "content": [{ "type": "text", "text": format!("Unbekanntes Tool: {name}") }], + match req["params"]["name"].as_str().unwrap_or("") { + "write_panel" => call_write(req), + "archive_panel" => call_archive(req), + other => json!({ + "content": [{ "type": "text", "text": format!("Unbekanntes Tool: {other}") }], "isError": true, - }); + }), } +} + +fn call_write(req: &Value) -> Value { let text = req["params"]["arguments"]["text"].as_str().unwrap_or(""); match std::env::var("AI_CONTROL_PANEL") { Ok(path) => match std::fs::write(&path, text) { @@ -107,3 +134,20 @@ fn call_tool(req: &Value) -> Value { }), } } + +fn call_archive(req: &Value) -> Value { + let project = std::env::var("AI_CONTROL_PROJECT").unwrap_or_default(); + let dir = req["params"]["arguments"]["dir"].as_str(); + match crate::archive_panel_content(&project, dir) { + Ok(path) => json!({ + "content": [{ "type": "text", "text": format!("Archiviert: {}", path.display()) }], + }), + Err(e) => json!({ + "content": [{ + "type": "text", + "text": format!("Nicht archiviert: {e}. Zielordner im Argument `dir` angeben oder im Panel wählen."), + }], + "isError": true, + }), + } +} diff --git a/src-tauri/src/terminal.rs b/src-tauri/src/terminal.rs index 7820e6e..b670d0a 100644 --- a/src-tauri/src/terminal.rs +++ b/src-tauri/src/terminal.rs @@ -200,6 +200,7 @@ pub fn term_start( cmd.cwd(&cwd); cmd.env("TERM", "xterm-256color"); cmd.env("AI_CONTROL_PANEL", &panel_path); + cmd.env("AI_CONTROL_PROJECT", &project); if let Some(pool_dir) = crate::project_pool_dir(&project)? { cmd.env("CLAUDE_CONFIG_DIR", pool_dir); } @@ -293,6 +294,13 @@ pub fn panel_read(project: String) -> String { std::fs::read_to_string(crate::panel_file(&project)).unwrap_or_default() } +/// Schreibt den Panel-Inhalt (Titel-Edit im Panel). Der Watcher meldet die +/// Änderung als `panel-update` an alle Fenster. +#[tauri::command] +pub fn panel_set(project: String, text: String) -> Result<(), String> { + std::fs::write(crate::panel_file(&project), text).map_err(|e| e.to_string()) +} + /// Löst das Panel in ein eigenes Fenster ab. Existiert es schon, kommt es nach /// vorn. `panel-detached` blendet das angedockte Panel im Terminal-Fenster aus. #[tauri::command] diff --git a/src/panel-view.ts b/src/panel-view.ts index edb154c..fc7cf24 100644 --- a/src/panel-view.ts +++ b/src/panel-view.ts @@ -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, diff --git a/src/panel.ts b/src/panel.ts index 75e8a3f..295789d 100644 --- a/src/panel.ts +++ b/src/panel.ts @@ -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("panel_read", { project })); await listen("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("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("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 diff --git a/src/terminal.ts b/src/terminal.ts index 1940b7a..052af67 100644 --- a/src/terminal.ts +++ b/src/terminal.ts @@ -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("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("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(); diff --git a/terminal.html b/terminal.html index 0f394a2..77953b6 100644 --- a/terminal.html +++ b/terminal.html @@ -212,11 +212,10 @@ } .panel-head { flex: none; - height: 36px; display: flex; - align-items: center; - gap: 6px; - padding: 0 8px 0 12px; + flex-direction: column; + gap: 2px; + padding: 6px 8px 8px 12px; border-bottom: 1px solid #313244; font: 500 12px/1 -apple-system, @@ -225,10 +224,33 @@ user-select: none; -webkit-user-select: none; } + .panel-titlerow { + display: flex; + align-items: center; + gap: 2px; + min-width: 0; + } + .panel-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 2px; + } .panel-title { - margin-right: auto; + flex: 0 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; color: #a6adc8; } + .panel-title[contenteditable="true"] { + overflow: visible; + text-overflow: clip; + outline: none; + color: inherit; + cursor: text; + } .panel-btn { border: none; background: transparent; @@ -345,11 +367,17 @@