Technische ID als Identität der Notizen; Pfad wird zur Eigenschaft

Bisher war der Dateipfad die Identität einer Notiz: Umbenennen brach
Verweise, und der Pfad tauchte in der Oberfläche auf (Tooltips, Menüpunkte
"Dateiname ändern"/"Ordnerpfad ändern"). Jede Notiz trägt jetzt eine
technische ID im Frontmatter (id: <uuid>), die nie wechselt; der
Systempfad ist nur noch eine Eigenschaft.

- ensure_ids ergänzt fehlende IDs beim Seitenaufbau (wie ensure_node_texts);
  frontmatter() schreibt sie bei jedem Anlegen als erste Zeile.
- Doc, WikiDocEntry, FolderNode und Suchtreffer führen die ID mit;
  resolve_id löst sie auf den aktuellen relpath auf.
- Commands sprechen IDs: archive_read/write/set_title/delete nehmen `id`,
  archive_create_doc/create_folder die ID des Elternknotens plus Namen,
  panel_archive_cmd den Zielknoten als ID.
- Frontend adressiert durchgehend über IDs: Auswahl, Klapp-Zustand,
  Verlauf des Zurück-Knopfs, Wikilink-Sprünge, Suchtreffer-Vormerkung und
  der Zielordner-Baum des Archiv-Dialogs.
- Der Pfad ist aus der Oberfläche verschwunden: die Menüpunkte zum
  Umbenennen von Datei und Ordner samt Commands (archive_rename,
  archive_move, archive_move_folder) sind entfallen — es gibt nur noch den
  Titel, Name und Titel können nicht mehr auseinanderlaufen.

Offen dadurch: Verschieben im Baum fehlt (ToDo: Drag & Drop).

Archiv-Dialog nebenbei überarbeitet: modales Popup im Design der
Archiv-Ansicht mit Abbrechen und zuverlässigem Escape, Zielordner über
einen einklappbaren Baum statt Pfadeingabe, zusätzlicher Knopf "Auf Platte
legen" (panel_save_as); Archivieren öffnet keinen Dateimanager mehr.
This commit is contained in:
marcus hinz
2026-07-26 17:53:19 +02:00
parent 9de58d43e8
commit 2251a1d45f
26 changed files with 984 additions and 403 deletions
+2 -3
View File
@@ -66,13 +66,12 @@ fn main() {
"search_run",
"panel_load",
"wiki_open",
"archive_move",
"archive_rename",
"archive_read",
"archive_write",
"archive_set_title",
"archive_folders",
"panel_save_as",
"archive_delete",
"archive_move_folder",
"archive_create_folder",
"archive_create_doc",
"open_panel_window",
+2 -3
View File
@@ -24,12 +24,11 @@
"allow-archive-create-doc",
"allow-archive-create-folder",
"allow-archive-delete",
"allow-archive-move",
"allow-archive-move-folder",
"allow-archive-rename",
"allow-archive-read",
"allow-archive-write",
"allow-archive-set-title",
"allow-archive-folders",
"allow-panel-save-as",
"allow-buffer-read",
"allow-commands-delete",
"allow-enabled-modules",
+2 -3
View File
@@ -23,12 +23,11 @@
"allow-archive-create-doc",
"allow-archive-create-folder",
"allow-archive-delete",
"allow-archive-move",
"allow-archive-move-folder",
"allow-archive-rename",
"allow-archive-read",
"allow-archive-write",
"allow-archive-set-title",
"allow-archive-folders",
"allow-panel-save-as",
"allow-buffer-read",
"allow-commands-delete",
"allow-enabled-modules",
@@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-archive-folders"
description = "Enables the archive_folders command without any pre-configured scope."
commands.allow = ["archive_folders"]
[[permission]]
identifier = "deny-archive-folders"
description = "Denies the archive_folders command without any pre-configured scope."
commands.deny = ["archive_folders"]
@@ -1,11 +0,0 @@
# 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"]
@@ -1,11 +0,0 @@
# 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"]
@@ -1,11 +0,0 @@
# 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"]
@@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-panel-save-as"
description = "Enables the panel_save_as command without any pre-configured scope."
commands.allow = ["panel_save_as"]
[[permission]]
identifier = "deny-panel-save-as"
description = "Denies the panel_save_as command without any pre-configured scope."
commands.deny = ["panel_save_as"]
+2 -3
View File
@@ -311,13 +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_read,
terminal::archive_write,
terminal::archive_set_title,
terminal::archive_folders,
terminal::panel_save_as,
terminal::archive_delete,
terminal::archive_move_folder,
terminal::archive_create_folder,
terminal::archive_create_doc,
terminal::open_panel_window
+18 -1
View File
@@ -436,8 +436,25 @@ pub(crate) fn panel_archive_cmd(
description: Option<String>,
tags: Option<Vec<String>>,
) -> Result<String, String> {
// Der Zielordner kommt als Knoten-ID; erst hier wird daraus ein Pfad.
let folder = match folder.filter(|f| !f.trim().is_empty()) {
Some(id) => {
let home = require_archive_home(&project)?;
let rel = crate::domain::archive_index::resolve_id(&home, &id)?;
let stem = std::path::Path::new(&rel)
.file_stem()
.unwrap_or_default()
.to_string_lossy()
.to_string();
Some(match rel.rsplit_once('/') {
Some((head, _)) => format!("{head}/{stem}"),
None => stem,
})
}
None => None,
};
let meta = ArchiveMeta {
folder: folder.filter(|f| !f.trim().is_empty()),
folder,
description: description.filter(|d| !d.trim().is_empty()),
tags: tags.unwrap_or_default(),
};
+5 -2
View File
@@ -282,7 +282,8 @@ pub(crate) fn frontmatter(
meta: &ArchiveMeta,
) -> String {
let mut fm = format!(
"---\ntitle: \"{}\"\nproject: {project}\ncreated: {iso}\nsource: ai-control\n",
"---\nid: {}\ntitle: \"{}\"\nproject: {project}\ncreated: {iso}\nsource: ai-control\n",
uuid::Uuid::new_v4(),
title.replace('"', "'"),
);
if let Some(d) = &meta.description {
@@ -542,7 +543,9 @@ mod tests {
fn frontmatter_mit_und_ohne_meta() {
let leer = ArchiveMeta::default();
let fm = frontmatter("Titel", "proj", "2026-07-19T10:00:00Z", &leer);
assert!(fm.starts_with("---\ntitle: \"Titel\"\n"));
// Erste Zeile ist die technische ID, dann der Titel.
assert!(fm.starts_with("---\nid: "));
assert!(fm.contains("\ntitle: \"Titel\"\n"));
assert!(!fm.contains("description:"));
assert!(!fm.contains("tags:"));
+57 -2
View File
@@ -12,7 +12,10 @@ use crate::domain::archive::{parse_frontmatter, parse_tag_list, slugify, strip_s
#[derive(serde::Serialize, Clone)]
pub(crate) struct Doc {
/// Pfad relativ zum Archiv-Home.
/// Technische ID aus dem Frontmatter — bleibt über Umbenennen und
/// Verschieben hinweg gleich; alle Verweise laufen darüber.
pub(crate) id: String,
/// Pfad relativ zum Archiv-Home (Eigenschaft, keine Identität).
pub(crate) relpath: String,
/// Wikilink-Name: Datei-Stem ohne führenden Zeitstempel.
pub(crate) name: String,
@@ -113,6 +116,7 @@ fn read_doc(home: &Path, path: &Path) -> Result<(Doc, String), String> {
.display()
.to_string();
let doc = Doc {
id: fm.get("id").cloned().unwrap_or_default(),
relpath,
title: fm.get("title").unwrap_or(&name).clone(),
description: fm.get("description").cloned(),
@@ -164,6 +168,8 @@ pub(crate) struct WikiFolder {
#[derive(serde::Serialize)]
pub(crate) struct WikiDocEntry {
/// Technische ID — Adressat aller Aktionen.
pub(crate) id: String,
/// Pfad relativ zum Archiv-Home — Sprung ins Dokument und Adressat der
/// Zeilen-Aktionen (umbenennen, löschen).
pub(crate) relpath: String,
@@ -230,9 +236,48 @@ pub(crate) fn archive_page(home: &Path, tag: Option<&str>) -> Result<WikiPage, S
})
}
/// Ordner-Knoten für den Zielordner-Baum des Archiv-Dialogs: Pfad plus
/// Anzeige-Titel aus dem Knotentext (`<name>.md`/`.html` daneben) — dieselbe
/// logische Sicht wie die Archiv-Ansicht.
#[derive(serde::Serialize)]
pub(crate) struct FolderNode {
/// Technische ID des Knotentexts — Adressat der Auswahl.
pub(crate) id: String,
/// Pfad relativ zum Archiv-Home (nur Eigenschaft).
pub(crate) path: String,
/// Titel des Knotentexts, sonst der Ordnername.
pub(crate) title: String,
}
pub(crate) fn folder_nodes(home: &Path) -> Result<Vec<FolderNode>, String> {
let mut paths = Vec::new();
folder_paths(home, home, &mut paths)?;
paths.sort();
let mut out = Vec::new();
for path in paths {
let name = Path::new(&path)
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
// Knotentext daneben: erst .md, dann .html.
let fm = ["md", "html"]
.iter()
.map(|ext| home.join(&path).with_extension(ext))
.find(|p| p.is_file())
.and_then(|p| fs::read_to_string(p).ok())
.map(|text| parse_frontmatter(&text))
.unwrap_or_default();
let title = fm.get("title").cloned().unwrap_or_else(|| name.clone());
let id = fm.get("id").cloned().unwrap_or_default();
out.push(FolderNode { id, path, title });
}
Ok(out)
}
/// 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> {
pub(crate) 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()))?;
@@ -264,6 +309,7 @@ fn doc_entry(doc: &Doc) -> WikiDocEntry {
.map(|c| c[..10].to_string())
});
WikiDocEntry {
id: doc.id.clone(),
relpath: doc.relpath.clone(),
name: doc.name.clone(),
title: doc.title.clone(),
@@ -275,6 +321,15 @@ fn doc_entry(doc: &Doc) -> WikiDocEntry {
}
}
/// Löst eine technische ID auf den aktuellen relpath auf.
pub(crate) fn resolve_id(home: &Path, id: &str) -> Result<String, String> {
scan_archive(home)?
.into_iter()
.find(|d| d.id == id)
.map(|d| d.relpath)
.ok_or_else(|| format!("keine Notiz mit ID {id}"))
}
/// 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> {
+47 -44
View File
@@ -49,24 +49,6 @@ fn fresh_target(target: &Path, home: &Path) -> Result<(), String> {
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.
@@ -194,6 +176,49 @@ pub(crate) fn move_folder(home: &Path, folder: &str, to: &str) -> Result<(), Str
Ok(())
}
/// Zweite Invariante des Notizmodells: JEDES Dokument trägt eine technische
/// ID im Frontmatter (`id:`). Sie entsteht einmal und bleibt — Titel,
/// Dateiname und Ordner dürfen sich danach beliebig ändern, Verweise laufen
/// über die ID. Dateien ohne ID (von Hand angelegt, Altbestand) bekommen hier
/// eine.
pub(crate) fn ensure_ids(home: &Path) -> Result<(), String> {
for path in md_files(home)? {
let text = fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?;
if crate::domain::archive::parse_frontmatter(&text).contains_key("id") {
continue;
}
let line = format!("id: {}", uuid::Uuid::new_v4());
let out = match text.strip_prefix("---\n") {
// Frontmatter vorhanden: ID als erste Zeile einfügen.
Some(rest) => format!("---\n{line}\n{rest}"),
// Ohne Frontmatter: einen Block davor setzen, Rumpf bleibt.
None => format!("---\n{line}\n---\n\n{text}"),
};
fs::write(&path, out).map_err(|e| format!("{}: {e}", path.display()))?;
}
Ok(())
}
/// Alle Markdown-Dateien unterhalb von `dir`, rekursiv; versteckte Einträge
/// bleiben außen vor.
fn md_files(dir: &Path) -> Result<Vec<PathBuf>, String> {
let mut out = Vec::new();
for entry in fs::read_dir(dir).map_err(|e| format!("{}: {e}", dir.display()))? {
let entry = entry.map_err(|e| format!("{}: {e}", dir.display()))?;
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with('.') {
continue;
}
let path = entry.path();
if path.is_dir() {
out.extend(md_files(&path)?);
} else if name.ends_with(".md") {
out.push(path);
}
}
Ok(out)
}
/// Invariante des Notizmodells: JEDER Knoten besitzt eine Textdatei — der
/// Ordnername ist nur technische Verwaltung, Titel und Inhalt stehen im
/// gleichnamigen Dokument daneben (Wurzel: `index.md`). Läuft vor jedem
@@ -260,35 +285,13 @@ mod tests {
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, "../2026-07-19_1000-adr-logging.md").is_err());
assert!(delete_doc(&home, "/etc/passwd.md").is_err());
assert!(delete_doc(&home, "konzepte").is_err()); // kein .md
assert!(move_folder(&home, "konzepte", "../raus").is_err());
}
@@ -316,7 +319,7 @@ mod tests {
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("\ntitle: \"Deploy Nötiz!\"\n"));
assert!(text.contains("created: "));
// In der Wurzel, gleicher Name kollidiert laut, leerer Name bricht ab.
@@ -383,7 +386,7 @@ mod tests {
assert!(home.join("leer.md").is_file());
assert!(home.join("leer/unter.md").is_file());
let text = fs::read_to_string(home.join("leer.md")).unwrap();
assert!(text.starts_with("---\ntitle: \"leer\"\n"));
assert!(text.contains("\ntitle: \"leer\"\n"));
// Gestempelte Zwillinge zählen als Knotentext — kein Duplikat: konzepte/
// hat keins, bekommt eines; ein zweiter Lauf legt nichts Neues an.
assert!(home.join("konzepte.md").is_file());
+13 -5
View File
@@ -12,7 +12,9 @@ use crate::domain::archive_index::scan_with_bodies;
#[derive(serde::Serialize)]
pub(crate) struct Hit {
/// Pfad relativ zum Archiv-Home.
/// Technische ID der Notiz — Adressat des Treffer-Sprungs.
pub(crate) id: String,
/// Pfad relativ zum Archiv-Home (Anzeige).
pub(crate) relpath: String,
pub(crate) title: String,
/// Textausschnitt um die Fundstelle, Treffer in `**…**`.
@@ -45,13 +47,18 @@ pub(crate) fn search(
let conn = build_index(home)?;
let mut stmt = conn
.prepare(
"SELECT relpath, title, snippet(docs, 5, '**', '**', ' … ', 12) \
"SELECT id, relpath, title, snippet(docs, 6, '**', '**', ' … ', 12) \
FROM docs WHERE docs MATCH ?1 ORDER BY rank LIMIT ?2",
)
.map_err(|e| e.to_string())?;
let rows = stmt
.query_map(rusqlite::params![expr, limit as i64], |row| {
Ok(Hit { relpath: row.get(0)?, title: row.get(1)?, snippet: row.get(2)? })
Ok(Hit {
id: row.get(0)?,
relpath: row.get(1)?,
title: row.get(2)?,
snippet: row.get(3)?,
})
})
.map_err(|e| format!("Suchausdruck „{query}“: {e}"))?;
rows.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())
@@ -103,16 +110,17 @@ fn build_index(home: &Path) -> Result<Connection, String> {
let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
conn
.execute_batch(
"CREATE VIRTUAL TABLE docs USING fts5(relpath UNINDEXED, name, title, description, tags, body)",
"CREATE VIRTUAL TABLE docs USING fts5(id UNINDEXED, relpath UNINDEXED, name, title, description, tags, body)",
)
.map_err(|e| e.to_string())?;
let docs = scan_with_bodies(home)?;
let mut insert = conn
.prepare("INSERT INTO docs (relpath, name, title, description, tags, body) VALUES (?1, ?2, ?3, ?4, ?5, ?6)")
.prepare("INSERT INTO docs (id, relpath, name, title, description, tags, body) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)")
.map_err(|e| e.to_string())?;
for (doc, body) in &docs {
insert
.execute(rusqlite::params![
doc.id,
doc.relpath,
doc.name,
doc.title,
+86 -32
View File
@@ -527,6 +527,7 @@ pub fn wiki_open(project: String, name: String) -> Result<(), String> {
Some(tag) => {
let display = crate::domain::project::display_name_in(&Paths::real(), &project)?;
crate::domain::archive_ops::ensure_node_texts(&home, &display)?;
crate::domain::archive_ops::ensure_ids(&home)?;
let json = serde_json::to_string(&crate::domain::archive_index::archive_page(
&home,
(!tag.is_empty()).then_some(tag),
@@ -549,8 +550,9 @@ pub fn wiki_open(project: String, name: String) -> Result<(), String> {
/// Liest den Body eines Archiv-Dokuments (ohne Frontmatter) — Inhalt der
/// Notiz-Ansicht im Archiv-Tab.
#[tauri::command]
pub fn archive_read(project: String, relpath: String) -> Result<String, String> {
pub fn archive_read(project: String, id: String) -> Result<String, String> {
let home = crate::domain::archive::require_archive_home(&project)?;
let relpath = crate::domain::archive_index::resolve_id(&home, &id)?;
let path = crate::domain::archive_ops::doc_path(&home, &relpath)?;
let text = std::fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?;
Ok(crate::domain::archive::strip_frontmatter(&text).to_string())
@@ -559,19 +561,60 @@ pub fn archive_read(project: String, relpath: String) -> Result<String, String>
/// Schreibt den Body einer Archiv-Notiz zurück (Bearbeiten im
/// Archiv-Fenster); die Frontmatter der Datei bleibt.
#[tauri::command]
pub fn archive_write(project: String, relpath: String, text: String) -> Result<(), String> {
pub fn archive_write(project: String, id: String, text: String) -> Result<(), String> {
let home = crate::domain::archive::require_archive_home(&project)?;
let relpath = crate::domain::archive_index::resolve_id(&home, &id)?;
let path = crate::domain::archive_ops::doc_path(&home, &relpath)?;
crate::domain::archive_ops::write_body(&path, &text)
}
/// Ordner-Knoten des Archivs (Pfad + Titel, sortiert) — Zielordner-Baum des
/// Archiv-Dialogs.
#[tauri::command]
pub fn archive_folders(
project: String,
) -> Result<Vec<crate::domain::archive_index::FolderNode>, String> {
let home = crate::domain::archive::require_archive_home(&project)?;
crate::domain::archive_index::folder_nodes(&home)
}
/// Legt den Entwurf als Datei an einem frei gewählten Pfad ab (Speichern-
/// Dialog) — ohne Archiv, ohne Frontmatter: der Panel-Inhalt, wie er ist.
#[tauri::command]
pub fn panel_save_as(project: String, path: String) -> Result<(), String> {
let text = std::fs::read_to_string(panel_file(&project)).map_err(|e| e.to_string())?;
std::fs::write(&path, text).map_err(|e| format!("{path}: {e}"))
}
/// Setzt den Anzeige-Titel einer Notiz (Klick auf den Titel im Archiv);
/// danach die frische Übersicht, damit Baum und Karten den neuen Titel zeigen.
#[tauri::command]
pub fn archive_set_title(project: String, relpath: String, title: String) -> Result<(), String> {
pub fn archive_set_title(project: String, id: String, title: String) -> Result<(), String> {
let home = crate::domain::archive::require_archive_home(&project)?;
let relpath = crate::domain::archive_index::resolve_id(&home, &id)?;
let path = crate::domain::archive_ops::doc_path(&home, &relpath)?;
crate::domain::archive_ops::set_title(&path, &title)?;
// Der technische Name folgt dem Titel — beide dürfen nicht auseinander
// laufen. Bei einem Knotentext wandert der gleichnamige Ordner mit; die
// Archiv-Wurzel (index.md) behält ihren Namen, sie ist die Konvention.
let stem = path.file_stem().unwrap_or_default().to_string_lossy().to_string();
let name = crate::domain::archive::strip_stamp(&stem).to_string();
let slug = crate::domain::archive::slugify(&title);
if name != slug && name != "index" {
let dir = path.with_file_name(&name);
if dir.is_dir() {
let old_rel = dir.strip_prefix(&home).unwrap().display().to_string();
let new_rel = match old_rel.rsplit_once('/') {
Some((parent, _)) => format!("{parent}/{slug}"),
None => slug.clone(),
};
crate::domain::archive_ops::move_folder(&home, &old_rel, &new_rel)?;
relink_folder(&project, &home.join(&old_rel), &home.join(&new_rel))?;
} else {
let new_rel = crate::domain::archive_ops::rename_doc(&home, &relpath, &title)?;
relink(&project, &path, Some(&home.join(new_rel)))?;
}
}
wiki_refresh_page(&project, &home)
}
@@ -580,57 +623,66 @@ pub fn archive_set_title(project: String, relpath: String, title: String) -> Res
fn wiki_refresh_page(project: &str, home: &std::path::Path) -> Result<(), String> {
let display = crate::domain::project::display_name_in(&Paths::real(), project)?;
crate::domain::archive_ops::ensure_node_texts(home, &display)?;
crate::domain::archive_ops::ensure_ids(home)?;
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> {
pub fn archive_delete(project: String, id: String) -> Result<(), String> {
let home = crate::domain::archive::require_archive_home(&project)?;
let relpath = crate::domain::archive_index::resolve_id(&home, &id)?;
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> {
pub fn archive_create_folder(project: String, parent: String, name: String) -> Result<(), String> {
let home = crate::domain::archive::require_archive_home(&project)?;
let folder = join_under(&home, &parent, &name)?;
crate::domain::archive_ops::create_folder(&home, &folder)?;
let display = crate::domain::project::display_name_in(&Paths::real(), &project)?;
crate::domain::archive_ops::ensure_node_texts(&home, &display)?;
crate::domain::archive_ops::ensure_ids(&home)?;
wiki_refresh_page(&project, &home)
}
/// Pfad eines neuen Kindes unterhalb des Knotens `parent` (ID; leer =
/// Wurzel) — der Name wird als Slug angehängt.
fn join_under(
home: &std::path::Path,
parent: &str,
name: &str,
) -> Result<String, String> {
let name = name.trim();
if name.is_empty() {
return Err("Name fehlt".into());
}
let slug = crate::domain::archive::slugify(name);
if parent.is_empty() {
return Ok(slug);
}
// Der Zielordner ist der Ordner neben dem Knotentext des Elternteils.
let rel = crate::domain::archive_index::resolve_id(home, parent)?;
let stem = std::path::Path::new(&rel)
.file_stem()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let dir = match rel.rsplit_once('/') {
Some((head, _)) => format!("{head}/{stem}"),
None => stem,
};
Ok(format!("{dir}/{slug}"))
}
/// 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
@@ -638,9 +690,11 @@ pub fn archive_create_folder(project: String, folder: String) -> Result<(), Stri
/// 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> {
pub fn archive_create_doc(project: String, parent: 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_new = join_under(&home, &parent, &name)?;
let folder = rel_new.rsplit_once('/').map(|(h, _)| h.to_string()).unwrap_or_default();
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())?;
+104 -11
View File
@@ -5,10 +5,20 @@ import { initArchiveForm } from "./archive-form";
function setup() {
document.body.innerHTML = `<button id="btn">Archiv</button>`;
const onSubmit = vi.fn();
const form = initArchiveForm(document.getElementById("btn")!, onSubmit);
const onSave = vi.fn();
const folders = vi.fn(() =>
Promise.resolve([
{ id: "id-konzepte", path: "konzepte", title: "Konzepte" },
{ id: "id-panel", path: "konzepte/panel", title: "Panel" },
]),
);
const form = initArchiveForm(document.getElementById("btn")!, onSubmit, {
folders,
onSave,
});
const root = document.querySelector<HTMLElement>(".archive-form")!;
const inputs = [...root.querySelectorAll("input")];
return { form, onSubmit, root, inputs };
return { form, onSubmit, onSave, folders, root, inputs };
}
describe("initArchiveForm", () => {
@@ -25,15 +35,18 @@ describe("initArchiveForm", () => {
expect(root.hidden).toBe(true);
});
it("liefert getrimmte Meta; Leeres wird undefined, Tags gesplittet", () => {
it("liefert getrimmte Meta; Leeres wird undefined, Tags gesplittet", async () => {
const { form, onSubmit, root, inputs } = setup();
form.toggle();
inputs[0].value = " konzepte/panel ";
inputs[1].value = "";
inputs[2].value = " adr, infra ,, ";
await new Promise((r) => setTimeout(r));
root
.querySelector<HTMLElement>('.archive-browse-row[data-path="id-konzepte"]')!
.click();
inputs[0].value = "";
inputs[1].value = " adr, infra ,, ";
root.querySelector<HTMLElement>(".archive-form-submit")!.click();
expect(onSubmit).toHaveBeenCalledWith({
folder: "konzepte/panel",
folder: "id-konzepte",
description: undefined,
tags: ["adr", "infra"],
});
@@ -45,13 +58,12 @@ describe("initArchiveForm", () => {
it("startet nach dem Archivieren wieder leer", () => {
const { form, onSubmit, root, inputs } = setup();
form.toggle();
inputs[0].value = "konzepte/panel";
inputs[1].value = "Beschreibung A";
inputs[2].value = "panel, wiki";
inputs[0].value = "Beschreibung A";
inputs[1].value = "panel, wiki";
root.querySelector<HTMLElement>(".archive-form-submit")!.click();
form.toggle();
expect(inputs.map((i) => i.value)).toEqual(["", "", ""]);
expect(inputs.map((i) => i.value)).toEqual(["", ""]);
root.querySelector<HTMLElement>(".archive-form-submit")!.click();
expect(onSubmit).toHaveBeenLastCalledWith({
folder: undefined,
@@ -60,6 +72,87 @@ describe("initArchiveForm", () => {
});
});
it("Baum startet eingeklappt, Pfeil klappt auf, Klick wählt aus", async () => {
const { form, folders, root } = setup();
form.toggle();
await new Promise((r) => setTimeout(r));
expect(folders).toHaveBeenCalled();
// Eingeklappt: Wurzel und erste Ebene sichtbar, Unterebene verborgen.
const visible = () =>
[...root.querySelectorAll<HTMLElement>(".archive-browse-row")].filter(
(r) => !r.closest<HTMLElement>(".archive-kids[hidden]"),
);
expect(visible().map((r) => r.dataset.path)).toEqual(["", "id-konzepte"]);
// Logische Sicht: Titel des Knotentexts, Pfad an der Zeile.
const konzepte = visible()[1];
expect(konzepte.querySelector(".wiki-tree-name")!.textContent).toBe("Konzepte");
expect(konzepte.title).toBe("konzepte");
// Pfeil klappt auf, ohne die Auswahl zu ändern.
konzepte.querySelector<HTMLElement>(".archive-arrow")!.click();
expect(visible().map((r) => r.dataset.path)).toEqual(["", "id-konzepte", "id-panel"]);
// Aufklappen ändert die Auswahl nicht.
expect(visible()[0].className).toContain("active");
// Klick auf die Zeile wählt aus.
visible()[2].click();
expect(visible()[2].className).toContain("active");
expect(visible()[0].className).not.toContain("active");
});
it("Ladefehler des Baums steht im Kasten", async () => {
document.body.innerHTML = `<button id="btn">Archiv</button>`;
const form = initArchiveForm(document.getElementById("btn")!, vi.fn(), {
folders: () => Promise.reject(new Error("kein Archiv-Ordner gesetzt")),
});
form.toggle();
await new Promise((r) => setTimeout(r));
expect(
document.querySelector(".archive-browse-error")!.textContent,
).toContain("kein Archiv-Ordner gesetzt");
});
it("Auf Platte legen ruft onSave und schließt, ohne zu archivieren", () => {
const { form, onSave, onSubmit, root } = setup();
form.toggle();
root.querySelector<HTMLElement>(".archive-form-save")!.click();
expect(onSave).toHaveBeenCalledOnce();
expect(onSubmit).not.toHaveBeenCalled();
expect(root.hidden).toBe(true);
});
it("Abbrechen schließt ohne Abschicken", () => {
const { form, onSubmit, root } = setup();
form.toggle();
root.querySelector<HTMLElement>(".archive-form-cancel")!.click();
expect(root.hidden).toBe(true);
expect(onSubmit).not.toHaveBeenCalled();
});
it("Klick auf den Hintergrund schließt, Klick in die Box nicht", () => {
const { form, onSubmit, root } = setup();
form.toggle();
root.querySelector<HTMLElement>(".wiki-form")!.dispatchEvent(
new MouseEvent("mousedown", { bubbles: true }),
);
expect(root.hidden).toBe(false);
root.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
expect(root.hidden).toBe(true);
expect(onSubmit).not.toHaveBeenCalled();
});
/// Im Terminal-Fenster liegt der Fokus oft in der Shell — Escape muss auch
/// dann greifen, wenn die Taste nicht im Dialog ankommt.
it("Escape schließt auch von außerhalb des Dialogs", () => {
const { form, onSubmit, root } = setup();
form.toggle();
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
expect(root.hidden).toBe(true);
expect(onSubmit).not.toHaveBeenCalled();
});
it("Enter schickt ab, Escape schließt ohne Abschicken", () => {
const { form, onSubmit, root } = setup();
form.toggle();
+242 -22
View File
@@ -1,7 +1,10 @@
/// Archiv-Formular: klappt unter dem Archiv-Button auf und fragt Ordner,
/// Beschreibung und Schlagwörter ab (alles optional); Enter oder der Button
/// archivieren, Escape schließt. Gemeinsam für das angedockte Panel und das
/// abgelöste Fenster.
/// Archiv-Dialog: wohin archivieren, mit welcher Beschreibung und welchen
/// Schlagwörtern. Modales Popup im Design der Archiv-Ansicht — abgedunkelter
/// Hintergrund, zentrierte Box. Der Zielordner wird im sichtbaren Baum des
/// Archivs gewählt (Wurzel plus alle Ordner, Klick übernimmt); für einen neuen
/// Ordner steht darunter ein Feld, in dem der gewählte Pfad ergänzt wird.
/// Enter archiviert, Escape oder ein Klick auf den Hintergrund schließt.
/// Gemeinsam für das angedockte Panel und das Archiv-Fenster.
import { t } from "./messages";
@@ -11,31 +14,224 @@ export interface ArchiveFormMeta {
tags: string[];
}
/// Ordner-Knoten des Archivs für den Zielordner-Baum.
export interface ArchiveFolderNode {
/// Technische ID des Knotens — das archivierte Ziel.
id: string;
/// Pfad (nur für den Tooltip).
path: string;
title: string;
}
export interface ArchiveFormOptions {
/// Vorhandene Archiv-Ordner (Pfad + Titel) für den Zielordner-Baum.
folders?(): Promise<ArchiveFolderNode[]>;
/// „Auf Platte legen": Entwurf als Datei an frei gewähltem Pfad ablegen.
onSave?(): void;
}
export function initArchiveForm(
anchor: HTMLElement,
_anchor: HTMLElement,
onSubmit: (meta: ArchiveFormMeta) => void,
opts: ArchiveFormOptions = {},
): { toggle(): void } {
function field(placeholder: string): HTMLInputElement {
const i = document.createElement("input");
i.type = "text";
i.placeholder = placeholder;
return i;
function field(labelText: string, placeholder: string): HTMLInputElement {
const input = document.createElement("input");
input.type = "text";
input.className = "wiki-tree-input";
input.placeholder = placeholder;
box.append(labelled(labelText, input));
return input;
}
/// Beschriftete Gruppe. `label` nur für echte Eingabefelder: ein <label>
/// leitet Klicks auf sein erstes beschriftbares Kind um — Knöpfe im Baum
/// würden dadurch alle auf dieselbe Zeile wirken.
function labelled(
labelText: string,
control: HTMLElement,
tag: "label" | "div" = "label",
): HTMLElement {
const group = document.createElement(tag);
group.className = "archive-form-label";
const caption = document.createElement("span");
caption.textContent = labelText;
group.append(caption, control);
return group;
}
// Backdrop trägt die Modal-Optik, die Box das Formular — dieselben Klassen
// wie die Dialoge der Archiv-Ansicht.
const form = document.createElement("div");
form.className = "archive-form";
form.className = "archive-form wiki-modal";
form.hidden = true;
const folder = field(t("archiveForm.folder"));
const desc = field(t("archiveForm.description"));
const tags = field(t("archiveForm.tags"));
const box = document.createElement("div");
box.className = "wiki-form archive-form-box";
const caption = document.createElement("div");
caption.className = "wiki-form-title";
caption.textContent = t("archiveForm.title");
box.append(caption);
// --- Zielordner: durchlaufbarer Baum ------------------------------------
// Logische Sicht des Archivs (Knotentitel plus Pfad an der Zeile), startet
// eingeklappt. Die gewählte Zeile ist die Anzeige des Ziels — kein zweites
// Feld daneben.
const browse = document.createElement("div");
browse.className = "archive-browse";
box.append(labelled(t("archiveForm.folderLabel"), browse, "div"));
/// Gewählter Zielordner (Pfad relativ zum Archiv-Home, "" = Wurzel).
let selected = "";
/// Aufgeklappte Knoten (Pfade) — überleben das Neuzeichnen des Baums.
const openPaths = new Set<string>();
interface Node {
id: string;
path: string;
title: string;
children: Node[];
}
/// Flache Pfadliste zu einem Baum; Zwischenebenen ohne eigenen Eintrag
/// entstehen mit ihrem Ordnernamen als Titel.
function buildNodes(folders: ArchiveFolderNode[]): Node[] {
const byPath = new Map<string, Node>();
const roots: Node[] = [];
const ensure = (path: string, title: string, id = ""): Node => {
const found = byPath.get(path);
if (found) return found;
const node: Node = { id, path, title, children: [] };
byPath.set(path, node);
const cut = path.lastIndexOf("/");
if (cut < 0) roots.push(node);
else {
const parent = path.slice(0, cut);
ensure(parent, parent.split("/").pop()!).children.push(node);
}
return node;
};
for (const f of [...folders].sort((a, b) => a.path.localeCompare(b.path))) {
ensure(f.path, f.title, f.id);
}
return roots;
}
/// Ein Knoten: Zeile (Pfeil klappt, Rest wählt aus) plus Kinderliste.
function nodeEl(node: Node, depth: number): HTMLElement {
const wrap = document.createElement("div");
wrap.className = "archive-node";
const row = document.createElement("div");
row.className = "archive-browse-row";
row.dataset.path = node.id;
row.style.setProperty("--depth", String(depth));
const arrow = document.createElement("button");
arrow.className = "archive-arrow";
arrow.type = "button";
if (node.children.length) {
arrow.textContent = "▸";
arrow.title = t("archiveForm.expand");
} else {
arrow.classList.add("blank");
}
const icon = document.createElement("span");
icon.className = "wiki-tree-icon";
icon.innerHTML = `<svg width="17" height="17" viewBox="0 0 16 16"><circle cx="8" cy="8" r="4.2"/></svg>`;
const label = document.createElement("span");
label.className = "wiki-tree-name";
label.textContent = node.title;
// Der Pfad würde die Zeile überbreit machen; er steht als Tooltip dran.
row.title = node.path;
row.append(arrow, icon, label);
const kids = document.createElement("div");
kids.className = "archive-kids";
kids.hidden = !openPaths.has(node.path);
for (const child of node.children) kids.append(nodeEl(child, depth + 1));
arrow.addEventListener("click", (e) => {
e.stopPropagation();
if (!node.children.length) return;
kids.hidden = !kids.hidden;
if (kids.hidden) openPaths.delete(node.path);
else openPaths.add(node.path);
arrow.textContent = kids.hidden ? "▸" : "▾";
});
arrow.textContent = node.children.length ? (kids.hidden ? "▸" : "▾") : "";
row.addEventListener("click", () => select(node.id));
wrap.append(row, kids);
return wrap;
}
function select(path: string) {
selected = path;
mark();
}
/// Die gewählte Zeile hervorheben — sie ist die einzige Anzeige des Ziels.
function mark() {
for (const row of browse.querySelectorAll<HTMLElement>(
".archive-browse-row",
)) {
row.classList.toggle("active", row.dataset.path === selected);
}
}
function renderBrowse(folders: ArchiveFolderNode[]) {
const root = document.createElement("div");
root.className = "archive-browse-row";
root.dataset.path = "";
root.style.setProperty("--depth", "0");
const blank = document.createElement("span");
blank.className = "archive-arrow blank";
const icon = document.createElement("span");
icon.className = "wiki-tree-icon";
icon.innerHTML = `<svg width="17" height="17" viewBox="0 0 16 16"><circle cx="8" cy="8" r="4.2"/></svg>`;
const label = document.createElement("span");
label.className = "wiki-tree-name";
label.textContent = t("wiki.archive");
root.append(blank, icon, label);
root.addEventListener("click", () => select(""));
browse.replaceChildren(root);
for (const node of buildNodes(folders)) browse.append(nodeEl(node, 1));
mark();
}
const desc = field(
t("archiveForm.descriptionLabel"),
t("archiveForm.description"),
);
const tags = field(t("archiveForm.tagsLabel"), t("archiveForm.tags"));
const row = document.createElement("div");
row.className = "wiki-form-row";
const submit = document.createElement("button");
submit.className = "archive-form-submit";
submit.className = "archive-form-submit wiki-form-submit";
submit.textContent = t("archiveForm.submit");
form.append(folder, desc, tags, submit);
const cancel = document.createElement("button");
cancel.className = "archive-form-cancel wiki-form-cancel";
cancel.textContent = t("archiveForm.cancel");
row.append(submit);
if (opts.onSave) {
const save = document.createElement("button");
save.className = "archive-form-save wiki-form-submit";
save.textContent = t("archiveForm.save");
save.title = t("archiveForm.saveTitle");
save.addEventListener("click", () => {
opts.onSave!();
close();
});
row.append(save);
}
row.append(cancel);
box.append(row);
form.append(box);
document.body.append(form);
const meta = (): ArchiveFormMeta => ({
folder: folder.value.trim() || undefined,
folder: selected || undefined,
description: desc.value.trim() || undefined,
tags: tags.value
.split(",")
@@ -45,27 +241,51 @@ export function initArchiveForm(
// Leeren, weil das Formular zum Fenster gehört und nicht zum Dokument.
const close = () => {
form.hidden = true;
folder.value = "";
selected = "";
desc.value = "";
tags.value = "";
document.removeEventListener("keydown", onKey);
};
// Escape auch dann, wenn der Fokus nicht im Dialog sitzt (Terminal-Fenster
// gibt Tasten sonst an die Shell weiter).
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") close();
};
const open = () => {
const r = anchor.getBoundingClientRect();
form.style.top = `${r.bottom + 6}px`;
form.style.right = `${Math.max(8, window.innerWidth - r.right - 4)}px`;
form.hidden = false;
folder.focus();
document.addEventListener("keydown", onKey);
desc.focus();
// Frisch laden: zwischen zwei Dialogen können Ordner entstanden sein.
// Scheitert das Laden, steht der Grund im Kasten — ein leerer Kasten
// ohne Erklärung war der Fehler der Vorversion.
browse.replaceChildren();
// .catch statt zweitem then-Argument: so fallen auch Fehler beim Aufbau
// des Baums auf, nicht nur beim Laden.
opts
.folders?.()
.then(renderBrowse)
.catch((e) => {
const err = document.createElement("div");
err.className = "archive-browse-error";
err.textContent = String(e);
browse.replaceChildren(err);
});
};
const fire = () => {
onSubmit(meta());
close();
};
submit.addEventListener("click", fire);
cancel.addEventListener("click", close);
form.addEventListener("mousedown", (e) => {
if (e.target === form) close();
});
form.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
fire();
} else if (e.key === "Escape") {
e.stopPropagation();
close();
}
});
+20 -10
View File
@@ -205,7 +205,7 @@ const de = {
raw: "Roh",
editDraft: "Entwurf bearbeiten (Cmd/Ctrl+Enter speichert, Esc verwirft)",
copy: "In die Zwischenablage kopieren",
archive: "In den Archiv-Ordner speichern",
archive: "In den Archiv-Ordner speichern",
chooseArchiveDir: "Archiv-Ordner wählen",
processEnded: "[Prozess beendet]",
},
@@ -220,10 +220,18 @@ const de = {
session: "Session",
},
archiveForm: {
folder: "Ordner — z. B. konzepte/panel",
title: "Entwurf archivieren",
folderLabel: "Zielordner im Archiv",
expand: "Auf-/zuklappen",
descriptionLabel: "Beschreibung",
tagsLabel: "Schlagwörter",
folder: "z. B. konzepte/panel",
description: "Beschreibung",
tags: "Schlagwörter, kommagetrennt",
submit: "Archivieren",
save: "Auf Platte legen …",
saveTitle: "Als Datei an frei gewähltem Ort ablegen — ohne Archiv",
cancel: "Abbrechen",
},
search: {
placeholder: "Archiv durchsuchen — #tag filtert",
@@ -242,8 +250,6 @@ const de = {
"Archivieren: Archiv-Button im Entwurf oder „archiviere das“ im Chat — mit Ordner, Beschreibung und Schlagwörtern.",
noPage: "Keine Wiki-Seite geladen.",
openOverview: "Archiv-Übersicht öffnen",
folderRename: "Ordnerpfad ändern",
renameDoc: "Dateiname ändern",
titleEdit: "Titel bearbeiten — klicken",
editDoc: "Bearbeiten",
save: "Speichern",
@@ -257,7 +263,6 @@ const de = {
newDoc: "Neues Dokument",
create: "Anlegen",
cancel: "Abbrechen",
folderPath: "ordner/unterordner",
docName: "Name des Dokuments",
},
};
@@ -462,7 +467,7 @@ const en: typeof de = {
raw: "Raw",
editDraft: "Edit draft (Cmd/Ctrl+Enter saves, Esc discards)",
copy: "Copy to clipboard",
archive: "Save to the archive folder",
archive: "Save to the archive folder",
chooseArchiveDir: "Choose archive folder",
processEnded: "[process ended]",
},
@@ -477,10 +482,18 @@ const en: typeof de = {
session: "Session",
},
archiveForm: {
folder: "Folder — e.g. concepts/panel",
title: "Archive draft",
folderLabel: "Target folder in the archive",
expand: "Expand/collapse",
descriptionLabel: "Description",
tagsLabel: "Tags",
folder: "e.g. concepts/panel",
description: "Description",
tags: "Tags, comma-separated",
submit: "Archive",
save: "Save to disk …",
saveTitle: "Store as a file at a location you pick — outside the archive",
cancel: "Cancel",
},
search: {
placeholder: "Search the archive — #tag filters",
@@ -499,8 +512,6 @@ const en: typeof de = {
"To archive: the archive button in the draft, or “archive this” in the chat — with folder, description and tags.",
noPage: "No wiki page loaded.",
openOverview: "Open archive overview",
folderRename: "Change folder path",
renameDoc: "Change file name",
titleEdit: "Edit title — click",
editDoc: "Edit",
save: "Save",
@@ -514,7 +525,6 @@ const en: typeof de = {
newDoc: "New document",
create: "Create",
cancel: "Cancel",
folderPath: "folder/subfolder",
docName: "Document name",
},
};
+8 -11
View File
@@ -26,10 +26,9 @@ export const wikiTab: PanelTab = {
);
return initWikiView(container, {
autoStart: ctx.standalone,
readDoc: (relpath) => invoke("archive_read", { project: ctx.project, relpath }),
writeDoc: (relpath, text) =>
invoke("archive_write", { project: ctx.project, relpath, text }),
setTitle: (relpath, title) => run("archive_set_title", { relpath, title }),
readDoc: (id) => invoke("archive_read", { project: ctx.project, id }),
writeDoc: (id, text) => invoke("archive_write", { project: ctx.project, id, text }),
setTitle: (id, title) => run("archive_set_title", { id, title }),
openWiki: ctx.openWiki,
takePending: () => {
const p = pendingSelect;
@@ -37,11 +36,9 @@ export const wikiTab: PanelTab = {
return p;
},
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 }),
remove: (id) => run("archive_delete", { id }),
createFolder: (parent, name) => run("archive_create_folder", { parent, name }),
createDoc: (parent, name) => run("archive_create_doc", { parent, name }),
},
});
},
@@ -67,8 +64,8 @@ export const searchTab: PanelTab = {
init: (container, ctx) =>
initSearchView(
container,
(_path, relpath) => {
pendingSelect = relpath;
(_path, _relpath, id) => {
pendingSelect = id;
ctx.openWiki("tag:");
},
(raw) => {
+120 -32
View File
@@ -522,6 +522,9 @@
}
/* Anlege-Dialog (Ordner/Dokument): modales Popup mit abgedunkeltem
Hintergrund. */
.wiki-modal[hidden] {
display: none;
}
.wiki-modal {
position: fixed;
inset: 0;
@@ -949,55 +952,140 @@
}
/* Archiv-Formular: klappt unter dem Archiv-Button auf. */
.archive-form {
position: fixed;
z-index: 20;
/* Archiv-Dialog: Optik aus .wiki-modal/.wiki-form, plus Ordner-Baum zum
Anklicken. */
.archive-form-box {
min-width: 540px;
max-width: 90vw;
}
.archive-form-label {
display: flex;
flex-direction: column;
gap: 6px;
width: 260px;
color: var(--text);
font:
600 14px/1.4 -apple-system,
system-ui,
sans-serif;
}
/* Eingabefelder des Dialogs sichtbar abheben — der dünne Rahmen auf
dunklem Grund war als Feld nicht zu erkennen. */
.archive-form-box .wiki-tree-input {
background: var(--surface);
border: 1px solid var(--line-strong);
border-radius: 6px;
padding: 9px 12px;
font-size: 15px;
}
.archive-form-box .wiki-tree-input:focus {
border-color: var(--accent);
}
.archive-form-box .wiki-tree-input::placeholder {
color: var(--faint);
}
/* Durchlaufbarer Ordnerbaum: eigene Fläche, scrollt bei vielen Ordnern. */
.archive-browse {
display: flex;
flex-direction: column;
gap: 1px;
max-height: 240px;
/* Nur senkrecht scrollen: eine waagerechte Leiste am unteren Rand sähe
aus wie ein weiteres Bedienelement. Lange Titel kürzen stattdessen. */
overflow-y: auto;
overflow-x: hidden;
background: var(--surface);
border: 1px solid var(--line-strong);
border-radius: 8px;
padding: 10px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
padding: 6px;
}
.archive-form[hidden] {
.archive-browse-row {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
min-width: 0;
text-align: left;
border: none;
background: none;
cursor: pointer;
border-radius: 5px;
padding: 5px 8px;
/* Einrückung nach Tiefe — dieselbe Staffelung wie im Archiv-Baum. */
padding-left: calc(8px + var(--depth, 0) * 18px);
color: var(--text);
text-transform: none;
letter-spacing: 0;
font:
400 15px/1.5 -apple-system,
system-ui,
sans-serif;
}
/* Klapp-Pfeil; ohne Kinder bleibt der Platz frei, damit die Titel bündig
stehen. */
.archive-arrow {
flex: none;
width: 16px;
border: none;
background: none;
cursor: pointer;
padding: 0;
color: var(--muted);
font-size: 13px;
line-height: 1;
text-align: center;
}
.archive-arrow.blank {
cursor: default;
}
.archive-arrow:hover:not(.blank) {
color: var(--text);
}
.archive-kids[hidden] {
display: none;
}
.archive-form input {
box-sizing: border-box;
width: 100%;
background: var(--tile);
border: 1px solid var(--line-strong);
border-radius: 6px;
padding: 6px 10px;
color: var(--text);
/* Ladefehler des Baums — laut statt leerer Kasten. */
.archive-browse-error {
color: #f38ba8;
text-transform: none;
letter-spacing: 0;
padding: 6px 8px;
font:
400 12px/1.4 -apple-system,
500 13px/1.5 -apple-system,
system-ui,
sans-serif;
outline: none;
}
.archive-form input:focus {
/* Jede Zeile dauerhaft sichtbar — nichts erscheint erst beim Überfahren. */
.archive-browse-row {
background: var(--tile);
border: 1px solid transparent;
margin-bottom: 2px;
}
.archive-browse-row:hover {
border-color: var(--line-strong);
}
.archive-form input::placeholder {
color: var(--faint);
.archive-browse-row.active {
border-color: var(--accent);
color: var(--accent);
}
.archive-form-submit {
border: none;
background: var(--line);
color: var(--text);
border-radius: 6px;
padding: 6px 10px;
font:
500 12px/1.4 -apple-system,
system-ui,
sans-serif;
.archive-browse-row .wiki-tree-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.archive-form-submit:hover {
background: var(--line-strong);
.archive-browse-row .wiki-tree-icon svg {
fill: none;
stroke: var(--muted);
stroke-width: 1.3;
}
.archive-browse-row.active .wiki-tree-icon svg {
stroke: var(--accent);
}
/* Knopfreihe vom Formular abgesetzt. */
.archive-form-box .wiki-form-row {
border-top: 1px solid var(--line-strong);
padding-top: 12px;
margin-top: 2px;
}
/* Sichtbare Fehlermeldung (panelToast), oben rechts über dem Panel-Inhalt. */
+57 -28
View File
@@ -91,7 +91,8 @@ interface Project {
terminal: { theme: string | null };
}
const projects = await invoke<Project[]>("list_projects");
const picked = THEMES[projects.find((p) => p.name === project)?.terminal.theme ?? "mocha"];
const picked =
THEMES[projects.find((p) => p.name === project)?.terminal.theme ?? "mocha"];
applyTheme(picked);
// Fensterhintergrund des Panel-Fensters ist die Kopf-Fläche, nicht das
// Terminal-Dunkel.
@@ -111,33 +112,59 @@ if (initialMode) {
// Archivieren: wie im angedockten Panel — Formular aufklappen, Abschicken
// wählt notfalls erst das Archiv-Home per Dialog.
const archiveBtn = document.getElementById("panel-archive")!;
const archiveForm = initArchiveForm(archiveBtn, async (meta) => {
try {
const configured = await invoke<string | null>("panel_archive_dir_cmd", {
project,
});
let dir: string | undefined;
if (!configured) {
const { open } = await import("@tauri-apps/plugin-dialog");
const chosen = await open({ directory: true, title: t("panel.chooseArchiveDir") });
if (!chosen) return;
dir = chosen as string;
const archiveForm = initArchiveForm(
archiveBtn,
async (meta) => {
try {
const configured = await invoke<string | null>("panel_archive_dir_cmd", {
project,
});
let dir: string | undefined;
if (!configured) {
const { open } = await import("@tauri-apps/plugin-dialog");
const chosen = await open({
directory: true,
title: t("panel.chooseArchiveDir"),
});
if (!chosen) return;
dir = chosen as string;
}
await view.flush(); // offene Bearbeitung erst speichern — archiviert, was zu sehen ist
await invoke<string>("panel_archive_cmd", {
project,
dir,
folder: meta.folder ?? null,
description: meta.description ?? null,
tags: meta.tags,
});
flash(archiveBtn, "copied", 1400);
} catch (e) {
flash(archiveBtn, "error", 1400);
panelToast(`Archivieren fehlgeschlagen: ${e}`);
}
await view.flush(); // offene Bearbeitung erst speichern — archiviert, was zu sehen ist
const path = await invoke<string>("panel_archive_cmd", {
project,
dir,
folder: meta.folder ?? null,
description: meta.description ?? null,
tags: meta.tags,
});
flash(archiveBtn, "copied", 1400);
invoke("reveal_path_cmd", { path });
} catch (e) {
flash(archiveBtn, "error", 1400);
panelToast(`Archivieren fehlgeschlagen: ${e}`);
}
});
},
{
// Ordner-Vorschläge aus dem Archiv; „Auf Platte legen" schreibt den
// Entwurf an einen frei gewählten Pfad (ohne Archiv, ohne Frontmatter).
folders: () => invoke<string[]>("archive_folders", { project }),
onSave: async () => {
try {
const { save } = await import("@tauri-apps/plugin-dialog");
const path = await save({
title: t("archiveForm.saveTitle"),
defaultPath: "entwurf.md",
});
if (!path) return;
await view.flush();
await invoke("panel_save_as", { project, path });
flash(archiveBtn, "copied", 1400);
} catch (e) {
flash(archiveBtn, "error", 1400);
panelToast(`Speichern fehlgeschlagen: ${e}`);
}
},
},
);
archiveBtn.addEventListener("click", () => archiveForm.toggle());
// Öffner-Klick im Terminal-Header bei stehendem Fenster: auf den
@@ -150,4 +177,6 @@ document.getElementById("panel-dock")!.hidden = true;
// „Schließen": Fenster zu, ohne wieder anzudocken (Panel bleibt aus, bis ein
// neuer Entwurf kommt).
document.getElementById("panel-close")!.addEventListener("click", () => win.close());
document
.getElementById("panel-close")!
.addEventListener("click", () => win.close());
+2 -1
View File
@@ -21,7 +21,7 @@ const run = JSON.stringify({
query: "arch",
tag: null,
home: "/tmp/archiv",
hits: [{ relpath: "a/2026-01-01_0000-x.md", title: "X", snippet: "ein **arch**iv" }],
hits: [{ id: "id-x", relpath: "a/2026-01-01_0000-x.md", title: "X", snippet: "ein **arch**iv" }],
});
describe("initSearchView", () => {
@@ -84,6 +84,7 @@ describe("initSearchView", () => {
expect(onOpen).toHaveBeenCalledWith(
"/tmp/archiv/a/2026-01-01_0000-x.md",
"a/2026-01-01_0000-x.md",
"id-x",
);
});
+3 -2
View File
@@ -8,6 +8,7 @@ import { t } from "./messages";
import { renderTile } from "./tiles";
interface Hit {
id: string;
relpath: string;
title: string;
snippet: string;
@@ -27,7 +28,7 @@ export interface SearchView {
export function initSearchView(
container: HTMLElement,
onOpen: (path: string, relpath: string) => void,
onOpen: (path: string, relpath: string, id: string) => void,
onSearch: (query: string) => void,
): SearchView {
let count = 0;
@@ -108,7 +109,7 @@ export function initSearchView(
snippetEl(hit.snippet),
{ cls: "hit-path", text: hit.relpath },
],
onClick: () => onOpen(`${run.home}/${hit.relpath}`, hit.relpath),
onClick: () => onOpen(`${run.home}/${hit.relpath}`, hit.relpath, hit.id),
}),
);
}
+60 -29
View File
@@ -177,7 +177,9 @@ function b64ToBytes(b64: string): Uint8Array {
return bytes;
}
await win.listen<string>("pty-output", (e) => term.write(b64ToBytes(e.payload)));
await win.listen<string>("pty-output", (e) =>
term.write(b64ToBytes(e.payload)),
);
await win.listen("pty-exit", () =>
term.write(`\r\n\x1b[90m${t("panel.processEnded")}\x1b[0m\r\n`),
);
@@ -281,41 +283,70 @@ document.getElementById("panel-hide")!.addEventListener("click", () => {
// Archivieren: Button klappt das Formular (Ordner/Beschreibung/Schlagwörter)
// auf; Abschicken wählt notfalls erst das Archiv-Home per Dialog.
const archiveBtn = document.getElementById("panel-archive")!;
const archiveForm = initArchiveForm(archiveBtn, async (meta) => {
try {
const configured = await invoke<string | null>("panel_archive_dir_cmd", {
project,
});
let dir: string | undefined;
if (!configured) {
const { open } = await import("@tauri-apps/plugin-dialog");
const chosen = await open({ directory: true, title: t("panel.chooseArchiveDir") });
if (!chosen) return;
dir = chosen as string;
const archiveForm = initArchiveForm(
archiveBtn,
async (meta) => {
try {
const configured = await invoke<string | null>("panel_archive_dir_cmd", {
project,
});
let dir: string | undefined;
if (!configured) {
const { open } = await import("@tauri-apps/plugin-dialog");
const chosen = await open({
directory: true,
title: t("panel.chooseArchiveDir"),
});
if (!chosen) return;
dir = chosen as string;
}
await view.flush(); // offene Bearbeitung erst speichern — archiviert, was zu sehen ist
await invoke<string>("panel_archive_cmd", {
project,
dir,
folder: meta.folder ?? null,
description: meta.description ?? null,
tags: meta.tags,
});
flash(archiveBtn, "copied", 1400);
} catch (e) {
flash(archiveBtn, "error", 1400);
panelToast(`Archivieren fehlgeschlagen: ${e}`);
invoke("term_log", { msg: `archive: ${e}` });
}
await view.flush(); // offene Bearbeitung erst speichern — archiviert, was zu sehen ist
const path = await invoke<string>("panel_archive_cmd", {
project,
dir,
folder: meta.folder ?? null,
description: meta.description ?? null,
tags: meta.tags,
});
flash(archiveBtn, "copied", 1400);
invoke("reveal_path_cmd", { path });
} catch (e) {
flash(archiveBtn, "error", 1400);
panelToast(`Archivieren fehlgeschlagen: ${e}`);
invoke("term_log", { msg: `archive: ${e}` });
}
});
},
{
// Ordner-Vorschläge aus dem Archiv; „Auf Platte legen" schreibt den
// Entwurf an einen frei gewählten Pfad (ohne Archiv, ohne Frontmatter).
folders: () => invoke<string[]>("archive_folders", { project }),
onSave: async () => {
try {
const { save } = await import("@tauri-apps/plugin-dialog");
const path = await save({
title: t("archiveForm.saveTitle"),
defaultPath: "entwurf.md",
});
if (!path) return;
await view.flush();
await invoke("panel_save_as", { project, path });
flash(archiveBtn, "copied", 1400);
} catch (e) {
flash(archiveBtn, "error", 1400);
panelToast(`Speichern fehlgeschlagen: ${e}`);
}
},
},
);
archiveBtn.addEventListener("click", () => archiveForm.toggle());
// Splitter: Panelbreite ziehen.
splitter.addEventListener("mousedown", (e) => {
e.preventDefault();
const move = (ev: MouseEvent) => {
const w = Math.max(240, Math.min(window.innerWidth - 200, window.innerWidth - ev.clientX));
const w = Math.max(
240,
Math.min(window.innerWidth - 200, window.innerWidth - ev.clientX),
);
panel.style.width = `${w}px`;
fit.fit();
};
+29 -37
View File
@@ -12,9 +12,7 @@ function setup(pending: string | null = null) {
const setTitle = vi.fn();
const openWiki = vi.fn();
const actions = {
rename: vi.fn(),
remove: vi.fn(),
moveFolder: vi.fn(),
createFolder: vi.fn(),
createDoc: vi.fn(),
};
@@ -36,6 +34,7 @@ function setup(pending: string | null = null) {
}
const doc = (relpath: string, name: string, extra: object = {}) => ({
id: `id-${name}`,
relpath,
name,
title: name.toUpperCase(),
@@ -61,9 +60,17 @@ const page = JSON.stringify({
backlinks: 2,
description: "Beschreibung",
}),
// Knotentext des Ordners „konzepte" — jeder Knoten hat einen.
doc("konzepte.md", "konzepte", { title: "konzepte" }),
],
},
{
name: "konzepte",
docs: [
doc("konzepte/2026-07-19_1005-neu.md", "neu"),
doc("konzepte/panel.md", "panel", { title: "panel" }),
],
},
{ 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")],
@@ -115,7 +122,7 @@ describe("initWikiView — Baum", () => {
// Auswahl des Knotens zeigt Inhalt UND Kindliste.
document.querySelector<HTMLElement>(".wiki-tree summary .wiki-tree-name")!.click();
await flush();
expect(readDoc).toHaveBeenCalledWith("konzepte.md");
expect(readDoc).toHaveBeenCalledWith("id-konzepte");
expect(document.querySelector(".wiki-note-body h1")!.textContent).toBe("Inhalt");
expect(document.querySelector(".wiki-note-children .wiki-doc-title")!.textContent).toBe(
"NEU",
@@ -129,7 +136,7 @@ describe("initWikiView — Notiz-Ansicht", () => {
view.set(page);
document.querySelector<HTMLElement>(".wiki-tree-doc")!.click(); // WURZEL-DOC
await flush();
expect(readDoc).toHaveBeenCalledWith("2026-07-19_1000-wurzel-doc.md");
expect(readDoc).toHaveBeenCalledWith("id-wurzel-doc");
expect(document.querySelector(".wiki-note-title")!.textContent).toBe("WURZEL-DOC");
// Meta als Popup am Info-Knopf.
const pop = document.querySelector<HTMLElement>(".wiki-note-info-pop")!;
@@ -166,10 +173,7 @@ describe("initWikiView — Notiz-Ansicht", () => {
expect(document.querySelector(".wiki-edit-preview h1")!.textContent).toBe("Neu");
document.querySelector<HTMLElement>(".wiki-form-submit")!.click();
await flush();
expect(writeDoc).toHaveBeenCalledWith(
"2026-07-19_1000-wurzel-doc.md",
"# Neu\n\nGeändert.",
);
expect(writeDoc).toHaveBeenCalledWith("id-wurzel-doc", "# Neu\n\nGeändert.");
// Nach dem Speichern wieder die Anzeige.
expect(document.querySelector(".wiki-note-editor")).toBeNull();
expect(document.querySelector(".wiki-note-body")).not.toBeNull();
@@ -207,11 +211,11 @@ describe("initWikiView — Notiz-Ansicht", () => {
});
it("vorgemerkter Suchtreffer wählt die Notiz beim set() aus", async () => {
const { view, readDoc } = setup("konzepte/2026-07-19_1005-neu.md");
const { view, readDoc } = setup("id-neu");
view.set(page);
await flush();
expect(document.querySelector(".wiki-note-title")!.textContent).toBe("NEU");
expect(readDoc).toHaveBeenCalledWith("konzepte/2026-07-19_1005-neu.md");
expect(readDoc).toHaveBeenCalledWith("id-neu");
});
});
@@ -246,7 +250,7 @@ describe("initWikiView — Aktionen", () => {
expect(input.value).toBe("WURZEL-DOC");
input.value = "Neuer Titel";
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
expect(setTitle).toHaveBeenCalledWith("2026-07-19_1000-wurzel-doc.md", "Neuer Titel");
expect(setTitle).toHaveBeenCalledWith("id-wurzel-doc", "Neuer Titel");
});
it("Escape verwirft die Titel-Bearbeitung", async () => {
@@ -267,7 +271,7 @@ describe("initWikiView — Aktionen", () => {
document.querySelector<HTMLElement>(".wiki-tree-doc")!.click();
await flush();
document.querySelector<HTMLElement>(".wiki-note-actions .cmd-del")!.click();
expect(actions.remove).toHaveBeenCalledWith("2026-07-19_1000-wurzel-doc.md");
expect(actions.remove).toHaveBeenCalledWith("id-wurzel-doc");
});
it("Plus im Notiz-Kopf: Formular legt Dokument im gewählten Ordner an", () => {
@@ -280,7 +284,7 @@ describe("initWikiView — Aktionen", () => {
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(actions.createDoc).toHaveBeenCalledWith("id-konzepte", "Deploy Notiz");
expect(document.querySelector(".wiki-form")).toBeNull();
});
@@ -299,35 +303,23 @@ describe("initWikiView — Aktionen", () => {
expect(actions.createDoc).not.toHaveBeenCalled();
});
it("Rechtsklick auf Ordner: Menü legt Ordner darunter an", () => {
it("Rechtsklick auf Knoten: Menü legt darunter an (Eltern-ID)", () => {
const { view, actions } = setup();
view.set(page);
const summary = document.querySelector<HTMLElement>(".wiki-tree summary")!;
summary.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true }));
view.set(mergedPage);
document
.querySelector<HTMLElement>(".wiki-tree summary")!
.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true }));
const items = [...document.querySelectorAll<HTMLElement>(".wiki-menu-item")];
items.find((i) => i.textContent === "Neuer Ordner")!.click();
const input = document.querySelector<HTMLInputElement>(".wiki-form input")!;
expect(input.value).toBe("konzepte/");
input.value = "konzepte/neu";
input.value = "neu";
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
expect(actions.createFolder).toHaveBeenCalledWith("konzepte/neu");
expect(actions.createFolder).toHaveBeenCalledWith("id-konzepte", "neu");
});
it("Rechtsklick auf Ordner: Dialog ändert den Ordnerpfad", () => {
const { view, actions } = setup();
view.set(page);
const summary = document.querySelector<HTMLElement>(".wiki-tree summary")!;
summary.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true }));
const items = [...document.querySelectorAll<HTMLElement>(".wiki-menu-item")];
items.find((i) => i.textContent === "Ordnerpfad ändern")!.click();
const input = document.querySelector<HTMLInputElement>(".wiki-form 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");
});
it("Rechtsklick auf Dokument: Menü löscht mit relpath", () => {
it("Rechtsklick auf Dokument: Menü löscht mit ID", () => {
const { view, actions } = setup();
view.set(page);
document
@@ -335,7 +327,7 @@ describe("initWikiView — Aktionen", () => {
.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true }));
const items = [...document.querySelectorAll<HTMLElement>(".wiki-menu-item")];
items.find((i) => i.textContent === "Dokument löschen")!.click();
expect(actions.remove).toHaveBeenCalledWith("2026-07-19_1000-wurzel-doc.md");
expect(actions.remove).toHaveBeenCalledWith("id-wurzel-doc");
expect(document.querySelector(".wiki-menu")).toBeNull();
});
});
+83 -89
View File
@@ -12,6 +12,9 @@ import { linkWikiRefs } from "./panel-view";
import { deleteAction } from "./tiles";
interface DocEntry {
/// Technische ID — bleibt über Umbenennen und Verschieben gleich; alle
/// Aktionen und die Auswahl laufen darüber. Der Pfad ist nur Eigenschaft.
id: string;
relpath: string;
name: string;
title: string;
@@ -40,22 +43,21 @@ interface Page {
/// 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;
remove(id: string): void;
/// Neues Kind unterhalb des Knotens (ID; "" = Wurzel).
createFolder(parent: string, name: string): void;
createDoc(parent: string, name: string): void;
}
export interface WikiCallbacks {
/// Leeren Puffer sofort mit der Übersicht füllen (eigenes Fenster).
autoStart?: boolean;
/// Body eines Archiv-Dokuments (ohne Frontmatter) für die Notiz-Ansicht.
readDoc(relpath: string): Promise<string>;
readDoc(id: string): Promise<string>;
/// Body einer Archiv-Notiz zurückschreiben (Bearbeiten im Archiv).
writeDoc(relpath: string, text: string): Promise<void>;
writeDoc(id: string, text: string): Promise<void>;
/// Anzeige-Titel einer Notiz setzen (Klick auf den Titel).
setTitle(relpath: string, title: string): void;
setTitle(id: string, title: string): void;
/// Wiki-Ziel laden (`tag:` = Übersicht in den Puffer, Einstiegs-Chip).
openWiki(name: string): void;
/// Vorgemerkte Auswahl (Suchtreffer-Sprung): einmalig abholen.
@@ -134,9 +136,9 @@ function buildTree(p: Page): TreeNode {
}
export function initWikiView(container: HTMLElement, cb: WikiCallbacks): WikiView {
/// Zugeklappte Ordner und Auswahl — Auswahl ist ein Ordnerpfad ("" =
/// Wurzel) oder ein Dokument-relpath (endet auf .md); beides überlebt
/// Puffer-Updates innerhalb der Ansicht.
/// Zugeklappte Knoten und Auswahl — beides über die technische ID der
/// Notiz ("" = Archiv-Wurzel); übersteht Umbenennen, Verschieben und
/// Puffer-Updates.
const closed = new Set<string>();
let selected = "";
let current: Page | null = null;
@@ -146,31 +148,41 @@ export function initWikiView(container: HTMLElement, cb: WikiCallbacks): WikiVie
/// Bearbeitungsmodus der aktuellen Notiz (Editor statt Anzeige).
let editing = false;
const isDoc = (sel: string) => sel.endsWith(".md");
/// Ist die ID ein Blatt (Notiz ohne Kinder)?
const isLeaf = (id: string) => !!id && !nodeById(id);
function findNode(path: string): TreeNode | null {
let n = tree;
if (!path) return n;
for (const part of path.split("/")) {
const next = n.children.get(part);
if (!next) return null;
n = next;
}
return n;
}
function findDoc(relpath: string): DocEntry | null {
if (!current) return null;
/// Notiz zur ID (Blatt oder Knotentext).
function findDoc(id: string): DocEntry | null {
if (!current || !id) return null;
for (const f of current.folders) {
const hit = f.docs.find((d) => d.relpath === relpath);
const hit = f.docs.find((d) => d.id === id);
if (hit) return hit;
}
return null;
}
/// Baumknoten zur ID seines Knotentexts ("" = Wurzel).
function nodeById(id: string, from: TreeNode = tree): TreeNode | null {
if (!id) return tree;
if (from.content?.id === id) return from;
for (const child of from.children.values()) {
const hit = nodeById(id, child);
if (hit) return hit;
}
return null;
}
/// Elternknoten einer Notiz — Ziel beim Anlegen von Kindern.
function parentOf(id: string, from: TreeNode = tree): TreeNode | null {
if (from.docs.some((d) => d.id === id)) return from;
for (const child of from.children.values()) {
if (child.content?.id === id) return from;
const hit = parentOf(id, child);
if (hit) return hit;
}
return null;
}
/// Ordnerpfad eines Dokument-relpaths.
const docFolder = (relpath: string) =>
relpath.includes("/") ? relpath.slice(0, relpath.lastIndexOf("/")) : "";
/// Wikilink lokal auflösen (Name, Titel, Stem — Slug-Vergleich wie im
/// Backend); ohne Treffer übernimmt das Backend (Dokument-Tab).
@@ -179,7 +191,7 @@ export function initWikiView(container: HTMLElement, cb: WikiCallbacks): WikiVie
for (const f of current?.folders ?? []) {
for (const d of f.docs) {
if ([d.name, d.title, stem(d.relpath)].some((s) => slugify(s) === want)) {
select(d.relpath);
select(d.id);
return;
}
}
@@ -238,23 +250,24 @@ export function initWikiView(container: HTMLElement, cb: WikiCallbacks): WikiVie
}
function folderRow(name: string, full: string, node: TreeNode): HTMLElement {
const key = node.content?.id ?? full;
const det = document.createElement("details");
det.className = "wiki-tree-folder";
det.open = !closed.has(full);
det.open = !closed.has(key);
det.addEventListener("toggle", () => {
if (det.open) closed.delete(full);
else closed.add(full);
if (det.open) closed.delete(key);
else closed.add(key);
});
const sum = document.createElement("summary");
sum.classList.add(node.content ? "has-content" : "book");
if (full === selected || node.content?.relpath === selected) {
if (node.content?.id === selected) {
sum.classList.add("active");
}
// Die ganze Zeile wählt die Notiz aus (wie in Trilium); NUR der Pfeil
// klappt — deshalb das summary-Default unterbinden und selbst schalten.
sum.addEventListener("click", (e) => {
e.preventDefault();
select(full);
select(node.content?.id ?? "");
});
const arrow = document.createElement("span");
arrow.className = "wiki-tree-arrow";
@@ -270,16 +283,10 @@ export function initWikiView(container: HTMLElement, cb: WikiCallbacks): WikiVie
sum.addEventListener("contextmenu", (e) => {
e.preventDefault();
e.stopPropagation();
const id = node.content?.id ?? "";
openMenu(e.clientX, e.clientY, [
{ label: t("wiki.newDoc"), run: () => newDocForm(full) },
{ label: t("wiki.newFolder"), run: () => newFolderForm(full) },
{
label: t("wiki.folderRename"),
run: () =>
openForm(t("wiki.folderRename"), t("wiki.folderPath"), full, (v) =>
cb.actions.moveFolder(full, v),
),
},
{ label: t("wiki.newDoc"), run: () => newDocForm(id) },
{ label: t("wiki.newFolder"), run: () => newFolderForm(id) },
]);
});
sum.append(arrow, typeIcon("node"), label);
@@ -290,12 +297,12 @@ export function initWikiView(container: HTMLElement, cb: WikiCallbacks): WikiVie
function docRow(doc: DocEntry): HTMLElement {
const row = document.createElement("button");
row.className = "wiki-tree-doc";
if (doc.relpath === selected) row.classList.add("active");
if (doc.id === selected) row.classList.add("active");
const label = document.createElement("span");
label.className = "wiki-tree-name";
label.textContent = doc.title;
row.append(typeIcon("doc"), label);
row.addEventListener("click", () => select(doc.relpath));
row.addEventListener("click", () => select(doc.id));
row.addEventListener("contextmenu", (e) => {
e.preventDefault();
e.stopPropagation();
@@ -303,19 +310,12 @@ export function initWikiView(container: HTMLElement, cb: WikiCallbacks): WikiVie
{
label: t("wiki.editDoc"),
run: () => {
select(doc.relpath);
select(doc.id);
editing = true;
renderMain();
},
},
{
label: t("wiki.renameDoc"),
run: () =>
openForm(t("wiki.renameDoc"), t("wiki.docName"), doc.name, (v) =>
cb.actions.rename(doc.relpath, v),
),
},
{ label: t("wiki.deleteDoc"), run: () => cb.actions.remove(doc.relpath) },
{ label: t("wiki.deleteDoc"), run: () => cb.actions.remove(doc.id) },
]);
});
return row;
@@ -362,13 +362,15 @@ export function initWikiView(container: HTMLElement, cb: WikiCallbacks): WikiVie
// ---------- Anlege-Formular (Ordner/Dokument) ----------
function newDocForm(folder: string) {
openForm(t("wiki.newDoc"), t("wiki.docName"), "", (v) => cb.actions.createDoc(folder, v));
function newDocForm(parent: string) {
openForm(t("wiki.newDoc"), t("wiki.docName"), "", (v) =>
cb.actions.createDoc(parent, v),
);
}
function newFolderForm(folder: string) {
openForm(t("wiki.newFolder"), t("wiki.folderPath"), folder ? `${folder}/` : "", (v) =>
cb.actions.createFolder(v),
function newFolderForm(parent: string) {
openForm(t("wiki.newFolder"), t("wiki.docName"), "", (v) =>
cb.actions.createFolder(parent, v),
);
}
@@ -430,7 +432,7 @@ export function initWikiView(container: HTMLElement, cb: WikiCallbacks): WikiVie
function goBack() {
while (history.length) {
const prev = history.pop()!;
const exists = isDoc(prev) ? !!findDoc(prev) : !!findNode(prev);
const exists = !!findDoc(prev) || !!nodeById(prev);
if (exists) {
select(prev, false);
return;
@@ -461,7 +463,7 @@ export function initWikiView(container: HTMLElement, cb: WikiCallbacks): WikiVie
// Klick auf den Titel bearbeitet ihn direkt (Frontmatter-Titel der
// Notiz); Enter übernimmt, Escape verwirft. Der technische Datei-/
// Ordnername bleibt davon unberührt — der sitzt im Baum-Kontextmenü.
const titleDoc = isDoc(selected) ? findDoc(selected) : findNode(selected)?.content;
const titleDoc = findDoc(selected) ?? nodeById(selected)?.content;
if (titleDoc && !editing) {
h.classList.add("editable");
h.title = t("wiki.titleEdit");
@@ -471,7 +473,7 @@ export function initWikiView(container: HTMLElement, cb: WikiCallbacks): WikiVie
input.value = title;
input.addEventListener("keydown", (e) => {
e.stopPropagation();
if (e.key === "Enter") cb.setTitle(titleDoc.relpath, input.value.trim());
if (e.key === "Enter") cb.setTitle(titleDoc.id, input.value.trim());
else if (e.key === "Escape") input.replaceWith(h);
});
input.addEventListener("blur", () => input.replaceWith(h));
@@ -528,9 +530,9 @@ export function initWikiView(container: HTMLElement, cb: WikiCallbacks): WikiVie
function noteBody(doc: DocEntry): HTMLElement {
const body = document.createElement("div");
body.className = "wiki-note-body";
cb.readDoc(doc.relpath).then(
cb.readDoc(doc.id).then(
(text) => {
if (selected !== doc.relpath && findNode(selected)?.content !== doc) return;
if (selected !== doc.id) return;
body.innerHTML = renderMarkdown(text);
linkWikiRefs(body, followWikiLink);
},
@@ -563,7 +565,7 @@ export function initWikiView(container: HTMLElement, cb: WikiCallbacks): WikiVie
preview.innerHTML = renderMarkdown(area.value);
linkWikiRefs(preview, followWikiLink);
};
cb.readDoc(doc.relpath).then(
cb.readDoc(doc.id).then(
(text) => {
area.value = text;
draw();
@@ -576,7 +578,7 @@ export function initWikiView(container: HTMLElement, cb: WikiCallbacks): WikiVie
);
area.addEventListener("input", draw);
const save = () => {
cb.writeDoc(doc.relpath, area.value).then(
cb.writeDoc(doc.id, area.value).then(
() => {
editing = false;
renderMain();
@@ -631,7 +633,7 @@ export function initWikiView(container: HTMLElement, cb: WikiCallbacks): WikiVie
editing = true;
renderMain();
});
return [edit, deleteAction(t("wiki.deleteDoc"), () => cb.actions.remove(doc.relpath))];
return [edit, deleteAction(t("wiki.deleteDoc"), () => cb.actions.remove(doc.id))];
}
/// Kindzeile im Dokument-Abschnitt: Titel links, Anlage-/Änderungsdatum
@@ -664,7 +666,7 @@ export function initWikiView(container: HTMLElement, cb: WikiCallbacks): WikiVie
const main = container.querySelector<HTMLElement>(".wiki-main")!;
main.textContent = "";
if (isDoc(selected)) {
if (isLeaf(selected)) {
const doc = findDoc(selected);
if (!doc) return;
if (editing) {
@@ -677,9 +679,9 @@ export function initWikiView(container: HTMLElement, cb: WikiCallbacks): WikiVie
return;
}
const node = findNode(selected);
const node = nodeById(selected);
if (!node) return;
const title = node.content?.title ?? (selected ? selected.split("/").pop()! : t("wiki.archive"));
const title = node.content?.title ?? t("wiki.archive");
const children = [...node.children.keys()].length + node.docs.length;
const meta: string[] = node.content ? metaParts(node.content) : [];
@@ -687,11 +689,7 @@ export function initWikiView(container: HTMLElement, cb: WikiCallbacks): WikiVie
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),
),
);
add.addEventListener("click", () => newDocForm(selected));
const actions: HTMLElement[] = node.content
? [add, ...docActions(node.content)]
: [add];
@@ -731,13 +729,15 @@ export function initWikiView(container: HTMLElement, cb: WikiCallbacks): WikiVie
});
list.append(caption);
for (const [name, child] of [...node.children].sort((a, b) => a[0].localeCompare(b[0]))) {
const full = selected ? `${selected}/${name}` : name;
const childId = child.content?.id ?? "";
list.append(
childRow(child.content?.title ?? name, child.content ?? null, () => select(full)),
childRow(child.content?.title ?? name, child.content ?? null, () =>
select(childId),
),
);
}
for (const doc of [...node.docs].sort((a, b) => a.title.localeCompare(b.title))) {
list.append(childRow(doc.title, doc, () => select(doc.relpath)));
list.append(childRow(doc.title, doc, () => select(doc.id)));
}
main.append(list);
}
@@ -746,15 +746,9 @@ export function initWikiView(container: HTMLElement, cb: WikiCallbacks): WikiVie
if (remember && target !== selected) history.push(selected);
editing = false;
selected = target;
// Elternordner eines gewählten Dokuments aufklappen, sonst bleibt die
// Hervorhebung unsichtbar.
if (isDoc(target)) {
const parts = docFolder(target).split("/").filter(Boolean);
let path = "";
for (const part of parts) {
path = path ? `${path}/${part}` : part;
closed.delete(path);
}
// Vorfahren aufklappen, sonst bleibt die Hervorhebung unsichtbar.
for (let p = parentOf(target); p?.content; p = parentOf(p.content.id)) {
closed.delete(p.content.id);
}
if (current) render();
}
@@ -802,7 +796,7 @@ export function initWikiView(container: HTMLElement, cb: WikiCallbacks): WikiVie
}
// Die gewählte Notiz kann nach Umbenennen/Löschen weg sein — dann
// zurück zur Wurzel.
const exists = isDoc(selected) ? !!findDoc(selected) : !!findNode(selected);
const exists = !!findDoc(selected) || !!nodeById(selected);
if (selected && !exists) {
selected = "";
}