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
+212 -2
View File
@@ -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<PathBuf> {
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<PathBuf, String> {
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<String>,
#[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<String>,
}
#[derive(Serialize, Deserialize, Default, Clone)]
@@ -550,7 +711,7 @@ fn create_project_full_in(
reg.insert(name.to_string(), dir.clone());
save_registry(paths, &reg)?;
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<String> {
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<String>) -> Result<String, String> {
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<tauri::Wry> {
/// Regular, dadurch Dock-Icon und Cmd-Tab-Eintrag pro Terminal.
fn terminal_builder(project: String) -> tauri::Builder<tauri::Wry> {
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<tauri::Wry> {
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]
+66 -22
View File
@@ -58,25 +58,48 @@ fn handle(method: &str, req: &Value) -> Option<Value> {
}))
}
"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<Value> {
}
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,
}),
}
}
+8
View File
@@ -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]