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:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user