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
+6
View File
@@ -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",
])),
)
+6
View File
@@ -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",
+6
View File
@@ -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",
@@ -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"]
@@ -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"]
@@ -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"]
@@ -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"]
@@ -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"]
@@ -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"]
+6
View File
@@ -311,6 +311,12 @@ fn invoke_handlers() -> impl Fn(tauri::ipc::Invoke<tauri::Wry>) -> 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
]
}
+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.
+10 -2
View File
@@ -324,10 +324,18 @@ fn env_path(var: &str) -> Result<String, Value> {
.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 (`<panel>.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 {
+174 -25
View File
@@ -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::path::PathBuf> {
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<String>) -> 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<String>,
) -> 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));
+1 -1
View File
@@ -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", () => {
+20 -12
View File
@@ -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",
},
};
+23 -4
View File
@@ -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<string, string>) =>
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 = {
+185 -126
View File
@@ -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);
+3 -2
View File
@@ -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<string>("panel-update", (e) => {
+7 -3
View File
@@ -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");
}
+4 -1
View File
@@ -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();
+181 -82
View File
@@ -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 = `<div id="w"></div>`;
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<HTMLElement>(".wiki-doc");
rows[1].click(); // Wurzel-Dokument (nach der Zuletzt-Zeile)
expect(onLink).toHaveBeenCalledWith("wurzel-doc");
rows[1].querySelector<HTMLElement>(".wiki-doc-tags .wiki-chip")!.click();
expect(onLink).toHaveBeenCalledWith("tag:adr");
document.querySelector<HTMLElement>(".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<HTMLElement>(".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<HTMLElement>(".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<HTMLElement>(".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<HTMLElement>(".wiki-doc")!;
row.querySelector<HTMLElement>(".cmd-edit")!.click();
const input = row.querySelector<HTMLInputElement>(".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<HTMLElement>(".wiki-doc .cmd-edit")!.click();
const input = document.querySelector<HTMLInputElement>(".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<HTMLElement>(".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<HTMLElement>(".wiki-tree .wiki-tree-name")[0].click();
document.querySelector<HTMLElement>(".wiki-head .wiki-add")!.click();
const form = document.querySelector<HTMLElement>(".wiki-form")!;
expect(form.querySelector(".wiki-form-title")!.textContent).toBe("Neues Dokument");
const input = form.querySelector<HTMLInputElement>("input")!;
input.value = "Deploy Notiz";
form.querySelector<HTMLElement>(".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<HTMLElement>(".wiki-head .wiki-add")!.click();
document.querySelector<HTMLElement>(".wiki-form-cancel")!.click();
expect(document.querySelector(".wiki-form")).toBeNull();
document.querySelector<HTMLElement>(".wiki-head .wiki-add")!.click();
document
.querySelector<HTMLElement>(".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<HTMLElement>(".wiki-tree .wiki-tree-name")[0].click();
document.querySelector<HTMLElement>(".wiki-tree-head .wiki-add")!.click();
const input = document.querySelector<HTMLInputElement>(".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<HTMLElement>(".wiki-tree summary")!;
summary.querySelector<HTMLElement>(".cmd-edit")!.click();
const input = summary.querySelector<HTMLInputElement>(".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<HTMLElement>(".wiki-body a.wiki")!;
expect(wiki.textContent).toBe("Label");
wiki.click();
expect(onLink).toHaveBeenCalledWith("ziel");
document.querySelector<HTMLElement>(".wiki-back")!.click();
expect(onLink).toHaveBeenCalledWith("tag:");
const back = document.querySelector<HTMLElement>(".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<HTMLElement>(".wiki-empty .wiki-chip")!.click();
expect(onLink).toHaveBeenCalledWith("tag:");
expect(openWiki).toHaveBeenCalledWith("tag:");
view.set(page);
expect(view.empty()).toBe(false);
});
+268 -144
View File
@@ -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<string, TreeNode>;
}
/// 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<string>();
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<HTMLElement>(".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<HTMLElement>(".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,
};