Refinement-Runde Panel/Wiki: Bedienung, Themes, Archiv-Wahl, Frontend-Tests

- Slug transliteriert Umlaute/ß (fuer statt f-r); aktiver Tab mit Akzent +
  Unterstrich; kein aktiver Tab bei zugeklapptem Panel (mode.clear)
- Suche: Live-Update auch beim Löschen (<3 Zeichen: Hinweis), #tag-Filter in
  der Query, Treffer öffnen im Dokument-Tab (panel_load, ohne Frontmatter);
  Wiki-Sprung-Button im Dokument; Wiki-Fehler sichtbar als Toast
- Archiv-Formular am Archiv-Button (Ordner/Beschreibung/Schlagwörter);
  panel_archive_cmd nimmt Meta entgegen
- Wiki-Übersicht: Zuletzt-Sektion, Backlink-Zähler, Wurzel-Eyebrow
- Themes: Panel-Farben über CSS-Variablen, applyTheme leitet sie aus dem
  jeweiligen Theme ab (auch helle); gemeinsames themes.ts
- Fenster: Doppelklick maximiert; gelöstes Fenster merkt Größe/Position
  (Window-ACL: set-position/outer-position/inner-size ergänzt);
  open_panel_window async (GTK-Deadlock-Vorgabe); Panel-Fenster spiegelt
  JS-Fehler ins Dev-Log
- Archiv pro Projekt wähl-/abwählbar (Einstellungsdialog, setzt/entfernt
  Permissions); require_archive_home legt den Ordner an; ohne Archiv nur
  Befehle+Dokument im Panel
- Frontend-Tests: vitest + happy-dom, 27 Tests über Modus-, Such-, Wiki-,
  Editor- und Formular-Logik (npm test)
This commit is contained in:
marcus hinz
2026-07-19 17:08:05 +02:00
parent 81a663e020
commit 286513def8
27 changed files with 1653 additions and 190 deletions
+3
View File
@@ -12,6 +12,9 @@
"core:default",
"core:window:allow-start-dragging",
"core:window:allow-set-size",
"core:window:allow-set-position",
"core:window:allow-outer-position",
"core:window:allow-inner-size",
"core:window:allow-minimize",
"core:window:allow-toggle-maximize",
"core:window:allow-close",
+3
View File
@@ -308,11 +308,14 @@ pub(crate) fn terminal_builder(project: String) -> tauri::Builder<tauri::Wry> {
terminal::panel_set,
terminal::search_read,
terminal::search_run,
terminal::panel_load,
terminal::wiki_read,
terminal::wiki_open,
terminal::open_panel_window,
commands::panel_archive_dir_cmd,
commands::panel_archive_cmd,
commands::set_archive_home_cmd,
commands::clear_archive_home_cmd,
commands::archive_docs_cmd,
commands::reveal_path_cmd,
// Header im Terminal-Prozess: Projektliste, Icon und Pool-Name.
+30 -5
View File
@@ -342,12 +342,37 @@ pub(crate) fn panel_archive_dir_cmd(project: String) -> Option<String> {
project_archive_home(&project).map(|p| p.display().to_string())
}
/// Archiviert den aktuellen Panel-Entwurf. `dir` (aus dem Ordner-Dialog) setzt
/// das Archiv-Home zugleich; ohne `dir` muss es konfiguriert sein.
/// Setzt das Archiv-Home eines Projekts (Einstellungsdialog) — inklusive
/// Permissions-Eintrag in der Projekt-settings.json.
#[tauri::command]
pub(crate) fn panel_archive_cmd(project: String, dir: Option<String>) -> Result<String, String> {
archive_panel_content(&project, dir.as_deref(), &ArchiveMeta::default())
.map(|p| p.display().to_string())
pub(crate) fn set_archive_home_cmd(project: String, dir: String) -> Result<(), String> {
crate::domain::archive::set_project_archive_home(&project, &dir)
}
/// Wählt das Archiv ab (Einstellungsdialog): Config-Eintrag und Permissions
/// weg, der Ordner bleibt liegen.
#[tauri::command]
pub(crate) fn clear_archive_home_cmd(project: String) -> Result<(), String> {
crate::domain::archive::clear_project_archive_home(&project)
}
/// Archiviert den aktuellen Panel-Entwurf. `dir` (aus dem Ordner-Dialog) setzt
/// das Archiv-Home zugleich; ohne `dir` muss es konfiguriert sein. Ordner,
/// Beschreibung und Schlagwörter kommen aus dem Archiv-Formular im Panel.
#[tauri::command]
pub(crate) fn panel_archive_cmd(
project: String,
dir: Option<String>,
folder: Option<String>,
description: Option<String>,
tags: Option<Vec<String>>,
) -> Result<String, String> {
let meta = ArchiveMeta {
folder: folder.filter(|f| !f.trim().is_empty()),
description: description.filter(|d| !d.trim().is_empty()),
tags: tags.unwrap_or_default(),
};
archive_panel_content(&project, dir.as_deref(), &meta).map(|p| p.display().to_string())
}
/// Dokumentliste des Archiv-Index (frisch gescannt) fürs Panel.
+51 -5
View File
@@ -20,8 +20,11 @@ pub(crate) fn project_archive_home(project: &str) -> Option<PathBuf> {
}
/// Archiv-Home als Result — die gemeinsame Vorbedingung aller Archiv-Tools.
/// Legt den konfigurierten Ordner an, falls er (noch) nicht existiert.
pub(crate) fn require_archive_home(project: &str) -> Result<PathBuf, String> {
project_archive_home(project).ok_or_else(|| "kein Archiv-Ordner gesetzt".into())
let home = project_archive_home(project).ok_or("kein Archiv-Ordner gesetzt")?;
fs::create_dir_all(&home).map_err(|e| format!("{}: {e}", home.display()))?;
Ok(home)
}
/// Setzt das Archiv-Home: ai-control.json (~-relativ) und ein Eintrag in
@@ -87,6 +90,42 @@ fn add_archive_permission(paths: &Paths, project: &str, dir: &str) -> Result<(),
fs::write(&sp, raw + "\n").map_err(|e| format!("{}: {e}", sp.display()))
}
/// Wählt das Archiv ab: archiveHome aus der ai-control.json entfernen und die
/// beim Setzen eingetragenen Rechte (additionalDirectories + Edit-Allow) aus
/// der Projekt-settings.json zurücknehmen. Der Ordner selbst bleibt liegen.
pub(crate) fn clear_project_archive_home(project: &str) -> Result<(), String> {
let paths = Paths::real();
let mut cfg = read_project_config_in(&paths, project)?;
let Some(dir) = cfg.archive_home.take() else {
return Ok(());
};
write_project_config_in(&paths, project, &cfg)?;
remove_archive_permission(&paths, project, &dir)
}
/// Gegenstück zu add_archive_permission.
fn remove_archive_permission(paths: &Paths, project: &str, dir: &str) -> Result<(), String> {
let sp = settings_path(&project_dir(paths, project)?);
if !sp.is_file() {
return Ok(());
}
let mut v: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&sp).map_err(|e| format!("{}: {e}", sp.display()))?)
.map_err(|e| format!("{}: {e}", sp.display()))?;
let Some(perms) = v.get_mut("permissions").and_then(|p| p.as_object_mut()) else {
return Ok(());
};
if let Some(dirs) = perms.get_mut("additionalDirectories").and_then(|d| d.as_array_mut()) {
dirs.retain(|d| d.as_str() != Some(dir));
}
let edit = format!("Edit({dir}/**)");
if let Some(allow) = perms.get_mut("allow").and_then(|a| a.as_array_mut()) {
allow.retain(|p| p.as_str() != Some(&edit));
}
let raw = serde_json::to_string_pretty(&v).map_err(|e| e.to_string())?;
fs::write(&sp, raw + "\n").map_err(|e| format!("{}: {e}", sp.display()))
}
/// Metadaten beim Archivieren: Unterordner im Archiv-Home plus Frontmatter-Felder.
#[derive(Default)]
pub(crate) struct ArchiveMeta {
@@ -252,10 +291,15 @@ fn first_line(text: &str) -> String {
pub(crate) fn slugify(s: &str) -> String {
let mut out = String::new();
for c in s.chars() {
if c.is_ascii_alphanumeric() {
out.push(c.to_ascii_lowercase());
} else if !out.ends_with('-') {
out.push('-');
// Umlaute/ß transliterieren statt verschlucken („für's" → fuer-s).
match c {
'ä' | 'Ä' => out.push_str("ae"),
'ö' | 'Ö' => out.push_str("oe"),
'ü' | 'Ü' => out.push_str("ue"),
'ß' => out.push_str("ss"),
_ if c.is_ascii_alphanumeric() => out.push(c.to_ascii_lowercase()),
_ if !out.ends_with('-') => out.push('-'),
_ => {}
}
}
let out: String = out.trim_matches('-').chars().take(60).collect();
@@ -314,6 +358,8 @@ mod tests {
assert_eq!(slugify("ADR: Logging vereinheitlichen"), "adr-logging-vereinheitlichen");
assert_eq!(slugify(" "), "entwurf");
assert_eq!(slugify("###"), "entwurf");
assert_eq!(slugify("Ein Test für's Archiv"), "ein-test-fuer-s-archiv");
assert_eq!(slugify("Größe ÄÖÜ"), "groesse-aeoeue");
}
#[test]
+24
View File
@@ -134,6 +134,9 @@ 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>,
}
@@ -158,6 +161,8 @@ pub(crate) struct WikiDocEntry {
pub(crate) tags: Vec<String>,
/// Archivierungsdatum aus dem Zeitstempel-Stem (`YYYY-MM-DD`).
pub(crate) date: Option<String>,
/// Zahl der Dokumente, die per Wikilink hierher zeigen.
pub(crate) backlinks: usize,
}
pub(crate) fn archive_page(home: &Path, tag: Option<&str>) -> Result<WikiPage, String> {
@@ -181,6 +186,17 @@ 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(),
@@ -190,6 +206,7 @@ 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)| {
@@ -214,6 +231,7 @@ fn doc_entry(doc: &Doc) -> WikiDocEntry {
description: doc.description.clone(),
tags: doc.tags.clone(),
date,
backlinks: doc.backlinks.len(),
}
}
@@ -342,6 +360,12 @@ mod tests {
assert_eq!(adr.date.as_deref(), Some("2026-07-19"));
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);
}
#[test]
+16 -4
View File
@@ -286,16 +286,26 @@ pub fn search_read(project: String) -> String {
std::fs::read_to_string(search_file(&project)).unwrap_or_default()
}
/// 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`.
#[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())
}
/// Suche aus dem Panel-Suchfeld: läuft wie das MCP-Tool search_archive und
/// schreibt die Treffer-Datei; der Watcher zieht sie in die Ansicht (beide
/// Fenster).
#[tauri::command]
pub fn search_run(project: String, query: String) -> Result<(), String> {
pub fn search_run(project: String, query: String, tag: Option<String>) -> Result<(), String> {
let home = crate::domain::archive::require_archive_home(&project)?;
let hits = crate::domain::archive_search::search(&home, &query, None, 20)?;
let hits = crate::domain::archive_search::search(&home, &query, tag.as_deref(), 20)?;
let payload = serde_json::json!({
"query": query,
"tag": null,
"tag": tag,
"home": home.display().to_string(),
"hits": hits,
});
@@ -329,8 +339,10 @@ pub fn wiki_open(project: String, name: String) -> Result<(), String> {
/// Löst das Panel in ein eigenes Fenster ab. Existiert es schon, kommt es nach
/// vorn. `panel-detached` blendet das angedockte Panel im Terminal-Fenster aus.
/// 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 fn open_panel_window(app: AppHandle, project: String) -> Result<(), String> {
pub async fn open_panel_window(app: AppHandle, project: String) -> Result<(), String> {
let label = format!("panel-{project}");
if let Some(w) = app.get_webview_window(&label) {
let _ = w.set_focus();