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 @@