Archiv-Navigator: Baum nur Ordner + flache Inhaltsliste (Zeilen: öffnen/umbenennen/löschen), Ordner/Dokument anlegen (Dateiname ohne Stempel, Datum im Frontmatter), Write-back geöffneter Archiv-Dokumente über Quell-Verknüpfung; Ablösen übergibt aktiven Tab

This commit is contained in:
marcus hinz
2026-07-23 21:17:41 +02:00
parent 1523856ea7
commit f58b9887c7
26 changed files with 1362 additions and 468 deletions
+16 -1
View File
@@ -275,7 +275,12 @@ fn check_folder(folder: &str) -> Result<&std::path::Path, String> {
/// YAML-Frontmatter des Archiv-Dokuments inklusive optionaler
/// description/tags aus den Metadaten. Gegenstück: `parse_frontmatter` unten —
/// Schreiber und Leser des Formats leben bewusst im selben Modul.
fn frontmatter(title: &str, project: &str, iso: &str, meta: &ArchiveMeta) -> String {
pub(crate) fn frontmatter(
title: &str,
project: &str,
iso: &str,
meta: &ArchiveMeta,
) -> String {
let mut fm = format!(
"---\ntitle: \"{}\"\nproject: {project}\ncreated: {iso}\nsource: ai-control\n",
title.replace('"', "'"),
@@ -398,6 +403,16 @@ pub(crate) fn slugify(s: &str) -> String {
}
}
/// Aktuelle UTC-Zeit als ISO-String — Frontmatter-`created` neu angelegter
/// Dokumente.
pub(crate) fn utc_now_iso() -> Result<String, String> {
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|e| e.to_string())?
.as_secs();
Ok(utc_stamp(secs).1)
}
/// 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);
+92 -65
View File
@@ -8,9 +8,7 @@ use std::collections::HashMap;
use std::fs;
use std::path::Path;
use crate::domain::archive::{
parse_frontmatter, parse_tag_list, slugify, strip_frontmatter, strip_stamp,
};
use crate::domain::archive::{parse_frontmatter, parse_tag_list, slugify, strip_stamp};
#[derive(serde::Serialize, Clone)]
pub(crate) struct Doc {
@@ -22,6 +20,9 @@ pub(crate) struct Doc {
pub(crate) title: String,
pub(crate) description: Option<String>,
pub(crate) tags: Vec<String>,
/// Frontmatter-`created` (ISO) — Datumsquelle für Dokumente ohne
/// Zeitstempel im Dateinamen.
pub(crate) created: Option<String>,
/// Wikilink-Ziele im Dokumenttext (`[[ziel]]`/`[[ziel|label]]`, nur das Ziel).
pub(crate) links: Vec<String>,
/// Namen der Dokumente, die per Wikilink hierher zeigen.
@@ -107,6 +108,7 @@ fn read_doc(home: &Path, path: &Path) -> Result<(Doc, String), String> {
title: fm.get("title").unwrap_or(&name).clone(),
description: fm.get("description").cloned(),
tags: fm.get("tags").map(|t| parse_tag_list(t)).unwrap_or_default(),
created: fm.get("created").cloned(),
links: wikilinks(&text),
backlinks: Vec::new(),
name,
@@ -134,9 +136,6 @@ pub(crate) struct WikiPage {
pub(crate) tag: Option<String>,
pub(crate) total: usize,
pub(crate) tags: Vec<TagCount>,
/// Die neuesten Dokumente über alle Ordner; leer, wenn es nur einen
/// Ordner gibt (dann wäre die Sektion eine Dublette der Liste).
pub(crate) recent: Vec<WikiDocEntry>,
pub(crate) folders: Vec<WikiFolder>,
}
@@ -155,6 +154,9 @@ pub(crate) struct WikiFolder {
#[derive(serde::Serialize)]
pub(crate) struct WikiDocEntry {
/// Pfad relativ zum Archiv-Home — Sprung ins Dokument und Adressat der
/// Zeilen-Aktionen (umbenennen, löschen).
pub(crate) relpath: String,
pub(crate) name: String,
pub(crate) title: String,
pub(crate) description: Option<String>,
@@ -179,6 +181,16 @@ pub(crate) fn archive_page(home: &Path, tag: Option<&str>) -> Result<WikiPage, S
};
let mut folders: std::collections::BTreeMap<String, Vec<&Doc>> =
std::collections::BTreeMap::new();
// Die Übersicht führt alle Ordner, auch dokumentlose — der Navigator zeigt
// frisch angelegte Ordner sonst nicht. Schlagwort-Seiten bleiben auf die
// Treffer-Ordner beschränkt.
if tag.is_none() {
let mut dirs = Vec::new();
folder_paths(home, home, &mut dirs)?;
for d in dirs {
folders.entry(d).or_default();
}
}
for doc in &selected {
let folder = Path::new(&doc.relpath)
.parent()
@@ -186,17 +198,6 @@ pub(crate) fn archive_page(home: &Path, tag: Option<&str>) -> Result<WikiPage, S
.unwrap_or_default();
folders.entry(folder).or_default().push(doc);
}
let recent = if folders.len() > 1 {
// Neueste zuerst über alle Ordner — der Zeitstempel-Stem sortiert
// chronologisch, verglichen wird der Dateiname.
let mut all = selected.clone();
all.sort_by(|a, b| {
Path::new(&b.relpath).file_name().cmp(&Path::new(&a.relpath).file_name())
});
all.into_iter().take(5).map(doc_entry).collect()
} else {
Vec::new()
};
Ok(WikiPage {
kind: "page",
home: home.display().to_string(),
@@ -206,7 +207,6 @@ pub(crate) fn archive_page(home: &Path, tag: Option<&str>) -> Result<WikiPage, S
.into_iter()
.map(|(name, count)| TagCount { name: name.to_string(), count })
.collect(),
recent,
folders: folders
.into_iter()
.map(|(name, mut list)| {
@@ -218,14 +218,41 @@ pub(crate) fn archive_page(home: &Path, tag: Option<&str>) -> Result<WikiPage, S
})
}
/// Alle Ordner-Relpaths unterhalb von `dir`, rekursiv; versteckte Einträge
/// (Punkt-Präfix) bleiben außen vor — wie beim Dokument-Scan.
fn folder_paths(home: &Path, dir: &Path, out: &mut Vec<String>) -> Result<(), String> {
let entries = fs::read_dir(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
for entry in entries {
let entry = entry.map_err(|e| format!("{}: {e}", dir.display()))?;
let path = entry.path();
if entry.file_name().to_string_lossy().starts_with('.') || !path.is_dir() {
continue;
}
out.push(path.strip_prefix(home).unwrap().display().to_string());
folder_paths(home, &path, out)?;
}
Ok(())
}
fn doc_entry(doc: &Doc) -> WikiDocEntry {
let stem = Path::new(&doc.relpath)
.file_stem()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let date = (stem != doc.name).then(|| stem[..10].to_string());
// Zeitstempel im Dateinamen, sonst der Frontmatter-`created`-Tag —
// im Navigator angelegte Dokumente tragen das Datum nur dort.
let date = (stem != doc.name)
.then(|| stem[..10].to_string())
.or_else(|| {
doc
.created
.as_ref()
.filter(|c| c.len() >= 10)
.map(|c| c[..10].to_string())
});
WikiDocEntry {
relpath: doc.relpath.clone(),
name: doc.name.clone(),
title: doc.title.clone(),
description: doc.description.clone(),
@@ -235,37 +262,16 @@ fn doc_entry(doc: &Doc) -> WikiDocEntry {
}
}
/// Ein Archiv-Dokument als Wiki-Ansicht: Markdown-Rumpf ohne Frontmatter plus
/// Metadaten und Backlinks aus dem Scan.
#[derive(serde::Serialize)]
pub(crate) struct WikiDocPage {
pub(crate) kind: &'static str,
pub(crate) home: String,
pub(crate) relpath: String,
pub(crate) name: String,
pub(crate) title: String,
pub(crate) tags: Vec<String>,
pub(crate) backlinks: Vec<String>,
pub(crate) markdown: String,
}
pub(crate) fn wiki_doc(home: &Path, target: &str) -> Result<WikiDocPage, String> {
let pairs = scan_with_bodies(home)?;
/// Löst ein Wikilink-Ziel (Name, Titel oder Datei-Stem) gegen das Archiv auf
/// und liefert den relpath des Dokuments.
pub(crate) fn resolve_doc(home: &Path, target: &str) -> Result<String, String> {
let docs = scan_archive(home)?;
let want = slugify(target);
let (doc, body) = pairs
docs
.iter()
.find(|(d, _)| matches(d, &want))
.ok_or_else(|| format!("kein Archiv-Dokument zu „{target}“ gefunden"))?;
Ok(WikiDocPage {
kind: "doc",
home: home.display().to_string(),
relpath: doc.relpath.clone(),
name: doc.name.clone(),
title: doc.title.clone(),
tags: doc.tags.clone(),
backlinks: doc.backlinks.clone(),
markdown: strip_frontmatter(body).to_string(),
})
.find(|d| matches(d, &want))
.map(|d| d.relpath.clone())
.ok_or_else(|| format!("kein Archiv-Dokument zu „{target}“ gefunden"))
}
/// Alle `[[ziel]]`-Vorkommen im Text, in Dokumentreihenfolge; bei
@@ -358,14 +364,38 @@ mod tests {
assert_eq!(adr.title, "ADR Logging");
assert_eq!(adr.description.as_deref(), Some("Logging vereinheitlichen"));
assert_eq!(adr.date.as_deref(), Some("2026-07-19"));
assert_eq!(adr.relpath, "2026-07-19_1000-adr-logging.md");
assert_eq!(page.folders[1].name, "konzepte");
assert_eq!(page.folders[1].docs[0].name, "notiz-deploy");
// Zwei Ordner → Zuletzt-Sektion, neuestes Dokument zuerst, mit
// Backlink-Zähler aus dem Scan.
let recent: Vec<&str> = page.recent.iter().map(|d| d.name.as_str()).collect();
assert_eq!(recent, vec!["notiz-deploy", "adr-logging"]);
assert_eq!(page.recent[0].backlinks, 1);
assert_eq!(page.recent[1].backlinks, 0);
assert_eq!(page.folders[1].docs[0].backlinks, 1);
}
#[test]
fn leere_ordner_und_created_datum() {
let home = archiv();
fs::create_dir_all(home.join("leer/unter")).unwrap();
write(
&home,
"notiz-plain.md",
"---\ntitle: \"Plain\"\ncreated: 2026-07-23T10:00:00Z\n---\n\nText.\n",
);
let page = archive_page(&home, None).unwrap();
let names: Vec<&str> = page.folders.iter().map(|f| f.name.as_str()).collect();
// Dokumentlose Ordner erscheinen (Navigator), versteckte weiterhin nicht.
assert!(names.contains(&"leer"));
assert!(names.contains(&"leer/unter"));
assert!(!names.iter().any(|n| n.starts_with(".versteckt")));
// Ohne Zeitstempel im Namen kommt das Datum aus dem Frontmatter-created.
let plain = page
.folders
.iter()
.find(|f| f.name.is_empty())
.unwrap()
.docs
.iter()
.find(|d| d.name == "notiz-plain")
.unwrap();
assert_eq!(plain.date.as_deref(), Some("2026-07-23"));
}
#[test]
@@ -381,20 +411,17 @@ mod tests {
}
#[test]
fn wiki_doc_mit_rumpf_und_backlinks() {
fn resolve_ueber_name_titel_und_stem() {
let home = archiv();
// Auflösung über Titel; Name und Stem gehen über dieselben Slug-Vergleiche.
let doc = wiki_doc(&home, "Notiz Deploy").unwrap();
assert_eq!(doc.kind, "doc");
assert_eq!(doc.name, "notiz-deploy");
assert_eq!(
wiki_doc(&home, "2026-07-19_1000-adr-logging").unwrap().name,
"adr-logging"
resolve_doc(&home, "Notiz Deploy").unwrap(),
"konzepte/2026-07-19_1005-notiz-deploy.md"
);
assert_eq!(doc.title, "Notiz Deploy");
assert_eq!(doc.relpath, "konzepte/2026-07-19_1005-notiz-deploy.md");
assert_eq!(doc.backlinks, vec!["adr-logging"]);
assert_eq!(doc.markdown, "Text ohne Links.\n");
assert!(wiki_doc(&home, "fehlt").is_err());
assert_eq!(
resolve_doc(&home, "2026-07-19_1000-adr-logging").unwrap(),
"2026-07-19_1000-adr-logging.md"
);
assert!(resolve_doc(&home, "fehlt").is_err());
}
}
+275
View File
@@ -0,0 +1,275 @@
//! Dokument- und Ordner-Operationen im Archiv-Baum (Baum-Navigation im
//! Panel): verschieben, umbenennen, löschen. Alle Pfade sind relativ zum
//! Archiv-Home und strikt darauf begrenzt — die relpaths kommen aus dem
//! Webview, ein Traversal-Bestandteil bricht laut ab.
use std::fs;
use std::path::{Component, Path, PathBuf};
use crate::domain::archive::{
frontmatter, slugify, strip_frontmatter, strip_stamp, utc_now_iso, ArchiveMeta,
};
/// Relativer Pfad ohne Traversal: nur normale Komponenten, nichts Absolutes.
fn checked_rel(rel: &str) -> Result<(), String> {
if rel.is_empty() {
return Err("leerer Pfad".into());
}
for c in Path::new(rel).components() {
match c {
Component::Normal(_) => {}
_ => return Err(format!("unzulässiger Pfad: {rel}")),
}
}
Ok(())
}
/// Vorhandenes Archiv-Dokument zum relpath.
fn doc_path(home: &Path, relpath: &str) -> Result<PathBuf, String> {
checked_rel(relpath)?;
if !relpath.ends_with(".md") {
return Err(format!("kein Markdown-Dokument: {relpath}"));
}
let path = home.join(relpath);
if !path.is_file() {
return Err(format!("Dokument nicht gefunden: {relpath}"));
}
Ok(path)
}
/// Ziel für rename: darf noch nicht existieren — stilles Überschreiben wäre
/// Datenverlust.
fn fresh_target(target: &Path, home: &Path) -> Result<(), String> {
if target.exists() {
return Err(format!(
"Ziel existiert bereits: {}",
target.strip_prefix(home).unwrap_or(target).display()
));
}
Ok(())
}
/// Verschiebt ein Dokument in einen anderen Ordner (leer = Wurzel); der
/// Zielordner entsteht bei Bedarf. Liefert den neuen relpath.
pub(crate) fn move_doc(home: &Path, relpath: &str, folder: &str) -> Result<String, String> {
let src = doc_path(home, relpath)?;
if !folder.is_empty() {
checked_rel(folder)?;
}
let file_name = src.file_name().unwrap();
let dir = home.join(folder);
let target = dir.join(file_name);
if target == src {
return Ok(relpath.to_string());
}
fresh_target(&target, home)?;
fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
fs::rename(&src, &target).map_err(|e| format!("{}: {e}", src.display()))?;
Ok(target.strip_prefix(home).unwrap().display().to_string())
}
/// Benennt ein Dokument um: neuer Name als Slug hinter dem (erhaltenen)
/// Zeitstempel. Liefert den neuen relpath.
pub(crate) fn rename_doc(home: &Path, relpath: &str, name: &str) -> Result<String, String> {
let src = doc_path(home, relpath)?;
let stem = src.file_stem().unwrap().to_string_lossy().to_string();
let stamp = &stem[..stem.len() - strip_stamp(&stem).len()];
let target = src.with_file_name(format!("{stamp}{}.md", slugify(name)));
if target == src {
return Ok(relpath.to_string());
}
fresh_target(&target, home)?;
fs::rename(&src, &target).map_err(|e| format!("{}: {e}", src.display()))?;
Ok(target.strip_prefix(home).unwrap().display().to_string())
}
pub(crate) fn delete_doc(home: &Path, relpath: &str) -> Result<(), String> {
let path = doc_path(home, relpath)?;
fs::remove_file(&path).map_err(|e| format!("{}: {e}", path.display()))
}
/// Legt einen (auch leeren) Ordner unterhalb des Archiv-Home an.
pub(crate) fn create_folder(home: &Path, folder: &str) -> Result<(), String> {
checked_rel(folder)?;
let dir = home.join(folder);
fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))
}
/// Legt ein leeres Dokument an: `<slug>.md` im Ordner (leer = Wurzel), das
/// Datum steht in der Frontmatter (`created`), nicht im Dateinamen. Liefert
/// den relpath.
pub(crate) fn create_doc(
home: &Path,
folder: &str,
name: &str,
project_name: &str,
) -> Result<String, String> {
let name = name.trim();
if name.is_empty() {
return Err("Dokumentname fehlt".into());
}
if !folder.is_empty() {
checked_rel(folder)?;
}
let dir = home.join(folder);
fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
let target = dir.join(format!("{}.md", slugify(name)));
let fm = frontmatter(name, project_name, &utc_now_iso()?, &ArchiveMeta::default());
let mut file = match fs::OpenOptions::new().write(true).create_new(true).open(&target) {
Ok(f) => f,
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
return Err(format!(
"Ziel existiert bereits: {}",
target.strip_prefix(home).unwrap_or(&target).display()
))
}
Err(e) => return Err(format!("{}: {e}", target.display())),
};
std::io::Write::write_all(&mut file, fm.as_bytes())
.map_err(|e| format!("{}: {e}", target.display()))?;
Ok(target.strip_prefix(home).unwrap().display().to_string())
}
/// Schreibt den Panel-Body zurück in ein Archiv-Dokument (Quell-Verknüpfung
/// des Dokument-Tabs): der Frontmatter-Block der Datei bleibt, der Rumpf wird
/// ersetzt.
pub(crate) fn write_body(path: &Path, text: &str) -> Result<(), String> {
let doc = fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?;
let head = doc.len() - strip_frontmatter(&doc).len();
let out = format!("{}{}\n", &doc[..head], text.trim_end());
fs::write(path, out).map_err(|e| format!("{}: {e}", path.display()))
}
/// Verschiebt bzw. benennt einen ganzen Ordner um (fs::rename des
/// Verzeichnisses); Eltern des Ziels entstehen bei Bedarf.
pub(crate) fn move_folder(home: &Path, folder: &str, to: &str) -> Result<(), String> {
checked_rel(folder)?;
checked_rel(to)?;
let src = home.join(folder);
if !src.is_dir() {
return Err(format!("Ordner nicht gefunden: {folder}"));
}
let target = home.join(to);
if target == src {
return Ok(());
}
fresh_target(&target, home)?;
if let Some(parent) = target.parent() {
fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
}
fs::rename(&src, &target).map_err(|e| format!("{}: {e}", src.display()))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::testutil::tmp_paths;
fn archiv() -> PathBuf {
let home = tmp_paths().home.join("archiv");
fs::create_dir_all(home.join("konzepte")).unwrap();
fs::write(home.join("2026-07-19_1000-adr-logging.md"), "a").unwrap();
fs::write(home.join("konzepte/2026-07-19_1005-notiz-deploy.md"), "b").unwrap();
home
}
#[test]
fn verschieben_legt_ordner_an_und_liefert_relpath() {
let home = archiv();
let rel = move_doc(&home, "2026-07-19_1000-adr-logging.md", "adr/2026").unwrap();
assert_eq!(rel, "adr/2026/2026-07-19_1000-adr-logging.md");
assert!(home.join(&rel).is_file());
// In die Wurzel zurück: leerer Zielordner.
let rel = move_doc(&home, &rel, "").unwrap();
assert_eq!(rel, "2026-07-19_1000-adr-logging.md");
// Gleiches Ziel ist ein No-Op, kein Fehler.
assert_eq!(move_doc(&home, &rel, "").unwrap(), rel);
}
#[test]
fn verschieben_ueberschreibt_nicht() {
let home = archiv();
fs::write(home.join("konzepte/2026-07-19_1000-adr-logging.md"), "alt").unwrap();
let err = move_doc(&home, "2026-07-19_1000-adr-logging.md", "konzepte").unwrap_err();
assert!(err.contains("existiert bereits"));
}
#[test]
fn traversal_und_fremde_pfade_brechen_ab() {
let home = archiv();
assert!(move_doc(&home, "../2026-07-19_1000-adr-logging.md", "x").is_err());
assert!(move_doc(&home, "2026-07-19_1000-adr-logging.md", "../raus").is_err());
assert!(move_doc(&home, "/etc/passwd.md", "x").is_err());
assert!(delete_doc(&home, "konzepte").is_err()); // kein .md
assert!(move_folder(&home, "konzepte", "../raus").is_err());
}
#[test]
fn umbenennen_erhaelt_stempel_und_sluggt() {
let home = archiv();
let rel =
rename_doc(&home, "konzepte/2026-07-19_1005-notiz-deploy.md", "Deploy Nötiz!").unwrap();
assert_eq!(rel, "konzepte/2026-07-19_1005-deploy-noetiz.md");
assert!(home.join(&rel).is_file());
// Ohne Stempel bleibt der Name pur.
fs::write(home.join("plain.md"), "c").unwrap();
assert_eq!(rename_doc(&home, "plain.md", "Neu").unwrap(), "neu.md");
}
#[test]
fn anlegen_ordner_und_dokument() {
let home = archiv();
create_folder(&home, "notizen/2026").unwrap();
assert!(home.join("notizen/2026").is_dir());
assert!(create_folder(&home, "../raus").is_err());
let rel = create_doc(&home, "notizen/2026", "Deploy Nötiz!", "proj").unwrap();
assert_eq!(rel, "notizen/2026/deploy-noetiz.md");
let text = fs::read_to_string(home.join(&rel)).unwrap();
assert!(text.starts_with("---\ntitle: \"Deploy Nötiz!\"\n"));
assert!(text.contains("created: "));
// In der Wurzel, gleicher Name kollidiert laut, leerer Name bricht ab.
assert_eq!(create_doc(&home, "", "Plain", "proj").unwrap(), "plain.md");
let err = create_doc(&home, "notizen/2026", "Deploy Nötiz!", "proj").unwrap_err();
assert!(err.contains("existiert bereits"));
assert!(create_doc(&home, "", " ", "proj").is_err());
}
#[test]
fn write_body_ersetzt_rumpf_und_erhaelt_frontmatter() {
let home = archiv();
let path = home.join("mit-fm.md");
fs::write(&path, "---\ntitle: \"T\"\n---\n\nalter Text\n").unwrap();
write_body(&path, "neuer Text").unwrap();
assert_eq!(
fs::read_to_string(&path).unwrap(),
"---\ntitle: \"T\"\n---\n\nneuer Text\n"
);
// Ohne Frontmatter wird die Datei komplett ersetzt.
let plain = home.join("ohne-fm.md");
fs::write(&plain, "alt").unwrap();
write_body(&plain, "neu").unwrap();
assert_eq!(fs::read_to_string(&plain).unwrap(), "neu\n");
assert!(write_body(&home.join("fehlt.md"), "x").is_err());
}
#[test]
fn loeschen_und_ordner_verschieben() {
let home = archiv();
delete_doc(&home, "2026-07-19_1000-adr-logging.md").unwrap();
assert!(!home.join("2026-07-19_1000-adr-logging.md").exists());
move_folder(&home, "konzepte", "notizen/2026").unwrap();
assert!(home.join("notizen/2026/2026-07-19_1005-notiz-deploy.md").is_file());
assert!(!home.join("konzepte").exists());
let err = move_folder(&home, "fehlt", "x").unwrap_err();
assert!(err.contains("nicht gefunden"));
}
}
+1
View File
@@ -4,6 +4,7 @@
pub(crate) mod archive;
pub(crate) mod archive_index;
pub(crate) mod archive_ops;
pub(crate) mod archive_search;
pub(crate) mod credentials;
pub(crate) mod modules;
+12
View File
@@ -71,6 +71,18 @@ pub(crate) fn panel_file(project: &str) -> PathBuf {
Paths::real().panels_dir().join(format!("{}.md", checked(project)))
}
/// Quell-Verknüpfung des Panel-Inhalts: absoluter Pfad des Archiv-Dokuments,
/// aus dem der Dokument-Tab gerade geladen ist. Solange sie existiert,
/// schreibt jeder Editor-Commit den Body dorthin zurück; ein frischer Entwurf
/// (write_panel, Session-Start) entfernt sie. Liegt neben der Panel-Datei als
/// `<panel>.source`, damit der MCP-Server sie aus AI_CONTROL_PANEL ableiten
/// kann.
pub(crate) fn panel_source_file(project: &str) -> PathBuf {
Paths::real()
.panels_dir()
.join(format!("{}.md.source", checked(project)))
}
/// Command-History eines Projekts (JSONL, anhängend — flüchtig, wird beim
/// Session-Start geleert). Der Pfad landet als AI_CONTROL_COMMANDS in der
/// PTY-Umgebung.