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:
+35
-7
@@ -18,11 +18,10 @@
|
|||||||
}
|
}
|
||||||
.panel-head {
|
.panel-head {
|
||||||
flex: none;
|
flex: none;
|
||||||
height: 38px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
flex-direction: column;
|
||||||
gap: 6px;
|
gap: 3px;
|
||||||
padding: 0 8px 0 14px;
|
padding: 7px 8px 9px 14px;
|
||||||
background: #181825;
|
background: #181825;
|
||||||
border-bottom: 1px solid #313244;
|
border-bottom: 1px solid #313244;
|
||||||
font:
|
font:
|
||||||
@@ -32,10 +31,33 @@
|
|||||||
user-select: none;
|
user-select: none;
|
||||||
-webkit-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 {
|
.panel-title {
|
||||||
margin-right: auto;
|
flex: 0 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
color: #a6adc8;
|
color: #a6adc8;
|
||||||
}
|
}
|
||||||
|
.panel-title[contenteditable="true"] {
|
||||||
|
overflow: visible;
|
||||||
|
text-overflow: clip;
|
||||||
|
outline: none;
|
||||||
|
color: inherit;
|
||||||
|
cursor: text;
|
||||||
|
}
|
||||||
.panel-btn {
|
.panel-btn {
|
||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -131,12 +153,18 @@
|
|||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="panel-head" data-tauri-drag-region>
|
<div class="panel-head">
|
||||||
<span class="panel-title" data-tauri-drag-region>Entwurf</span>
|
<div class="panel-titlerow" data-tauri-drag-region>
|
||||||
|
<span class="panel-title">Entwurf</span>
|
||||||
|
<button class="panel-btn panel-edit" id="panel-title-edit" title="Titel bearbeiten">✎</button>
|
||||||
|
</div>
|
||||||
|
<div class="panel-actions">
|
||||||
<button class="panel-btn" id="panel-mode" title="Rohtext / gerendert">MD</button>
|
<button class="panel-btn" id="panel-mode" title="Rohtext / gerendert">MD</button>
|
||||||
<button class="panel-btn" id="panel-copy" title="In die Zwischenablage">Kopieren</button>
|
<button class="panel-btn" id="panel-copy" title="In die Zwischenablage">Kopieren</button>
|
||||||
|
<button class="panel-btn" id="panel-archive" title="In den Archiv-Ordner speichern">Archivieren</button>
|
||||||
<button class="panel-btn" id="panel-dock" title="Wieder andocken">Andocken</button>
|
<button class="panel-btn" id="panel-dock" title="Wieder andocken">Andocken</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div id="panel-content" class="md"></div>
|
<div id="panel-content" class="md"></div>
|
||||||
<script type="module" src="/src/panel.ts"></script>
|
<script type="module" src="/src/panel.ts"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
+212
-2
@@ -72,6 +72,160 @@ pub(crate) fn panel_file(project: &str) -> PathBuf {
|
|||||||
Paths::real().panels_dir().join(format!("{project}.md"))
|
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 ----------
|
// ---------- Projekt-Registry ----------
|
||||||
|
|
||||||
/// Pfad unterhalb von Home als "~/…" schreiben — Registry-Einträge im
|
/// Pfad unterhalb von Home als "~/…" schreiben — Registry-Einträge im
|
||||||
@@ -167,6 +321,13 @@ struct ProjectConfig {
|
|||||||
pool: Option<String>,
|
pool: Option<String>,
|
||||||
#[serde(default, skip_serializing_if = "TerminalConfig::is_empty")]
|
#[serde(default, skip_serializing_if = "TerminalConfig::is_empty")]
|
||||||
terminal: TerminalConfig,
|
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)]
|
#[derive(Serialize, Deserialize, Default, Clone)]
|
||||||
@@ -550,7 +711,7 @@ fn create_project_full_in(
|
|||||||
|
|
||||||
reg.insert(name.to_string(), dir.clone());
|
reg.insert(name.to_string(), dir.clone());
|
||||||
save_registry(paths, ®)?;
|
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() {
|
if cfg.pool.is_some() || !cfg.terminal.is_empty() {
|
||||||
write_project_config_in(paths, name, &cfg)?;
|
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> {
|
fn unassign_pool_in(paths: &Paths, project: &str) -> Result<(), String> {
|
||||||
let mut cfg = read_project_config_in(paths, project)?;
|
let mut cfg = read_project_config_in(paths, project)?;
|
||||||
cfg.pool = None;
|
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)?;
|
let cfg_path = project_config_path(paths, project)?;
|
||||||
return fs::remove_file(&cfg_path).map_err(|e| format!("{}: {e}", cfg_path.display()));
|
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);
|
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.
|
/// 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.
|
/// Die Extension ruft Show() beim Panel-Klick und positioniert das Fenster selbst.
|
||||||
#[cfg(target_os = "linux")]
|
#[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.
|
/// Regular, dadurch Dock-Icon und Cmd-Tab-Eintrag pro Terminal.
|
||||||
fn terminal_builder(project: String) -> tauri::Builder<tauri::Wry> {
|
fn terminal_builder(project: String) -> tauri::Builder<tauri::Wry> {
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
|
.plugin(tauri_plugin_dialog::init())
|
||||||
.manage(terminal::Terminals::default())
|
.manage(terminal::Terminals::default())
|
||||||
.setup(move |app| {
|
.setup(move |app| {
|
||||||
let cfg = terminal_config(&project)?;
|
let cfg = terminal_config(&project)?;
|
||||||
@@ -2349,7 +2525,10 @@ fn terminal_builder(project: String) -> tauri::Builder<tauri::Wry> {
|
|||||||
terminal::term_write,
|
terminal::term_write,
|
||||||
terminal::term_resize,
|
terminal::term_resize,
|
||||||
terminal::panel_read,
|
terminal::panel_read,
|
||||||
|
terminal::panel_set,
|
||||||
terminal::open_panel_window,
|
terminal::open_panel_window,
|
||||||
|
panel_archive_dir_cmd,
|
||||||
|
panel_archive_cmd,
|
||||||
// Header im Terminal-Prozess: Projektliste, Icon und Pool-Name.
|
// Header im Terminal-Prozess: Projektliste, Icon und Pool-Name.
|
||||||
list_projects,
|
list_projects,
|
||||||
project_icon,
|
project_icon,
|
||||||
@@ -2429,6 +2608,37 @@ mod tests {
|
|||||||
fs::metadata(path).unwrap().permissions().mode() & 0o777
|
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 --
|
// -- Pool anlegen --
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+51
-7
@@ -58,7 +58,8 @@ fn handle(method: &str, req: &Value) -> Option<Value> {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
"tools/list" => Some(json!({
|
"tools/list" => Some(json!({
|
||||||
"tools": [{
|
"tools": [
|
||||||
|
{
|
||||||
"name": "write_panel",
|
"name": "write_panel",
|
||||||
"description":
|
"description":
|
||||||
"Legt einen längeren Entwurf (ADR, E-Mail, Dokument, Spezifikation, \
|
"Legt einen längeren Entwurf (ADR, E-Mail, Dokument, Spezifikation, \
|
||||||
@@ -76,7 +77,29 @@ fn handle(method: &str, req: &Value) -> Option<Value> {
|
|||||||
},
|
},
|
||||||
"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)),
|
"tools/call" => Some(call_tool(req)),
|
||||||
"ping" => Some(json!({})),
|
"ping" => Some(json!({})),
|
||||||
@@ -85,13 +108,17 @@ fn handle(method: &str, req: &Value) -> Option<Value> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn call_tool(req: &Value) -> Value {
|
fn call_tool(req: &Value) -> Value {
|
||||||
let name = req["params"]["name"].as_str().unwrap_or("");
|
match req["params"]["name"].as_str().unwrap_or("") {
|
||||||
if name != "write_panel" {
|
"write_panel" => call_write(req),
|
||||||
return json!({
|
"archive_panel" => call_archive(req),
|
||||||
"content": [{ "type": "text", "text": format!("Unbekanntes Tool: {name}") }],
|
other => json!({
|
||||||
|
"content": [{ "type": "text", "text": format!("Unbekanntes Tool: {other}") }],
|
||||||
"isError": true,
|
"isError": true,
|
||||||
});
|
}),
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn call_write(req: &Value) -> Value {
|
||||||
let text = req["params"]["arguments"]["text"].as_str().unwrap_or("");
|
let text = req["params"]["arguments"]["text"].as_str().unwrap_or("");
|
||||||
match std::env::var("AI_CONTROL_PANEL") {
|
match std::env::var("AI_CONTROL_PANEL") {
|
||||||
Ok(path) => match std::fs::write(&path, text) {
|
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,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -200,6 +200,7 @@ pub fn term_start(
|
|||||||
cmd.cwd(&cwd);
|
cmd.cwd(&cwd);
|
||||||
cmd.env("TERM", "xterm-256color");
|
cmd.env("TERM", "xterm-256color");
|
||||||
cmd.env("AI_CONTROL_PANEL", &panel_path);
|
cmd.env("AI_CONTROL_PANEL", &panel_path);
|
||||||
|
cmd.env("AI_CONTROL_PROJECT", &project);
|
||||||
if let Some(pool_dir) = crate::project_pool_dir(&project)? {
|
if let Some(pool_dir) = crate::project_pool_dir(&project)? {
|
||||||
cmd.env("CLAUDE_CONFIG_DIR", pool_dir);
|
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()
|
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
|
/// 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.
|
/// vorn. `panel-detached` blendet das angedockte Panel im Terminal-Fenster aus.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
|||||||
@@ -7,10 +7,59 @@ export interface PanelView {
|
|||||||
|
|
||||||
/// Verkabelt einen Panel-Inhaltsbereich: MD/Roh-Umschalter und Copy-Button.
|
/// Verkabelt einen Panel-Inhaltsbereich: MD/Roh-Umschalter und Copy-Button.
|
||||||
/// Der Rohtext bleibt die Quelle für „Kopieren"; die Ansicht rendert Markdown.
|
/// 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: {
|
export function initPanelView(opts: {
|
||||||
content: HTMLElement;
|
content: HTMLElement;
|
||||||
copyBtn: HTMLElement;
|
copyBtn: HTMLElement;
|
||||||
modeBtn: HTMLElement;
|
modeBtn: HTMLElement;
|
||||||
|
titleEl?: HTMLElement;
|
||||||
|
editBtn?: HTMLElement;
|
||||||
|
/// Wird mit dem neuen Rohtext aufgerufen, wenn der Titel geändert wurde.
|
||||||
|
onCommit?: (text: string) => void;
|
||||||
}): PanelView {
|
}): PanelView {
|
||||||
let rawText = "";
|
let rawText = "";
|
||||||
let rendered = true;
|
let rendered = true;
|
||||||
@@ -42,10 +91,46 @@ export function initPanelView(opts: {
|
|||||||
}, 1200);
|
}, 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();
|
draw();
|
||||||
return {
|
return {
|
||||||
set(text: string) {
|
set(text: string) {
|
||||||
rawText = text;
|
rawText = text;
|
||||||
|
if (titleEl && titleEl.getAttribute("contenteditable") !== "true") {
|
||||||
|
titleEl.textContent = firstLine(text);
|
||||||
|
}
|
||||||
draw();
|
draw();
|
||||||
},
|
},
|
||||||
raw: () => rawText,
|
raw: () => rawText,
|
||||||
|
|||||||
@@ -14,12 +14,42 @@ const view = initPanelView({
|
|||||||
content: document.getElementById("panel-content")!,
|
content: document.getElementById("panel-content")!,
|
||||||
copyBtn: document.getElementById("panel-copy")!,
|
copyBtn: document.getElementById("panel-copy")!,
|
||||||
modeBtn: document.getElementById("panel-mode")!,
|
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 }));
|
view.set(await invoke<string>("panel_read", { project }));
|
||||||
|
|
||||||
await listen<string>("panel-update", (e) => view.set(e.payload));
|
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
|
// „Andocken": Fenster schließen — terminal.rs meldet `panel-attached`, das
|
||||||
// angedockte Panel im Terminal-Fenster erscheint wieder.
|
// angedockte Panel im Terminal-Fenster erscheint wieder.
|
||||||
document
|
document
|
||||||
|
|||||||
@@ -551,6 +551,9 @@ const view = initPanelView({
|
|||||||
content: panelContent,
|
content: panelContent,
|
||||||
copyBtn: document.getElementById("panel-copy")!,
|
copyBtn: document.getElementById("panel-copy")!,
|
||||||
modeBtn: document.getElementById("panel-mode")!,
|
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;
|
let detached = false;
|
||||||
@@ -586,6 +589,34 @@ document
|
|||||||
.addEventListener("click", () => invoke("open_panel_window", { project }));
|
.addEventListener("click", () => invoke("open_panel_window", { project }));
|
||||||
document.getElementById("panel-hide")!.addEventListener("click", hidePanel);
|
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: Panelbreite ziehen.
|
||||||
splitter.addEventListener("mousedown", (e) => {
|
splitter.addEventListener("mousedown", (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
+33
-5
@@ -212,11 +212,10 @@
|
|||||||
}
|
}
|
||||||
.panel-head {
|
.panel-head {
|
||||||
flex: none;
|
flex: none;
|
||||||
height: 36px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
flex-direction: column;
|
||||||
gap: 6px;
|
gap: 2px;
|
||||||
padding: 0 8px 0 12px;
|
padding: 6px 8px 8px 12px;
|
||||||
border-bottom: 1px solid #313244;
|
border-bottom: 1px solid #313244;
|
||||||
font:
|
font:
|
||||||
500 12px/1 -apple-system,
|
500 12px/1 -apple-system,
|
||||||
@@ -225,10 +224,33 @@
|
|||||||
user-select: none;
|
user-select: none;
|
||||||
-webkit-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 {
|
.panel-title {
|
||||||
margin-right: auto;
|
flex: 0 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
color: #a6adc8;
|
color: #a6adc8;
|
||||||
}
|
}
|
||||||
|
.panel-title[contenteditable="true"] {
|
||||||
|
overflow: visible;
|
||||||
|
text-overflow: clip;
|
||||||
|
outline: none;
|
||||||
|
color: inherit;
|
||||||
|
cursor: text;
|
||||||
|
}
|
||||||
.panel-btn {
|
.panel-btn {
|
||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -345,12 +367,18 @@
|
|||||||
<div id="splitter" hidden></div>
|
<div id="splitter" hidden></div>
|
||||||
<aside id="panel" hidden>
|
<aside id="panel" hidden>
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
|
<div class="panel-titlerow">
|
||||||
<span class="panel-title">Entwurf</span>
|
<span class="panel-title">Entwurf</span>
|
||||||
|
<button class="panel-btn panel-edit" id="panel-title-edit" title="Titel bearbeiten">✎</button>
|
||||||
|
</div>
|
||||||
|
<div class="panel-actions">
|
||||||
<button class="panel-btn" id="panel-mode" title="Rohtext / gerendert">MD</button>
|
<button class="panel-btn" id="panel-mode" title="Rohtext / gerendert">MD</button>
|
||||||
<button class="panel-btn" id="panel-copy" title="In die Zwischenablage">Kopieren</button>
|
<button class="panel-btn" id="panel-copy" title="In die Zwischenablage">Kopieren</button>
|
||||||
|
<button class="panel-btn" id="panel-archive" title="In den Archiv-Ordner speichern">Archivieren</button>
|
||||||
<button class="panel-btn" id="panel-detach" title="In eigenes Fenster ablösen">⧉</button>
|
<button class="panel-btn" id="panel-detach" title="In eigenes Fenster ablösen">⧉</button>
|
||||||
<button class="panel-btn" id="panel-hide" title="Panel ausblenden">✕</button>
|
<button class="panel-btn" id="panel-hide" title="Panel ausblenden">✕</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div id="panel-content" class="md"></div>
|
<div id="panel-content" class="md"></div>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user