From f58b9887c7b5f1b6a25798f956ac3ca10686f81f Mon Sep 17 00:00:00 2001 From: marcus hinz Date: Thu, 23 Jul 2026 21:17:41 +0200 Subject: [PATCH] =?UTF-8?q?Archiv-Navigator:=20Baum=20nur=20Ordner=20+=20f?= =?UTF-8?q?lache=20Inhaltsliste=20(Zeilen:=20=C3=B6ffnen/umbenennen/l?= =?UTF-8?q?=C3=B6schen),=20Ordner/Dokument=20anlegen=20(Dateiname=20ohne?= =?UTF-8?q?=20Stempel,=20Datum=20im=20Frontmatter),=20Write-back=20ge?= =?UTF-8?q?=C3=B6ffneter=20Archiv-Dokumente=20=C3=BCber=20Quell-Verkn?= =?UTF-8?q?=C3=BCpfung;=20Abl=C3=B6sen=20=C3=BCbergibt=20aktiven=20Tab?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/build.rs | 6 + src-tauri/capabilities/panel.json | 6 + src-tauri/capabilities/terminal.json | 6 + .../autogenerated/archive_create_doc.toml | 11 + .../autogenerated/archive_create_folder.toml | 11 + .../autogenerated/archive_delete.toml | 11 + .../autogenerated/archive_move.toml | 11 + .../autogenerated/archive_move_folder.toml | 11 + .../autogenerated/archive_rename.toml | 11 + src-tauri/src/app.rs | 6 + src-tauri/src/domain/archive.rs | 17 +- src-tauri/src/domain/archive_index.rs | 157 ++++--- src-tauri/src/domain/archive_ops.rs | 275 ++++++++++++ src-tauri/src/domain/mod.rs | 1 + src-tauri/src/domain/paths.rs | 12 + src-tauri/src/mcp.rs | 12 +- src-tauri/src/terminal.rs | 199 +++++++-- src/messages.test.ts | 2 +- src/messages.ts | 32 +- src/modules/archive.ts | 27 +- src/panel-tiles.css | 311 +++++++------ src/panel-wiring.ts | 5 +- src/panel.ts | 10 +- src/terminal.ts | 5 +- src/wiki-view.test.ts | 263 +++++++---- src/wiki-view.ts | 412 ++++++++++++------ 26 files changed, 1362 insertions(+), 468 deletions(-) create mode 100644 src-tauri/permissions/autogenerated/archive_create_doc.toml create mode 100644 src-tauri/permissions/autogenerated/archive_create_folder.toml create mode 100644 src-tauri/permissions/autogenerated/archive_delete.toml create mode 100644 src-tauri/permissions/autogenerated/archive_move.toml create mode 100644 src-tauri/permissions/autogenerated/archive_move_folder.toml create mode 100644 src-tauri/permissions/autogenerated/archive_rename.toml create mode 100644 src-tauri/src/domain/archive_ops.rs diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 2a603a7..ed32c9e 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -66,6 +66,12 @@ fn main() { "search_run", "panel_load", "wiki_open", + "archive_move", + "archive_rename", + "archive_delete", + "archive_move_folder", + "archive_create_folder", + "archive_create_doc", "open_panel_window", ])), ) diff --git a/src-tauri/capabilities/panel.json b/src-tauri/capabilities/panel.json index fc7991b..dd46936 100644 --- a/src-tauri/capabilities/panel.json +++ b/src-tauri/capabilities/panel.json @@ -20,6 +20,12 @@ "autostart:default", "dialog:default", "clipboard-manager:allow-write-text", + "allow-archive-create-doc", + "allow-archive-create-folder", + "allow-archive-delete", + "allow-archive-move", + "allow-archive-move-folder", + "allow-archive-rename", "allow-buffer-read", "allow-commands-delete", "allow-enabled-modules", diff --git a/src-tauri/capabilities/terminal.json b/src-tauri/capabilities/terminal.json index b78aa1d..7ad7f2d 100644 --- a/src-tauri/capabilities/terminal.json +++ b/src-tauri/capabilities/terminal.json @@ -20,6 +20,12 @@ "autostart:default", "dialog:default", "clipboard-manager:allow-write-text", + "allow-archive-create-doc", + "allow-archive-create-folder", + "allow-archive-delete", + "allow-archive-move", + "allow-archive-move-folder", + "allow-archive-rename", "allow-buffer-read", "allow-commands-delete", "allow-enabled-modules", diff --git a/src-tauri/permissions/autogenerated/archive_create_doc.toml b/src-tauri/permissions/autogenerated/archive_create_doc.toml new file mode 100644 index 0000000..e70f06e --- /dev/null +++ b/src-tauri/permissions/autogenerated/archive_create_doc.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-archive-create-doc" +description = "Enables the archive_create_doc command without any pre-configured scope." +commands.allow = ["archive_create_doc"] + +[[permission]] +identifier = "deny-archive-create-doc" +description = "Denies the archive_create_doc command without any pre-configured scope." +commands.deny = ["archive_create_doc"] diff --git a/src-tauri/permissions/autogenerated/archive_create_folder.toml b/src-tauri/permissions/autogenerated/archive_create_folder.toml new file mode 100644 index 0000000..df9f669 --- /dev/null +++ b/src-tauri/permissions/autogenerated/archive_create_folder.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-archive-create-folder" +description = "Enables the archive_create_folder command without any pre-configured scope." +commands.allow = ["archive_create_folder"] + +[[permission]] +identifier = "deny-archive-create-folder" +description = "Denies the archive_create_folder command without any pre-configured scope." +commands.deny = ["archive_create_folder"] diff --git a/src-tauri/permissions/autogenerated/archive_delete.toml b/src-tauri/permissions/autogenerated/archive_delete.toml new file mode 100644 index 0000000..db1a05b --- /dev/null +++ b/src-tauri/permissions/autogenerated/archive_delete.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-archive-delete" +description = "Enables the archive_delete command without any pre-configured scope." +commands.allow = ["archive_delete"] + +[[permission]] +identifier = "deny-archive-delete" +description = "Denies the archive_delete command without any pre-configured scope." +commands.deny = ["archive_delete"] diff --git a/src-tauri/permissions/autogenerated/archive_move.toml b/src-tauri/permissions/autogenerated/archive_move.toml new file mode 100644 index 0000000..6c0ba01 --- /dev/null +++ b/src-tauri/permissions/autogenerated/archive_move.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-archive-move" +description = "Enables the archive_move command without any pre-configured scope." +commands.allow = ["archive_move"] + +[[permission]] +identifier = "deny-archive-move" +description = "Denies the archive_move command without any pre-configured scope." +commands.deny = ["archive_move"] diff --git a/src-tauri/permissions/autogenerated/archive_move_folder.toml b/src-tauri/permissions/autogenerated/archive_move_folder.toml new file mode 100644 index 0000000..33fbfbb --- /dev/null +++ b/src-tauri/permissions/autogenerated/archive_move_folder.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-archive-move-folder" +description = "Enables the archive_move_folder command without any pre-configured scope." +commands.allow = ["archive_move_folder"] + +[[permission]] +identifier = "deny-archive-move-folder" +description = "Denies the archive_move_folder command without any pre-configured scope." +commands.deny = ["archive_move_folder"] diff --git a/src-tauri/permissions/autogenerated/archive_rename.toml b/src-tauri/permissions/autogenerated/archive_rename.toml new file mode 100644 index 0000000..99ae3c5 --- /dev/null +++ b/src-tauri/permissions/autogenerated/archive_rename.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-archive-rename" +description = "Enables the archive_rename command without any pre-configured scope." +commands.allow = ["archive_rename"] + +[[permission]] +identifier = "deny-archive-rename" +description = "Denies the archive_rename command without any pre-configured scope." +commands.deny = ["archive_rename"] diff --git a/src-tauri/src/app.rs b/src-tauri/src/app.rs index 7fb16fb..1bcb632 100644 --- a/src-tauri/src/app.rs +++ b/src-tauri/src/app.rs @@ -311,6 +311,12 @@ fn invoke_handlers() -> impl Fn(tauri::ipc::Invoke) -> bool + Send + terminal::search_run, terminal::panel_load, terminal::wiki_open, + terminal::archive_move, + terminal::archive_rename, + terminal::archive_delete, + terminal::archive_move_folder, + terminal::archive_create_folder, + terminal::archive_create_doc, terminal::open_panel_window ] } diff --git a/src-tauri/src/domain/archive.rs b/src-tauri/src/domain/archive.rs index c7101ad..e287277 100644 --- a/src-tauri/src/domain/archive.rs +++ b/src-tauri/src/domain/archive.rs @@ -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 { + 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); diff --git a/src-tauri/src/domain/archive_index.rs b/src-tauri/src/domain/archive_index.rs index 19a7125..95624e8 100644 --- a/src-tauri/src/domain/archive_index.rs +++ b/src-tauri/src/domain/archive_index.rs @@ -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, pub(crate) tags: Vec, + /// Frontmatter-`created` (ISO) — Datumsquelle für Dokumente ohne + /// Zeitstempel im Dateinamen. + pub(crate) created: Option, /// Wikilink-Ziele im Dokumenttext (`[[ziel]]`/`[[ziel|label]]`, nur das Ziel). pub(crate) links: Vec, /// 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, pub(crate) total: usize, pub(crate) tags: Vec, - /// 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, pub(crate) folders: Vec, } @@ -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, @@ -179,6 +181,16 @@ pub(crate) fn archive_page(home: &Path, tag: Option<&str>) -> Result> = 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 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) -> Result) -> 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, - pub(crate) backlinks: Vec, - pub(crate) markdown: String, -} - -pub(crate) fn wiki_doc(home: &Path, target: &str) -> Result { - 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 { + 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()); } } diff --git a/src-tauri/src/domain/archive_ops.rs b/src-tauri/src/domain/archive_ops.rs new file mode 100644 index 0000000..767a38b --- /dev/null +++ b/src-tauri/src/domain/archive_ops.rs @@ -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 { + 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 { + 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 { + 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: `.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 { + 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")); + } +} diff --git a/src-tauri/src/domain/mod.rs b/src-tauri/src/domain/mod.rs index 6eb3f69..2c8d14e 100644 --- a/src-tauri/src/domain/mod.rs +++ b/src-tauri/src/domain/mod.rs @@ -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; diff --git a/src-tauri/src/domain/paths.rs b/src-tauri/src/domain/paths.rs index 0a738ef..4ac1fe7 100644 --- a/src-tauri/src/domain/paths.rs +++ b/src-tauri/src/domain/paths.rs @@ -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 +/// `.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. diff --git a/src-tauri/src/mcp.rs b/src-tauri/src/mcp.rs index d69daa4..8043ef4 100644 --- a/src-tauri/src/mcp.rs +++ b/src-tauri/src/mcp.rs @@ -324,10 +324,18 @@ fn env_path(var: &str) -> Result { .map_err(|_| err(format!("{var} nicht gesetzt (Terminal außerhalb ai-control)."))) } -/// Schreibt Text in die Panel-Datei aus AI_CONTROL_PANEL. +/// Schreibt Text in die Panel-Datei aus AI_CONTROL_PANEL. Ein frischer +/// Entwurf löst die Quell-Verknüpfung des Dokument-Tabs (`.source`, +/// gesetzt beim Öffnen eines Archiv-Dokuments) — Edits gehören danach wieder +/// nur dem Panel, nicht der zuletzt geöffneten Archiv-Datei. fn write_panel_text(text: &str) -> Result<(), Value> { let path = env_path("AI_CONTROL_PANEL")?; - std::fs::write(&path, text).map_err(|e| err(format!("Panel-Datei nicht schreibbar: {e}"))) + std::fs::write(&path, text).map_err(|e| err(format!("Panel-Datei nicht schreibbar: {e}")))?; + match std::fs::remove_file(format!("{path}.source")) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(err(format!("Quell-Verknüpfung nicht gelöst: {e}"))), + } } fn call_write(req: &Value) -> Value { diff --git a/src-tauri/src/terminal.rs b/src-tauri/src/terminal.rs index 5822477..0db419f 100644 --- a/src-tauri/src/terminal.rs +++ b/src-tauri/src/terminal.rs @@ -5,7 +5,9 @@ use std::io::{Read, Write}; use std::sync::Mutex; use tauri::{AppHandle, Emitter, Manager, State, WebviewUrl, WebviewWindowBuilder}; -use crate::domain::paths::{commands_file, panel_file, search_file, wiki_file, Paths}; +use crate::domain::paths::{ + commands_file, panel_file, panel_source_file, search_file, wiki_file, Paths, +}; use crate::domain::project::{ project_config, project_pool_dir, verify_project_dir_in, ProjectConfig, }; @@ -145,6 +147,13 @@ pub fn term_start( let _ = std::fs::write(path, ""); } } + // Quell-Verknüpfung der Vorsession lösen — der Dokument-Puffer startet leer, + // Edits dürfen nicht in die zuletzt geöffnete Archiv-Datei laufen. + match std::fs::remove_file(panel_source_file(&project)) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(e.to_string()), + } let mut cmd = crate::platform::shell_command(&claude_command(&paths)); cmd.cwd(&cwd); @@ -419,21 +428,76 @@ pub fn todos_update( std::fs::write(&path, out).map_err(|e| e.to_string()) } +/// Quell-Verknüpfung des Dokument-Tabs setzen: ab jetzt schreibt jeder +/// Editor-Commit den Body in diese Archiv-Datei zurück. +fn link_panel_source(project: &str, path: &std::path::Path) -> Result<(), String> { + std::fs::write(panel_source_file(project), path.display().to_string()) + .map_err(|e| e.to_string()) +} + +/// Aktuelle Quell-Verknüpfung, falls gesetzt. +fn panel_source(project: &str) -> Option { + std::fs::read_to_string(panel_source_file(project)) + .ok() + .map(|s| std::path::PathBuf::from(s.trim())) + .filter(|p| !p.as_os_str().is_empty()) +} + +/// Verknüpfung nach einer Dokument-Operation nachziehen: zeigt sie auf den +/// alten Pfad, wandert sie mit; `None` löst sie (gelöschtes Dokument). +fn relink( + project: &str, + old: &std::path::Path, + new: Option<&std::path::Path>, +) -> Result<(), String> { + if panel_source(project).as_deref() != Some(old) { + return Ok(()); + } + match new { + Some(p) => link_panel_source(project, p), + None => std::fs::remove_file(panel_source_file(project)).map_err(|e| e.to_string()), + } +} + +/// Wie `relink`, für einen verschobenen Ordner: eine Verknüpfung auf ein +/// Dokument darin wandert mit. +fn relink_folder( + project: &str, + old: &std::path::Path, + new: &std::path::Path, +) -> Result<(), String> { + let Some(cur) = panel_source(project) else { + return Ok(()); + }; + match cur.strip_prefix(old) { + Ok(rest) => link_panel_source(project, &new.join(rest)), + Err(_) => Ok(()), + } +} + /// Schreibt den Panel-Inhalt (Titel-Edit im Panel). Der Watcher meldet die -/// Änderung als `panel-update` an alle Fenster. +/// Änderung als `panel-update` an alle Fenster. Zeigt der Dokument-Tab ein +/// Archiv-Dokument (Quell-Verknüpfung), wandert der Body implizit in die +/// Archiv-Datei zurück; ihre Frontmatter bleibt. #[tauri::command] pub fn panel_set(project: String, text: String) -> Result<(), String> { - std::fs::write(panel_file(&project), text).map_err(|e| e.to_string()) + std::fs::write(panel_file(&project), &text).map_err(|e| e.to_string())?; + match panel_source(&project) { + Some(src) => crate::domain::archive_ops::write_body(&src, &text), + None => Ok(()), + } } /// Lädt ein Archiv-Dokument in den Dokument-Puffer (Treffer-Klick in der -/// Suche) — ohne Frontmatter-Block, wie ein frischer Entwurf. Der Watcher -/// meldet den neuen Inhalt als `panel-update`. +/// Suche) — ohne Frontmatter-Block, wie ein frischer Entwurf — und setzt die +/// Quell-Verknüpfung fürs Zurückschreiben. Der Watcher meldet den neuen +/// Inhalt als `panel-update`. #[tauri::command] pub fn panel_load(project: String, path: String) -> Result<(), String> { let text = std::fs::read_to_string(&path).map_err(|e| format!("{path}: {e}"))?; let body = crate::domain::archive::strip_frontmatter(&text); - std::fs::write(panel_file(&project), body).map_err(|e| e.to_string()) + std::fs::write(panel_file(&project), body).map_err(|e| e.to_string())?; + link_panel_source(&project, std::path::Path::new(&path)) } /// Suche aus dem Panel-Suchfeld: läuft wie das MCP-Tool search_archive und @@ -452,23 +516,102 @@ pub fn search_run(project: String, query: String, tag: Option) -> Result std::fs::write(search_file(&project), payload.to_string()).map_err(|e| e.to_string()) } -/// Öffnet ein Wiki-Ziel (Klick auf einen `[[…]]`-Link oder Suchtreffer): -/// `tag:x` → Schlagwort-Seite, `tag:` → Archiv-Übersicht, sonst -/// Dokument-Auflösung über den Index. Der `tag:`-Namensraum ist damit dort -/// interpretiert, wo archive_page ihn erzeugt — nicht im Frontend. Schreibt -/// den Wiki-Puffer; der Watcher meldet ihn als `wiki-update`. +/// Öffnet ein Wiki-Ziel: `tag:x` → Schlagwort-Seite, `tag:` → +/// Archiv-Übersicht in den Wiki-Puffer (`wiki-update`); ein Dokumentname +/// (Wikilink, Backlink) wird über den Index aufgelöst und wie ein +/// Suchtreffer in den Dokument-Puffer geladen (`panel-update`). #[tauri::command] pub fn wiki_open(project: String, name: String) -> Result<(), String> { let home = crate::domain::archive::require_archive_home(&project)?; - let json = match name.strip_prefix("tag:") { - Some(tag) => serde_json::to_string(&crate::domain::archive_index::archive_page( - &home, - (!tag.is_empty()).then_some(tag), - )?), - None => serde_json::to_string(&crate::domain::archive_index::wiki_doc(&home, &name)?), + match name.strip_prefix("tag:") { + Some(tag) => { + let json = serde_json::to_string(&crate::domain::archive_index::archive_page( + &home, + (!tag.is_empty()).then_some(tag), + )?) + .map_err(|e| e.to_string())?; + std::fs::write(wiki_file(&project), json).map_err(|e| e.to_string()) + } + None => { + let rel = crate::domain::archive_index::resolve_doc(&home, &name)?; + let path = home.join(rel); + let text = + std::fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?; + let body = crate::domain::archive::strip_frontmatter(&text); + std::fs::write(panel_file(&project), body).map_err(|e| e.to_string())?; + link_panel_source(&project, &path) + } } - .map_err(|e| e.to_string())?; - std::fs::write(wiki_file(&project), json).map_err(|e| e.to_string()) +} + +/// Frische Archiv-Übersicht in den Wiki-Puffer — Abschluss der +/// Dokument-/Ordner-Operationen; der Watcher meldet `wiki-update`. +fn wiki_refresh_page(project: &str, home: &std::path::Path) -> Result<(), String> { + let json = serde_json::to_string(&crate::domain::archive_index::archive_page(home, None)?) + .map_err(|e| e.to_string())?; + std::fs::write(wiki_file(project), json).map_err(|e| e.to_string()) +} + +/// Verschiebt ein Archiv-Dokument in einen anderen Ordner (leer = Wurzel). +#[tauri::command] +pub fn archive_move(project: String, relpath: String, folder: String) -> Result<(), String> { + let home = crate::domain::archive::require_archive_home(&project)?; + let old = home.join(&relpath); + let new_rel = crate::domain::archive_ops::move_doc(&home, &relpath, &folder)?; + relink(&project, &old, Some(&home.join(new_rel)))?; + wiki_refresh_page(&project, &home) +} + +/// Benennt ein Archiv-Dokument um (Slug hinter erhaltenem Zeitstempel). +#[tauri::command] +pub fn archive_rename(project: String, relpath: String, name: String) -> Result<(), String> { + let home = crate::domain::archive::require_archive_home(&project)?; + let old = home.join(&relpath); + let new_rel = crate::domain::archive_ops::rename_doc(&home, &relpath, &name)?; + relink(&project, &old, Some(&home.join(new_rel)))?; + wiki_refresh_page(&project, &home) +} + +/// Löscht ein Archiv-Dokument; danach zeigt das Wiki die Übersicht. +#[tauri::command] +pub fn archive_delete(project: String, relpath: String) -> Result<(), String> { + let home = crate::domain::archive::require_archive_home(&project)?; + crate::domain::archive_ops::delete_doc(&home, &relpath)?; + relink(&project, &home.join(&relpath), None)?; + wiki_refresh_page(&project, &home) +} + +/// Verschiebt/benennt einen Archiv-Ordner um; danach Übersicht. +#[tauri::command] +pub fn archive_move_folder(project: String, folder: String, to: String) -> Result<(), String> { + let home = crate::domain::archive::require_archive_home(&project)?; + crate::domain::archive_ops::move_folder(&home, &folder, &to)?; + relink_folder(&project, &home.join(&folder), &home.join(&to))?; + wiki_refresh_page(&project, &home) +} + +/// Legt einen Ordner im Archiv an (Plus im Baum); danach Übersicht. +#[tauri::command] +pub fn archive_create_folder(project: String, folder: String) -> Result<(), String> { + let home = crate::domain::archive::require_archive_home(&project)?; + crate::domain::archive_ops::create_folder(&home, &folder)?; + wiki_refresh_page(&project, &home) +} + +/// Legt ein leeres Dokument an (Plus im Listenkopf) und öffnet es im +/// Dokument-Tab: leerer Dokument-Puffer plus Quell-Verknüpfung — das +/// Getippte landet über `panel_set` in der Archiv-Datei. Die Übersicht +/// bleibt hier unangetastet; der Wiki-Tab lädt sie beim nächsten Aktivieren +/// frisch (zwei konkurrierende Puffer-Events würden sonst um den aktiven Tab +/// rennen). +#[tauri::command] +pub fn archive_create_doc(project: String, folder: String, name: String) -> Result<(), String> { + let home = crate::domain::archive::require_archive_home(&project)?; + let display = crate::domain::project::display_name_in(&Paths::real(), &project)?; + let rel = crate::domain::archive_ops::create_doc(&home, &folder, &name, &display)?; + let path = home.join(rel); + std::fs::write(panel_file(&project), "").map_err(|e| e.to_string())?; + link_panel_source(&project, &path) } /// Löst das Panel in ein eigenes Fenster ab. Existiert es schon, kommt es nach @@ -476,7 +619,11 @@ pub fn wiki_open(project: String, name: String) -> Result<(), String> { /// Async, weil Fenster-Erzeugung aus einem synchronen Command auf dem /// GTK-Mainloop klemmen kann (Tauri-Vorgabe für window create in Commands). #[tauri::command] -pub async fn open_panel_window(app: AppHandle, project: String) -> Result<(), String> { +pub async fn open_panel_window( + app: AppHandle, + project: String, + mode: Option, +) -> Result<(), String> { let label = format!("panel-{project}"); if let Some(w) = app.get_webview_window(&label) { let _ = w.set_focus(); @@ -490,11 +637,13 @@ pub async fn open_panel_window(app: AppHandle, project: String) -> Result<(), St .as_deref() .or(cfg.name.as_deref()) .unwrap_or(project.as_str()); - let builder = WebviewWindowBuilder::new( - &app, - &label, - WebviewUrl::App(format!("panel.html?project={project}").into()), - ) + // Der beim Ablösen aktive Tab geht als URL-Parameter mit — das neue + // Fenster startet dort, statt in seiner Default-Ansicht. + let url = match &mode { + Some(m) => format!("panel.html?project={project}&mode={m}"), + None => format!("panel.html?project={project}"), + }; + let builder = WebviewWindowBuilder::new(&app, &label, WebviewUrl::App(url.into())) .title(format!("{title} — Dokument")) .inner_size(480.0, 640.0) .background_color(tauri::window::Color(r, g, b, 0xff)); diff --git a/src/messages.test.ts b/src/messages.test.ts index f3e0a74..b799a55 100644 --- a/src/messages.test.ts +++ b/src/messages.test.ts @@ -77,7 +77,7 @@ describe("t()", () => { }); it("lässt unbekannte Platzhalter stehen", () => { - expect(t("wiki.emptyTag", { falsch: "x" })).toBe("Keine Dokumente mit #{tag}."); + expect(t("search.noHits", { falsch: "x" })).toBe("Keine Treffer für {scope}"); }); it("gibt bei fehlendem Schlüssel den Pfad zurück", () => { diff --git a/src/messages.ts b/src/messages.ts index b8c20c5..736fb9b 100644 --- a/src/messages.ts +++ b/src/messages.ts @@ -235,18 +235,22 @@ const de = { archive: "Archiv", docOne: "{count} Dokument", docMany: "{count} Dokumente", - all: "Alle", backlinks: "Verweise hierher", - backlinksLabel: "Verweise hierher: ", - emptyTag: "Keine Dokumente mit #{tag}.", emptyArchive: "Das Archiv ist leer.", + emptyFolder: "Keine Dokumente in diesem Ordner.", emptyHint: "Archivieren: Archiv-Button im Entwurf oder „archiviere das“ im Chat — mit Ordner, Beschreibung und Schlagwörtern.", - recent: "Zuletzt", - root: "Wurzel", - back: "‹ Archiv", noPage: "Keine Wiki-Seite geladen.", openOverview: "Archiv-Übersicht öffnen", + folderRename: "Ordner umbenennen", + renameDoc: "Dokument umbenennen", + deleteDoc: "Dokument löschen", + newFolder: "Neuer Ordner", + newDoc: "Neues Dokument", + create: "Anlegen", + cancel: "Abbrechen", + folderPath: "ordner/unterordner", + docName: "Name des Dokuments", }, }; @@ -480,18 +484,22 @@ const en: typeof de = { archive: "Archive", docOne: "{count} document", docMany: "{count} documents", - all: "All", backlinks: "Links here", - backlinksLabel: "Links here: ", - emptyTag: "No documents tagged #{tag}.", emptyArchive: "The archive is empty.", + emptyFolder: "No documents in this folder.", emptyHint: "To archive: the archive button in the draft, or “archive this” in the chat — with folder, description and tags.", - recent: "Recent", - root: "Root", - back: "‹ Archive", noPage: "No wiki page loaded.", openOverview: "Open archive overview", + folderRename: "Rename folder", + renameDoc: "Rename document", + deleteDoc: "Delete document", + newFolder: "New folder", + newDoc: "New document", + create: "Create", + cancel: "Cancel", + folderPath: "folder/subfolder", + docName: "Document name", }, }; diff --git a/src/modules/archive.ts b/src/modules/archive.ts index 14c88ec..bfc101a 100644 --- a/src/modules/archive.ts +++ b/src/modules/archive.ts @@ -9,11 +9,30 @@ export const wikiTab: PanelTab = { buffer: "wiki", labelKey: "panel.tabWiki", titleKey: "panel.tabWikiTitle", - init: (container, ctx) => initWikiView(container, ctx.openWiki), - // Wiki-Tab bei leerem Puffer (Session-Start): Übersicht direkt laden. - onActivate: (view, ctx) => { - if (view.empty()) ctx.openWiki("tag:"); + // Dokument-/Ordner-Operationen laufen als Commands; der neue Stand kommt + // über den Wiki-Puffer (wiki-update) zurück, Fehler als Toast. Der + // Dokument-Sprung lädt wie ein Suchtreffer in den Dokument-Tab. + init: (container, ctx) => { + const run = (cmd: string, args: Record) => + void invoke(cmd, { project: ctx.project, ...args }).catch((e) => + ctx.toast(String(e)), + ); + return initWikiView(container, { + openDoc: ctx.openDoc, + openWiki: ctx.openWiki, + actions: { + rename: (relpath, name) => run("archive_rename", { relpath, name }), + remove: (relpath) => run("archive_delete", { relpath }), + moveFolder: (folder, to) => run("archive_move_folder", { folder, to }), + createFolder: (folder) => run("archive_create_folder", { folder }), + createDoc: (folder, name) => run("archive_create_doc", { folder, name }), + }, + }); }, + // Beim Aktivieren die Übersicht frisch laden: das Anlegen eines Dokuments + // öffnet den Dokument-Tab, ohne den Wiki-Puffer anzufassen — und auch + // direkt archivierte oder von Hand abgelegte Dateien erscheinen so. + onActivate: (_view, ctx) => ctx.openWiki("tag:"), }; export const searchTab: PanelTab = { diff --git a/src/panel-tiles.css b/src/panel-tiles.css index c612514..eb6564c 100644 --- a/src/panel-tiles.css +++ b/src/panel-tiles.css @@ -311,7 +311,7 @@ font-size: 10px; } -/* Wiki-Ansicht: Übersichts-/Schlagwort-Seiten und Dokumente. */ +/* Wiki-Ansicht: Ordner-Baum links, flache Inhaltsliste rechts. */ #wiki-content { flex: 1; min-height: 0; @@ -325,6 +325,189 @@ background: var(--line); border-radius: 4px; } +/* Baum links, Seite rechts; beide scrollen gemeinsam im Tab. */ +.wiki-layout { + display: flex; + align-items: flex-start; + gap: 12px; +} +.wiki-tree { + flex: 0 0 150px; + min-width: 0; + border-right: 1px solid var(--line); + padding-right: 8px; + position: sticky; + top: 0; +} +.wiki-main { + flex: 1; + min-width: 0; +} +.wiki-tree details > .wiki-tree-children { + margin-left: 10px; +} +.wiki-tree summary { + display: flex; + align-items: center; + gap: 2px; + list-style: none; + cursor: pointer; + padding: 2px 2px; + color: var(--muted); + font: + 500 11px/1.4 -apple-system, + system-ui, + sans-serif; + user-select: none; + -webkit-user-select: none; +} +.wiki-tree summary::-webkit-details-marker { + display: none; +} +.wiki-tree summary::before { + content: "▸"; + color: var(--faint); + font-size: 9px; + margin-right: 3px; +} +.wiki-tree details[open] > summary::before { + content: "▾"; +} +.wiki-tree-name { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.wiki-tree summary .panel-btn { + visibility: hidden; +} +.wiki-tree summary:hover .panel-btn { + visibility: visible; +} +.wiki-tree summary:hover, +.wiki-tree-root:hover { + background: var(--tile); + border-radius: 4px; +} +.wiki-tree summary.active, +.wiki-tree-root.active { + color: var(--accent); +} +.wiki-tree-root { + display: block; + width: 100%; + text-align: left; + border: none; + background: none; + color: var(--muted); + border-radius: 4px; + padding: 2px 4px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font: + 500 11px/1.5 -apple-system, + system-ui, + sans-serif; +} +.wiki-tree-input { + box-sizing: border-box; + width: 100%; + background: var(--surface); + border: 1px solid var(--line-strong); + border-radius: 4px; + padding: 2px 4px; + color: var(--text); + font: + 400 11px/1.4 -apple-system, + system-ui, + sans-serif; + outline: none; +} +/* Zeilen-Aktionen erst beim Überfahren der Zeile. */ +.wiki-doc-actions { + display: flex; + gap: 2px; + visibility: hidden; +} +.wiki-doc:hover .wiki-doc-actions { + visibility: visible; +} +/* Kopfzeile des Baums: Wurzel-Knopf + Ordner anlegen. */ +.wiki-tree-head { + display: flex; + align-items: center; + gap: 2px; +} +.wiki-tree-head .wiki-tree-root { + flex: 1; + min-width: 0; +} +.wiki-add { + border: none; + background: none; + color: var(--muted); + border-radius: 4px; + padding: 0 5px; + font: + 500 13px/1.4 -apple-system, + system-ui, + sans-serif; + cursor: pointer; +} +.wiki-add:hover { + background: var(--tile); + color: var(--text); +} +/* Anlege-Formular (Ordner/Dokument) oben in der Liste. */ +.wiki-form { + display: flex; + align-items: center; + gap: 6px; + background: var(--surface); + border: 1px solid var(--line-strong); + border-radius: 8px; + padding: 8px 10px; + margin: 10px 0 4px; +} +.wiki-form-title { + color: var(--muted); + font: + 500 11px/1.4 -apple-system, + system-ui, + sans-serif; + white-space: nowrap; +} +.wiki-form .wiki-tree-input { + flex: 1; + width: auto; + padding: 4px 8px; + font-size: 12px; +} +.wiki-form-submit, +.wiki-form-cancel { + border: none; + background: var(--line); + color: var(--text); + border-radius: 6px; + padding: 4px 10px; + font: + 500 11px/1.4 -apple-system, + system-ui, + sans-serif; +} +.wiki-form-submit:hover { + background: var(--line-strong); +} +.wiki-form-cancel { + background: none; + color: var(--muted); +} +.wiki-form-cancel:hover { + background: var(--tile); +} .wiki-head { display: flex; align-items: baseline; @@ -358,12 +541,7 @@ sans-serif; white-space: nowrap; } -.wiki-chips { - display: flex; - flex-wrap: wrap; - gap: 4px; - margin: 10px 0 4px; -} +/* Einstiegs-Knopf im Leerzustand. */ .wiki-chip { border: 1px solid var(--line); background: transparent; @@ -379,25 +557,6 @@ background: var(--line); color: var(--text); } -.wiki-chip.active { - background: var(--line); - border-color: var(--line-strong); - color: var(--accent); -} -.wiki-folder { - margin: 14px 0 2px; - padding-top: 10px; - border-top: 1px solid var(--line); - color: var(--faint); - font: - 500 10px/1 -apple-system, - system-ui, - sans-serif; - letter-spacing: 0.08em; - text-transform: uppercase; - user-select: none; - -webkit-user-select: none; -} .wiki-doc { padding: 7px 8px; margin: 4px -8px 0; @@ -434,12 +593,6 @@ system-ui, sans-serif; } -.wiki-doc-tags { - display: flex; - flex-wrap: wrap; - gap: 4px; - margin-top: 5px; -} .wiki-empty { margin-top: 18px; color: var(--muted); @@ -454,100 +607,6 @@ color: var(--text); font-weight: 600; } -.wiki-doc-head { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - margin-bottom: 8px; -} -.wiki-back { - border: none; - background: transparent; - color: var(--muted); - font: - 500 11px/1 -apple-system, - system-ui, - sans-serif; - border-radius: 6px; - padding: 4px 6px; -} -.wiki-back:hover { - background: var(--line); - color: var(--text); -} -.wiki-body { - font-size: 12.5px; - line-height: 1.55; - user-select: text; - -webkit-user-select: text; - cursor: text; -} -.wiki-body h1, -.wiki-body h2, -.wiki-body h3 { - line-height: 1.25; - margin: 1em 0 0.4em; -} -.wiki-body h1 { - font-size: 1.4em; -} -.wiki-body h2 { - font-size: 1.2em; -} -.wiki-body h3 { - font-size: 1.05em; -} -.wiki-body :first-child { - margin-top: 0; -} -.wiki-body p, -.wiki-body ul, -.wiki-body ol, -.wiki-body pre, -.wiki-body blockquote { - margin: 0 0 0.7em; -} -.wiki-body code { - font-family: "JetBrains Mono", Menlo, monospace; - font-size: 0.9em; - background: var(--line); - border-radius: 4px; - padding: 1px 5px; -} -.wiki-body pre { - background: var(--tile); - border-radius: 8px; - padding: 12px 14px; - overflow: auto; -} -.wiki-body pre code { - background: none; - padding: 0; -} -.wiki-body blockquote { - border-left: 3px solid var(--line); - padding-left: 12px; - color: var(--muted); -} -.wiki-body a { - color: var(--accent); -} -.wiki-backlinks { - margin-top: 14px; - padding-top: 8px; - border-top: 1px solid var(--line); - color: var(--faint); - font: - 500 11px/1.6 -apple-system, - system-ui, - sans-serif; -} -.wiki-backlinks a { - color: var(--accent); - text-decoration: none; -} - /* Backlink-Zähler in der Dokumentzeile. */ .wiki-doc-back { color: var(--faint); diff --git a/src/panel-wiring.ts b/src/panel-wiring.ts index a998601..5256c3b 100644 --- a/src/panel-wiring.ts +++ b/src/panel-wiring.ts @@ -123,10 +123,11 @@ export async function wirePanel( flush: () => void view.flush(), }); - // Sprung Dokument → Wiki: löst den angezeigten Titel gegen das Archiv auf. + // Sprung Dokument → Wiki: öffnet den Archiv-Navigator (Dokumentseiten im + // Wiki gibt es nicht mehr — Dokumente öffnen immer im Dokument-Tab). document .getElementById("panel-wiki-jump")! - .addEventListener("click", () => openWiki(titleEl.textContent || "")); + .addEventListener("click", () => openWiki("tag:")); view.set(draft); await listen("panel-update", (e) => { diff --git a/src/panel.ts b/src/panel.ts index 54d4900..6b8326c 100644 --- a/src/panel.ts +++ b/src/panel.ts @@ -100,11 +100,15 @@ document.documentElement.style.background = picked.header; document.body.style.background = picked.header; const { view, views, mode, draft } = await wirePanel(project); -// Ohne Entwurf startet das Fenster in der ToDo-Liste (falls gewählt und -// nicht leer), sonst in den Befehlen. +// Beim Ablösen kommt der dort aktive Tab als URL-Parameter mit; ohne ihn +// (Kaltstart) gilt die Default-Logik: ohne Entwurf die ToDo-Liste (falls +// gewählt und nicht leer), sonst die Befehle. +const initialMode = new URLSearchParams(location.search).get("mode"); const todos = views.get("todo"); const cmds = views.get("commands"); -if (!draft.trim()) { +if (initialMode) { + mode.to(initialMode); +} else if (!draft.trim()) { if (todos && !todos.empty()) mode.to("todo"); else if (cmds && !cmds.empty()) mode.to("commands"); } diff --git a/src/terminal.ts b/src/terminal.ts index 917fb4b..43fd649 100644 --- a/src/terminal.ts +++ b/src/terminal.ts @@ -254,9 +254,12 @@ await listen("panel-window-closed", () => { headerTabs.hidden = false; }); +// Der aktive Tab wandert mit ins abgelöste Fenster. document .getElementById("panel-detach")! - .addEventListener("click", () => invoke("open_panel_window", { project })); + .addEventListener("click", () => + invoke("open_panel_window", { project, mode: mode.current() }), + ); document.getElementById("panel-hide")!.addEventListener("click", () => { hidePanel(); mode.clear(); diff --git a/src/wiki-view.test.ts b/src/wiki-view.test.ts index 35e1766..3eda9ed 100644 --- a/src/wiki-view.test.ts +++ b/src/wiki-view.test.ts @@ -1,15 +1,29 @@ -// Wiki-Ansicht: Übersichts-/Schlagwort-Seiten, Dokumentseite, Leerzustände. -import { beforeEach, describe, expect, it, vi } from "vitest"; +// Wiki-Ansicht: Ordner-Baum links, flache Inhaltsliste rechts, Zeilen-Aktionen +// (öffnen, umbenennen, löschen), Ordner-Umbenennen, Leerzustände. +import { describe, expect, it, vi } from "vitest"; import { initWikiView } from "./wiki-view"; function setup() { document.body.innerHTML = `
`; - const onLink = vi.fn(); - const view = initWikiView(document.getElementById("w")!, onLink); - return { view, onLink }; + const openDoc = vi.fn(); + const openWiki = vi.fn(); + const actions = { + rename: vi.fn(), + remove: vi.fn(), + moveFolder: vi.fn(), + createFolder: vi.fn(), + createDoc: vi.fn(), + }; + const view = initWikiView(document.getElementById("w")!, { + openDoc, + openWiki, + actions, + }); + return { view, openDoc, openWiki, actions }; } -const doc = (name: string, extra: object = {}) => ({ +const doc = (relpath: string, name: string, extra: object = {}) => ({ + relpath, name, title: name.toUpperCase(), description: null, @@ -24,67 +38,185 @@ const page = JSON.stringify({ home: "/tmp/archiv", tag: null, total: 3, - tags: [ - { name: "adr", count: 2 }, - { name: "infra", count: 1 }, - ], - recent: [doc("neu")], + tags: [], folders: [ - { name: "", docs: [doc("wurzel-doc", { backlinks: 2, tags: ["adr"] })] }, - { name: "konzepte", docs: [doc("neu"), doc("alt")] }, + { + name: "", + docs: [ + doc("2026-07-19_1000-wurzel-doc.md", "wurzel-doc", { + backlinks: 2, + description: "Beschreibung", + }), + ], + }, + { name: "konzepte", docs: [doc("konzepte/2026-07-19_1005-neu.md", "neu")] }, + { + name: "konzepte/panel", + docs: [doc("konzepte/panel/2026-07-19_1010-alt.md", "alt")], + }, ], }); -describe("initWikiView — Seiten", () => { - it("rendert Kopf, Chips, Zuletzt- und Ordner-Sektionen", () => { +describe("initWikiView — Baum und Liste", () => { + it("Baum zeigt nur Ordner, Wurzel ist gewählt und listet ihren Inhalt", () => { const { view } = setup(); view.set(page); - expect(document.querySelector(".wiki-head-title")!.textContent).toBe("Archiv"); - expect(document.querySelector(".wiki-head-right")!.textContent).toBe("3 Dokumente"); - const chips = [...document.querySelectorAll(".wiki-chips .wiki-chip")].map( - (c) => c.textContent, - ); - expect(chips).toEqual(["Alle", "#adr 2", "#infra 1"]); - const eyebrows = [...document.querySelectorAll(".wiki-folder")].map( + // Baum: Wurzel-Knopf + Ordner (keine Dokumentzeilen im Baum). + expect(document.querySelector(".wiki-tree-root")!.className).toContain("active"); + const folders = [...document.querySelectorAll(".wiki-tree .wiki-tree-name")].map( (e) => e.textContent, ); - expect(eyebrows).toEqual(["Zuletzt", "Wurzel", "konzepte/"]); + expect(folders).toEqual(["konzepte", "panel"]); + // Liste rechts: nur der direkte Wurzel-Inhalt. + expect(document.querySelector(".wiki-head-title")!.textContent).toBe("Archiv"); + expect(document.querySelector(".wiki-head-right")!.textContent).toBe("1 Dokument"); + const rows = [...document.querySelectorAll(".wiki-doc .wiki-doc-title")].map( + (e) => e.textContent, + ); + expect(rows).toEqual(["WURZEL-DOC"]); expect(document.querySelector(".wiki-doc-back")!.textContent).toBe("↩ 2"); + expect(document.querySelector(".wiki-doc-desc")!.textContent).toBe("Beschreibung"); }); - it("Dokumentzeile öffnet das Dokument, Tag-Chip die Tag-Seite", () => { - const { view, onLink } = setup(); - view.set(page); - const rows = document.querySelectorAll(".wiki-doc"); - rows[1].click(); // Wurzel-Dokument (nach der Zuletzt-Zeile) - expect(onLink).toHaveBeenCalledWith("wurzel-doc"); - rows[1].querySelector(".wiki-doc-tags .wiki-chip")!.click(); - expect(onLink).toHaveBeenCalledWith("tag:adr"); - document.querySelector(".wiki-chips .wiki-chip")!.click(); - expect(onLink).toHaveBeenCalledWith("tag:"); - }); - - it("Tag-Seite: Titel, aktiver Chip, Leerfall", () => { + it("Ordner-Klick wählt aus und zeigt dessen flache Liste", () => { const { view } = setup(); + view.set(page); + const names = document.querySelectorAll(".wiki-tree .wiki-tree-name"); + names[0].click(); // konzepte + expect(document.querySelector(".wiki-head-title")!.textContent).toBe("konzepte"); + expect(document.querySelector(".wiki-head-sub")!.textContent).toBe( + "/tmp/archiv/konzepte", + ); + const rows = [...document.querySelectorAll(".wiki-doc .wiki-doc-title")].map( + (e) => e.textContent, + ); + // Nur direkter Inhalt — das Dokument aus konzepte/panel erscheint nicht. + expect(rows).toEqual(["NEU"]); + expect( + document.querySelector(".wiki-tree summary.active .wiki-tree-name")!.textContent, + ).toBe("konzepte"); + }); + + it("Dokument-Klick öffnet über den absoluten Pfad", () => { + const { view, openDoc } = setup(); + view.set(page); + document.querySelector(".wiki-doc")!.click(); + expect(openDoc).toHaveBeenCalledWith("/tmp/archiv/2026-07-19_1000-wurzel-doc.md"); + }); + + it("Auswahl übersteht ein Puffer-Update, verschwundener Ordner fällt zur Wurzel", () => { + const { view } = setup(); + view.set(page); + document.querySelectorAll(".wiki-tree .wiki-tree-name")[0].click(); + view.set(page); // Update: Auswahl bleibt + expect(document.querySelector(".wiki-head-title")!.textContent).toBe("konzepte"); view.set( JSON.stringify({ kind: "page", - home: "/a", - tag: "adr", - total: 0, - tags: [{ name: "adr", count: 1 }], - recent: [], - folders: [], + home: "/tmp/archiv", + tag: null, + total: 1, + tags: [], + folders: [{ name: "", docs: [doc("2026-07-19_1000-wurzel-doc.md", "wurzel-doc")] }], }), ); - expect(document.querySelector(".wiki-head-title")!.textContent).toBe("#adr"); - const active = document.querySelector(".wiki-chip.active")!; - expect(active.textContent).toBe("#adr 1"); - expect(document.querySelector(".wiki-empty strong")!.textContent).toBe( - "Keine Dokumente mit #adr.", + expect(document.querySelector(".wiki-head-title")!.textContent).toBe("Archiv"); + }); +}); + +describe("initWikiView — Aktionen", () => { + it("Stift an der Zeile: Inline-Umbenennen mit Enter, ohne das Dokument zu öffnen", () => { + const { view, actions, openDoc } = setup(); + view.set(page); + const row = document.querySelector(".wiki-doc")!; + row.querySelector(".cmd-edit")!.click(); + const input = row.querySelector(".wiki-tree-input")!; + expect(input.value).toBe("wurzel-doc"); + input.value = "Neuer Name"; + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + expect(actions.rename).toHaveBeenCalledWith( + "2026-07-19_1000-wurzel-doc.md", + "Neuer Name", ); + expect(openDoc).not.toHaveBeenCalled(); }); + it("Escape stellt die Zeile wieder her", () => { + const { view, actions } = setup(); + view.set(page); + document.querySelector(".wiki-doc .cmd-edit")!.click(); + const input = document.querySelector(".wiki-doc .wiki-tree-input")!; + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); + expect(document.querySelector(".wiki-doc .wiki-doc-title")!.textContent).toBe( + "WURZEL-DOC", + ); + expect(actions.rename).not.toHaveBeenCalled(); + }); + + it("Löschen meldet mit relpath, ohne das Dokument zu öffnen", () => { + const { view, actions, openDoc } = setup(); + view.set(page); + document.querySelector(".wiki-doc .cmd-del")!.click(); + expect(actions.remove).toHaveBeenCalledWith("2026-07-19_1000-wurzel-doc.md"); + expect(openDoc).not.toHaveBeenCalled(); + }); + + it("Plus im Listenkopf: Formular legt Dokument im gewählten Ordner an", () => { + const { view, actions, openDoc } = setup(); + view.set(page); + document.querySelectorAll(".wiki-tree .wiki-tree-name")[0].click(); + document.querySelector(".wiki-head .wiki-add")!.click(); + const form = document.querySelector(".wiki-form")!; + expect(form.querySelector(".wiki-form-title")!.textContent).toBe("Neues Dokument"); + const input = form.querySelector("input")!; + input.value = "Deploy Notiz"; + form.querySelector(".wiki-form-submit")!.click(); + expect(actions.createDoc).toHaveBeenCalledWith("konzepte", "Deploy Notiz"); + expect(document.querySelector(".wiki-form")).toBeNull(); + expect(openDoc).not.toHaveBeenCalled(); + }); + + it("Abbrechen und Escape schließen das Formular ohne Aktion", () => { + const { view, actions } = setup(); + view.set(page); + document.querySelector(".wiki-head .wiki-add")!.click(); + document.querySelector(".wiki-form-cancel")!.click(); + expect(document.querySelector(".wiki-form")).toBeNull(); + + document.querySelector(".wiki-head .wiki-add")!.click(); + document + .querySelector(".wiki-form")! + .dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); + expect(document.querySelector(".wiki-form")).toBeNull(); + expect(actions.createDoc).not.toHaveBeenCalled(); + }); + + it("Plus im Baum: Formular legt Ordner an, Pfad mit gewähltem Ordner vorbefüllt", () => { + const { view, actions } = setup(); + view.set(page); + document.querySelectorAll(".wiki-tree .wiki-tree-name")[0].click(); + document.querySelector(".wiki-tree-head .wiki-add")!.click(); + const input = document.querySelector(".wiki-form input")!; + expect(input.value).toBe("konzepte/"); + input.value = "konzepte/neu"; + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + expect(actions.createFolder).toHaveBeenCalledWith("konzepte/neu"); + }); + + it("Ordner-Stift: Inline-Eingabe des Pfads, Enter meldet moveFolder", () => { + const { view, actions } = setup(); + view.set(page); + const summary = document.querySelector(".wiki-tree summary")!; + summary.querySelector(".cmd-edit")!.click(); + const input = summary.querySelector(".wiki-tree-input")!; + expect(input.value).toBe("konzepte"); + input.value = "notizen/2026"; + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + expect(actions.moveFolder).toHaveBeenCalledWith("konzepte", "notizen/2026"); + }); +}); + +describe("initWikiView — Leerzustände", () => { it("leeres Archiv erklärt das Archivieren", () => { const { view } = setup(); view.set( @@ -94,7 +226,6 @@ describe("initWikiView — Seiten", () => { tag: null, total: 0, tags: [], - recent: [], folders: [], }), ); @@ -102,45 +233,13 @@ describe("initWikiView — Seiten", () => { "Das Archiv ist leer.", ); }); -}); - -describe("initWikiView — Dokument und Leerzustand", () => { - beforeEach(() => { - document.body.innerHTML = ""; - }); - - it("rendert Markdown, Rücksprung, Tags und Backlinks", () => { - const { view, onLink } = setup(); - view.set( - JSON.stringify({ - kind: "doc", - home: "/a", - relpath: "konzepte/2026-07-19_1000-x.md", - name: "x", - title: "X", - tags: ["adr"], - backlinks: ["anderes-doc"], - markdown: "# Überschrift\n\nText mit [[ziel|Label]].\n", - }), - ); - expect(document.querySelector(".wiki-body h1")!.textContent).toBe("Überschrift"); - const wiki = document.querySelector(".wiki-body a.wiki")!; - expect(wiki.textContent).toBe("Label"); - wiki.click(); - expect(onLink).toHaveBeenCalledWith("ziel"); - document.querySelector(".wiki-back")!.click(); - expect(onLink).toHaveBeenCalledWith("tag:"); - const back = document.querySelector(".wiki-backlinks a")!; - back.click(); - expect(onLink).toHaveBeenCalledWith("anderes-doc"); - }); it("leerer Puffer bietet den Übersichts-Einstieg", () => { - const { view, onLink } = setup(); + const { view, openWiki } = setup(); view.set(""); expect(view.empty()).toBe(true); document.querySelector(".wiki-empty .wiki-chip")!.click(); - expect(onLink).toHaveBeenCalledWith("tag:"); + expect(openWiki).toHaveBeenCalledWith("tag:"); view.set(page); expect(view.empty()).toBe(false); }); diff --git a/src/wiki-view.ts b/src/wiki-view.ts index 6891123..4da564f 100644 --- a/src/wiki-view.ts +++ b/src/wiki-view.ts @@ -1,15 +1,16 @@ -/// Wiki-Ansicht des Panels: rendert den Wiki-Puffer (show_archive/wiki_open). -/// Übersichts- und Schlagwort-Seiten kommen als strukturierte Daten (Kopfzeile, -/// Schlagwort-Chips, Ordner-Sektionen mit Dokumentzeilen), Dokumente als -/// gerendertes Markdown mit Backlinks. Titel, Beschreibungen und Pfade sind -/// Fremdtext und gehen nie durch innerHTML; nur der Markdown-Rumpf läuft wie -/// im Entwurf durch marked. +/// Wiki-Ansicht des Panels: Ordner-Navigator übers Archiv. Links der Baum — +/// nur Ordner, oberster Knoten ist die Archiv-Wurzel; Klick auf einen Ordner +/// zeigt rechts die flache Liste seines direkten Inhalts. Klick auf eine +/// Dokumentzeile lädt das Dokument in den Dokument-Tab (wie ein +/// Suchtreffer); an der Zeile: umbenennen (inline) und löschen, am +/// Ordnerknoten: umbenennen. Titel, Beschreibungen und Pfade sind Fremdtext +/// und gehen nie durch innerHTML. -import { renderMarkdown } from "./markdown"; -import { linkWikiRefs } from "./panel-view"; import { t } from "./messages"; +import { deleteAction, editAction } from "./tiles"; interface DocEntry { + relpath: string; name: string; title: string; description?: string | null; @@ -29,19 +30,25 @@ interface Page { tag?: string | null; total: number; tags: { name: string; count: number }[]; - recent: DocEntry[]; folders: Folder[]; } -interface DocPage { - kind: "doc"; - home: string; - relpath: string; - name: string; - title: string; - tags: string[]; - backlinks: string[]; - markdown: string; +/// Dokument-/Ordner-Operationen — laufen als Tauri-Commands, der neue Stand +/// kommt über den Wiki-Puffer zurück. +export interface WikiActions { + rename(relpath: string, name: string): void; + remove(relpath: string): void; + moveFolder(folder: string, to: string): void; + createFolder(folder: string): void; + createDoc(folder: string, name: string): void; +} + +export interface WikiCallbacks { + /// Dokument in den Dokument-Tab laden (absoluter Pfad, wie Suchtreffer). + openDoc(path: string): void; + /// Wiki-Ziel laden (`tag:` = Übersicht in den Puffer, Einstiegs-Chip). + openWiki(name: string): void; + actions: WikiActions; } export interface WikiView { @@ -50,54 +57,160 @@ export interface WikiView { empty(): boolean; } -export function initWikiView( - container: HTMLElement, - onLink: (name: string) => void, -): WikiView { - function chip(label: string, target: string, active = false): HTMLElement { - const b = document.createElement("button"); - b.className = "wiki-chip" + (active ? " active" : ""); - b.textContent = label; - b.addEventListener("click", (e) => { - e.stopPropagation(); - onLink(target); - }); - return b; - } +/// Verschachtelter Ordner-Knoten, aus den Ordnerpfaden gebaut. +interface TreeNode { + children: Map; +} - /// Kopfzeile einer Seite: Titel + Home-Pfad links, Dokumentzahl rechts. - function pageHead(p: Page): HTMLElement { - const head = document.createElement("div"); - head.className = "wiki-head"; - const left = document.createElement("div"); - left.className = "wiki-head-left"; - const title = document.createElement("div"); - title.className = "wiki-head-title"; - title.textContent = p.tag ? `#${p.tag}` : t("wiki.archive"); - const sub = document.createElement("div"); - sub.className = "wiki-head-sub"; - sub.textContent = p.home; - left.append(title, sub); - const count = document.createElement("div"); - count.className = "wiki-head-right"; - count.textContent = t(p.total === 1 ? "wiki.docOne" : "wiki.docMany", { count: p.total }); - head.append(left, count); - return head; - } - - /// Schlagwort-Leiste: „Alle“ plus ein Chip pro Schlagwort mit Zähler; der - /// aktive Filter ist markiert. - function tagBar(p: Page): HTMLElement { - const bar = document.createElement("div"); - bar.className = "wiki-chips"; - bar.append(chip(t("wiki.all"), "tag:", !p.tag)); - for (const t of p.tags) { - bar.append(chip(`#${t.name} ${t.count}`, `tag:${t.name}`, p.tag === t.name)); +function buildTree(folders: Folder[]): TreeNode { + const root: TreeNode = { children: new Map() }; + for (const folder of folders) { + if (!folder.name) continue; + let node = root; + for (const part of folder.name.split("/")) { + if (!node.children.has(part)) { + node.children.set(part, { children: new Map() }); + } + node = node.children.get(part)!; } - return bar; + } + return root; +} + +export function initWikiView(container: HTMLElement, cb: WikiCallbacks): WikiView { + /// Zugeklappte Ordner und gewählter Ordner ("" = Wurzel) — überleben + /// Puffer-Updates innerhalb der Ansicht. + const closed = new Set(); + let selected = ""; + let current: Page | null = null; + + // ---------- Baum-Spalte (nur Ordner) ---------- + + function folderRow(name: string, full: string, node: TreeNode): HTMLElement { + const det = document.createElement("details"); + det.className = "wiki-tree-folder"; + det.open = !closed.has(full); + det.addEventListener("toggle", () => { + if (det.open) closed.delete(full); + else closed.add(full); + }); + const sum = document.createElement("summary"); + if (full === selected) sum.classList.add("active"); + const label = document.createElement("span"); + label.className = "wiki-tree-name"; + label.textContent = name; + // Name wählt den Ordner aus (ohne zu klappen); der Pfeil davor klappt. + label.addEventListener("click", (e) => { + e.preventDefault(); + select(full); + }); + const rename = editAction(t("wiki.folderRename"), () => { + const input = document.createElement("input"); + input.className = "wiki-tree-input"; + input.value = full; + sum.replaceChildren(input); + input.focus(); + input.select(); + input.addEventListener("keydown", (e) => { + e.stopPropagation(); + if (e.key === "Enter") { + cb.actions.moveFolder(full, input.value.trim()); + } else if (e.key === "Escape") { + sum.replaceChildren(label, rename); + } + }); + input.addEventListener("click", (e) => e.preventDefault()); + }); + rename.addEventListener("click", (e) => { + e.preventDefault(); + e.stopPropagation(); + }); + sum.append(label, rename); + det.append(sum, renderChildren(node, full)); + return det; } - function docRow(doc: DocEntry): HTMLElement { + function renderChildren(node: TreeNode, path: string): HTMLElement { + const box = document.createElement("div"); + box.className = "wiki-tree-children"; + for (const [name, child] of [...node.children].sort((a, b) => + a[0].localeCompare(b[0]), + )) { + box.append(folderRow(name, path ? `${path}/${name}` : name, child)); + } + return box; + } + + function renderTree(p: Page): HTMLElement { + const aside = document.createElement("aside"); + aside.className = "wiki-tree"; + const head = document.createElement("div"); + head.className = "wiki-tree-head"; + const root = document.createElement("button"); + root.className = "wiki-tree-root" + (selected === "" ? " active" : ""); + root.textContent = t("wiki.archive"); + root.addEventListener("click", () => select("")); + const add = document.createElement("button"); + add.className = "wiki-add"; + add.title = t("wiki.newFolder"); + add.textContent = "+"; + add.addEventListener("click", () => + openForm(t("wiki.newFolder"), t("wiki.folderPath"), selected ? `${selected}/` : "", (v) => + cb.actions.createFolder(v), + ), + ); + head.append(root, add); + aside.append(head, renderChildren(buildTree(p.folders), "")); + return aside; + } + + // ---------- Anlege-Formular (Ordner/Dokument) ---------- + + /// Kleines Formular oben in der Liste: ein Textfeld, Anlegen/Abbrechen; + /// Enter legt an, Escape schließt. Leere oder ungültige Eingaben meldet + /// das Backend als Toast. + function openForm( + title: string, + placeholder: string, + initial: string, + onSubmit: (value: string) => void, + ) { + const main = container.querySelector(".wiki-main")!; + main.querySelector(".wiki-form")?.remove(); + const form = document.createElement("div"); + form.className = "wiki-form"; + const caption = document.createElement("div"); + caption.className = "wiki-form-title"; + caption.textContent = title; + const input = document.createElement("input"); + input.className = "wiki-tree-input"; + input.placeholder = placeholder; + input.value = initial; + const submit = document.createElement("button"); + submit.className = "wiki-form-submit"; + submit.textContent = t("wiki.create"); + const cancel = document.createElement("button"); + cancel.className = "wiki-form-cancel"; + cancel.textContent = t("wiki.cancel"); + const fire = () => { + const value = input.value.trim(); + form.remove(); + onSubmit(value); + }; + submit.addEventListener("click", fire); + cancel.addEventListener("click", () => form.remove()); + form.addEventListener("keydown", (e) => { + if (e.key === "Enter") fire(); + else if (e.key === "Escape") form.remove(); + }); + form.append(caption, input, submit, cancel); + main.querySelector(".wiki-head")!.after(form); + input.focus(); + } + + // ---------- Liste rechts: direkter Ordnerinhalt ---------- + + function docRow(p: Page, doc: DocEntry): HTMLElement { const row = document.createElement("div"); row.className = "wiki-doc"; const line = document.createElement("div"); @@ -119,6 +232,29 @@ export function initWikiView( date.textContent = doc.date; line.append(date); } + const acts = document.createElement("div"); + acts.className = "wiki-doc-actions"; + const rename = editAction(t("wiki.renameDoc"), () => { + const input = document.createElement("input"); + input.className = "wiki-tree-input"; + input.value = doc.name; + row.replaceChildren(input); + input.focus(); + input.select(); + input.addEventListener("keydown", (e) => { + if (e.key === "Enter") { + cb.actions.rename(doc.relpath, input.value); + } else if (e.key === "Escape" && current) { + renderMain(current); + } + }); + input.addEventListener("click", (e) => e.stopPropagation()); + }); + acts.append(rename, deleteAction(t("wiki.deleteDoc"), () => cb.actions.remove(doc.relpath))); + for (const btn of acts.querySelectorAll("button")) { + btn.addEventListener("click", (e) => e.stopPropagation()); + } + line.append(acts); row.append(line); if (doc.description) { const desc = document.createElement("div"); @@ -126,95 +262,70 @@ export function initWikiView( desc.textContent = doc.description; row.append(desc); } - if (doc.tags.length) { - const tags = document.createElement("div"); - tags.className = "wiki-doc-tags"; - for (const t of doc.tags) tags.append(chip(`#${t}`, `tag:${t}`)); - row.append(tags); - } - row.addEventListener("click", () => onLink(doc.name)); + row.addEventListener("click", () => cb.openDoc(`${p.home}/${doc.relpath}`)); return row; } - function renderPage(p: Page) { - container.append(pageHead(p)); - if (p.tags.length) container.append(tagBar(p)); - if (p.total === 0) { + function renderMain(p: Page) { + const main = container.querySelector(".wiki-main")!; + main.textContent = ""; + const docs = p.folders.find((f) => f.name === selected)?.docs ?? []; + + const head = document.createElement("div"); + head.className = "wiki-head"; + const left = document.createElement("div"); + left.className = "wiki-head-left"; + const title = document.createElement("div"); + title.className = "wiki-head-title"; + title.textContent = selected ? selected.split("/").pop()! : t("wiki.archive"); + const sub = document.createElement("div"); + sub.className = "wiki-head-sub"; + sub.textContent = selected ? `${p.home}/${selected}` : p.home; + left.append(title, sub); + const count = document.createElement("div"); + count.className = "wiki-head-right"; + count.textContent = t(docs.length === 1 ? "wiki.docOne" : "wiki.docMany", { + count: docs.length, + }); + const add = document.createElement("button"); + add.className = "wiki-add"; + add.title = t("wiki.newDoc"); + add.textContent = "+"; + add.addEventListener("click", () => + openForm(t("wiki.newDoc"), t("wiki.docName"), "", (v) => + cb.actions.createDoc(selected, v), + ), + ); + head.append(left, count, add); + main.append(head); + + if (docs.length === 0) { const empty = document.createElement("div"); empty.className = "wiki-empty"; const line = document.createElement("strong"); - line.textContent = p.tag ? t("wiki.emptyTag", { tag: p.tag }) : t("wiki.emptyArchive"); + line.textContent = p.total === 0 ? t("wiki.emptyArchive") : t("wiki.emptyFolder"); empty.append(line); - if (!p.tag) { - empty.append(t("wiki.emptyHint")); - } - container.append(empty); + if (p.total === 0) empty.append(t("wiki.emptyHint")); + main.append(empty); return; } - if (p.recent.length) { - const eyebrow = document.createElement("div"); - eyebrow.className = "wiki-folder"; - eyebrow.textContent = t("wiki.recent"); - container.append(eyebrow); - for (const doc of p.recent) container.append(docRow(doc)); - } - for (const folder of p.folders) { - if (folder.name) { - const eyebrow = document.createElement("div"); - eyebrow.className = "wiki-folder"; - eyebrow.textContent = `${folder.name}/`; - container.append(eyebrow); - } else if (p.recent.length) { - const eyebrow = document.createElement("div"); - eyebrow.className = "wiki-folder"; - eyebrow.textContent = t("wiki.root"); - container.append(eyebrow); - } - for (const doc of folder.docs) container.append(docRow(doc)); - } + for (const doc of docs) main.append(docRow(p, doc)); } - function renderDoc(d: DocPage) { - const head = document.createElement("div"); - head.className = "wiki-doc-head"; - const back = document.createElement("button"); - back.className = "wiki-back"; - back.textContent = t("wiki.back"); - back.addEventListener("click", () => onLink("tag:")); - const path = document.createElement("div"); - path.className = "wiki-head-sub"; - path.textContent = d.relpath; - head.append(back, path); - container.append(head); - if (d.tags.length) { - const tags = document.createElement("div"); - tags.className = "wiki-chips"; - for (const t of d.tags) tags.append(chip(`#${t}`, `tag:${t}`)); - container.append(tags); - } - const body = document.createElement("div"); - body.className = "wiki-body"; - body.innerHTML = renderMarkdown(d.markdown); - linkWikiRefs(body, onLink); - container.append(body); - if (d.backlinks.length) { - const back = document.createElement("div"); - back.className = "wiki-backlinks"; - back.append(t("wiki.backlinksLabel")); - d.backlinks.forEach((name, i) => { - if (i) back.append(" · "); - const a = document.createElement("a"); - a.href = "#"; - a.className = "wiki"; - a.textContent = name; - a.addEventListener("click", (e) => { - e.preventDefault(); - onLink(name); - }); - back.append(a); - }); - container.append(back); - } + function select(folder: string) { + selected = folder; + if (current) render(current); + } + + function render(p: Page) { + container.textContent = ""; + const layout = document.createElement("div"); + layout.className = "wiki-layout"; + const main = document.createElement("div"); + main.className = "wiki-main"; + layout.append(renderTree(p), main); + container.append(layout); + renderMain(p); } /// Leerer Puffer: Einstieg statt leerer Fläche. @@ -224,7 +335,11 @@ export function initWikiView( const line = document.createElement("strong"); line.textContent = t("wiki.noPage"); empty.append(line); - empty.append(chip(t("wiki.openOverview"), "tag:")); + const open = document.createElement("button"); + open.className = "wiki-chip"; + open.textContent = t("wiki.openOverview"); + open.addEventListener("click", () => cb.openWiki("tag:")); + empty.append(open); container.append(empty); } @@ -234,12 +349,21 @@ export function initWikiView( container.textContent = ""; loaded = !!text.trim(); if (!loaded) { + current = null; renderIntro(); return; } - const data: Page | DocPage = JSON.parse(text); - if (data.kind === "doc") renderDoc(data); - else renderPage(data); + current = JSON.parse(text); + // Der gewählte Ordner kann nach Umbenennen/Löschen weg sein — dann + // zurück zur Wurzel. Zwischenordner (nur Unterordner, keine eigenen + // Dokumente) bleiben wählbar. + const exists = current!.folders.some( + (f) => f.name === selected || f.name.startsWith(`${selected}/`), + ); + if (selected && !exists) { + selected = ""; + } + render(current!); }, empty: () => !loaded, };