Archiv-Wiki im Panel: vier Tabs, FTS5-Suche, strukturierbares Archiv

- Archiv-Home pro Projekt (archiveHome in ai-control.json); archive_panel
  mit Ordner, Beschreibung und Schlagwörtern (Frontmatter + Zeitstempel-Stem)
- Archiv-Index (archive_index.rs): Scan mit Frontmatter, Wikilinks,
  Backlinks; Wiki-Seiten als strukturierte Daten (Übersicht/Tag-Seiten)
- FTS5-Suche (archive_search.rs, rusqlite bundled): in-memory-Index pro
  Anfrage; MCP search_archive und Panel-Suchfeld (search_run) teilen die Engine
- Panel: vier Puffer/Ansichten (Dokument, Befehle, Suche, Wiki) mit Tabs im
  Fenster-Header; gemeinsame Verdrahtung panel-wiring.ts, Kachel-CSS,
  Wiki-View mit Tag-Chips, Ordner-Sektionen und Backlinks
- Archivieren speichert offene Bearbeitung (flush); Tab-Wechsel speichert
  statt zu blockieren; Fehler sichtbar als Panel-Toast
- Abgelöstes Fenster: rahmenlos (Linux), eigene Kopfleiste mit Tabs und
  Fensterknöpfen, Theme-Kopplung über gemeinsames themes.ts
- MCP-Tools show_commands, show_archive; Befehls-History mit Kachel-Löschen
This commit is contained in:
marcus hinz
2026-07-19 15:25:40 +02:00
parent d4d455fa08
commit 994d5070f0
29 changed files with 3471 additions and 571 deletions
+180 -15
View File
@@ -11,18 +11,23 @@ use crate::domain::project::{
};
use crate::domain::registry::project_dir;
/// Konfigurierter Archiv-Ordner des Projekts (ai-control.json: archiveDir),
/// Konfiguriertes Archiv-Home des Projekts (ai-control.json: archiveHome),
/// Home-expandiert; None, wenn nicht gesetzt.
pub(crate) fn project_archive_dir(project: &str) -> Option<PathBuf> {
pub(crate) fn project_archive_home(project: &str) -> Option<PathBuf> {
let paths = Paths::real();
let dir = read_project_config_in(&paths, project).ok()?.archive_dir?;
let dir = read_project_config_in(&paths, project).ok()?.archive_home?;
Some(expand_home(&paths, &dir))
}
/// Setzt den Archiv-Ordner: ai-control.json (~-relativ) und ein Eintrag in
/// Archiv-Home als Result — die gemeinsame Vorbedingung aller Archiv-Tools.
pub(crate) fn require_archive_home(project: &str) -> Result<PathBuf, String> {
project_archive_home(project).ok_or_else(|| "kein Archiv-Ordner gesetzt".into())
}
/// Setzt das Archiv-Home: ai-control.json (~-relativ) und ein Eintrag in
/// permissions.additionalDirectories + Edit der Projekt-settings.json, damit
/// claude das Archiv später lesen/scannen darf.
pub(crate) fn set_project_archive_dir(project: &str, dir: &str) -> Result<(), String> {
pub(crate) fn set_project_archive_home(project: &str, dir: &str) -> Result<(), String> {
let paths = Paths::real();
let expanded = expand_home(&paths, dir);
if !expanded.is_absolute() {
@@ -39,7 +44,7 @@ pub(crate) fn set_project_archive_dir(project: &str, dir: &str) -> Result<(), St
fs::create_dir_all(&expanded).map_err(|e| format!("{}: {e}", expanded.display()))?;
let contracted = contract_home(&paths, &expanded);
let mut cfg = read_project_config_in(&paths, project)?;
cfg.archive_dir = Some(contracted.clone());
cfg.archive_home = Some(contracted.clone());
write_project_config_in(&paths, project, &cfg)?;
add_archive_permission(&paths, project, &contracted)
}
@@ -82,17 +87,33 @@ fn add_archive_permission(paths: &Paths, project: &str, dir: &str) -> Result<(),
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 {
/// Unterordner relativ zum Archiv-Home (wird angelegt).
pub(crate) folder: Option<String>,
/// Einzeiler fürs Frontmatter.
pub(crate) description: Option<String>,
/// Schlagwörter fürs Frontmatter.
pub(crate) tags: Vec<String>,
}
/// Archiviert den aktuellen Panel-Inhalt des Projekts als Markdown-Datei mit
/// Frontmatter im Archiv-Ordner. `dir_override` setzt den Ordner zugleich
/// Frontmatter im Archiv-Home. `dir_override` setzt das Home zugleich
/// (Terminal-Fallback ohne Dialog). Liefert den geschriebenen Pfad.
pub(crate) fn archive_panel_content(
project: &str,
dir_override: Option<&str>,
meta: &ArchiveMeta,
) -> Result<PathBuf, String> {
if let Some(d) = dir_override {
set_project_archive_dir(project, d)?;
set_project_archive_home(project, d)?;
}
let dir = project_archive_dir(project).ok_or("kein Archiv-Ordner gesetzt")?;
let home = require_archive_home(project)?;
let dir = match &meta.folder {
Some(f) => home.join(check_folder(f)?),
None => home,
};
let text = fs::read_to_string(panel_file(project)).unwrap_or_default();
if text.trim().is_empty() {
return Err("Panel ist leer — nichts zu archivieren".into());
@@ -106,15 +127,110 @@ pub(crate) fn archive_panel_content(
let (stamp, iso) = utc_stamp(secs);
let title = first_line(&text);
let path = dir.join(format!("{stamp}-{}.md", slugify(&title)));
let doc = format!(
"---\ntitle: \"{}\"\nproject: {project}\ncreated: {iso}\nsource: ai-control\n---\n\n{}\n",
title.replace('"', "'"),
text.trim_end(),
);
let doc = format!("{}{}\n", frontmatter(&title, project, &iso, meta), text.trim_end());
fs::write(&path, doc).map_err(|e| format!("{}: {e}", path.display()))?;
Ok(path)
}
/// Unterordner-Pfad: relativ, nur normale Komponenten (kein `..`, kein Root).
fn check_folder(folder: &str) -> Result<&std::path::Path, String> {
let p = std::path::Path::new(folder);
let normal = p
.components()
.all(|c| matches!(c, std::path::Component::Normal(_)));
if p.components().next().is_some() && normal {
Ok(p)
} else {
Err(format!("Unterordner muss ein relativer Pfad ohne '..' sein: {folder}"))
}
}
/// YAML-Frontmatter des Archiv-Dokuments inklusive optionaler
/// description/tags aus den Metadaten. Gegenstück: `parse_frontmatter` unten —
/// Schreiber und Leser des Formats leben bewusst im selben Modul.
fn frontmatter(title: &str, project: &str, iso: &str, meta: &ArchiveMeta) -> String {
let mut fm = format!(
"---\ntitle: \"{}\"\nproject: {project}\ncreated: {iso}\nsource: ai-control\n",
title.replace('"', "'"),
);
if let Some(d) = &meta.description {
fm.push_str(&format!("description: \"{}\"\n", d.replace('"', "'")));
}
if !meta.tags.is_empty() {
let quoted: Vec<String> =
meta.tags.iter().map(|t| format!("\"{}\"", t.replace('"', "'"))).collect();
fm.push_str(&format!("tags: [{}]\n", quoted.join(", ")));
}
fm.push_str("---\n\n");
fm
}
/// Minimaler Frontmatter-Parser für die selbst geschriebenen Dokumente:
/// `key: value`-Zeilen zwischen den beiden `---`-Markern, Anführungszeichen
/// um Werte werden entfernt.
pub(crate) fn parse_frontmatter(text: &str) -> std::collections::HashMap<String, String> {
let mut map = std::collections::HashMap::new();
let Some(rest) = text.strip_prefix("---\n") else {
return map;
};
let Some(end) = rest.find("\n---") else {
return map;
};
for line in rest[..end].lines() {
let Some((key, value)) = line.split_once(':') else {
continue;
};
map.insert(key.trim().to_string(), unquote(value.trim()).to_string());
}
map
}
/// Dokument-Rumpf ohne den Frontmatter-Block; führende Leerzeilen entfernt.
pub(crate) fn strip_frontmatter(text: &str) -> &str {
let Some(rest) = text.strip_prefix("---\n") else {
return text;
};
match rest.find("\n---\n") {
Some(end) => rest[end + 5..].trim_start_matches('\n'),
None => text,
}
}
fn unquote(s: &str) -> &str {
s.strip_prefix('"')
.and_then(|s| s.strip_suffix('"'))
.unwrap_or(s)
}
/// Inline-Liste `["a", "b"]` bzw. `[a, b]` in Einzel-Tags zerlegen.
pub(crate) fn parse_tag_list(raw: &str) -> Vec<String> {
raw
.trim_start_matches('[')
.trim_end_matches(']')
.split(',')
.map(|t| unquote(t.trim()).to_string())
.filter(|t| !t.is_empty())
.collect()
}
/// Datei-Stem ohne führenden `YYYY-MM-DD_HHMM-`-Zeitstempel — das Gegenstück
/// zum Dateinamen aus `utc_stamp` + `slugify`.
pub(crate) fn strip_stamp(stem: &str) -> &str {
let bytes = stem.as_bytes();
let stamped = bytes.len() > 16
&& bytes[..16].iter().enumerate().all(|(i, b)| match i {
4 | 7 => *b == b'-',
10 => *b == b'_',
15 => *b == b'-',
_ => b.is_ascii_digit(),
});
if stamped {
&stem[16..]
} else {
stem
}
}
/// Titelzeile: erste Überschrift (## …) oder sonst erste nicht-leere Zeile.
fn first_line(text: &str) -> String {
let mut fallback: Option<&str> = None;
@@ -133,7 +249,7 @@ fn first_line(text: &str) -> String {
}
/// Dateinamen-tauglicher Slug (ascii, klein, Bindestriche, max. 60 Zeichen).
fn slugify(s: &str) -> String {
pub(crate) fn slugify(s: &str) -> String {
let mut out = String::new();
for c in s.chars() {
if c.is_ascii_alphanumeric() {
@@ -200,6 +316,55 @@ mod tests {
assert_eq!(slugify("###"), "entwurf");
}
#[test]
fn stamp_und_strip_roundtrip() {
let (stamp, _) = utc_stamp(20_645u64 * 86400);
assert_eq!(strip_stamp(&format!("{stamp}-adr-logging")), "adr-logging");
assert_eq!(strip_stamp("adr-logging"), "adr-logging");
assert_eq!(strip_stamp("2026-07-19-adr"), "2026-07-19-adr");
}
#[test]
fn frontmatter_und_parser_roundtrip() {
let meta = ArchiveMeta {
folder: None,
description: Some("Kurz".into()),
tags: vec!["adr".into(), "infra".into()],
};
let fm = frontmatter("Titel", "proj", "2026-07-19T10:00:00Z", &meta);
let map = parse_frontmatter(&fm);
assert_eq!(map.get("title").map(String::as_str), Some("Titel"));
assert_eq!(map.get("description").map(String::as_str), Some("Kurz"));
assert_eq!(parse_tag_list(&map["tags"]), vec!["adr", "infra"]);
}
#[test]
fn check_folder_relativ_ohne_punktpunkt() {
assert!(check_folder("konzepte/panel").is_ok());
assert!(check_folder("../raus").is_err());
assert!(check_folder("a/../b").is_err());
assert!(check_folder("/absolut").is_err());
assert!(check_folder("").is_err());
}
#[test]
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"));
assert!(!fm.contains("description:"));
assert!(!fm.contains("tags:"));
let voll = ArchiveMeta {
folder: None,
description: Some("Kurz \"zitiert\"".into()),
tags: vec!["archiv".into(), "wiki".into()],
};
let fm = frontmatter("Titel", "proj", "2026-07-19T10:00:00Z", &voll);
assert!(fm.contains("description: \"Kurz 'zitiert'\"\n"));
assert!(fm.contains("tags: [\"archiv\", \"wiki\"]\n"));
}
#[test]
fn first_line_ueberschrift_oder_erste_zeile() {
assert_eq!(first_line("\n\n# Titel\n\nText"), "Titel");
+376
View File
@@ -0,0 +1,376 @@
//! Archiv-Index: reproduzierbare Sicht über den Archiv-Baum eines Projekts —
//! pro Markdown-Dokument Name, Frontmatter-Metadaten und Wikilinks; Backlinks
//! fallen beim Scan als Nebenprodukt ab. Der Index ist abgeleitete Information
//! und wird bei Bedarf frisch aus dem Baum gebaut (nichts davon wird gesynct).
//! Das Dateiformat (Frontmatter, Zeitstempel-Stem) definiert archive.rs.
use std::collections::HashMap;
use std::fs;
use std::path::Path;
use crate::domain::archive::{
parse_frontmatter, parse_tag_list, slugify, strip_frontmatter, strip_stamp,
};
#[derive(serde::Serialize, Clone)]
pub(crate) struct Doc {
/// Pfad relativ zum Archiv-Home.
pub(crate) relpath: String,
/// Wikilink-Name: Datei-Stem ohne führenden Zeitstempel.
pub(crate) name: String,
/// Frontmatter-Titel, sonst der Name.
pub(crate) title: String,
pub(crate) description: Option<String>,
pub(crate) tags: Vec<String>,
/// Wikilink-Ziele im Dokumenttext (`[[ziel]]`/`[[ziel|label]]`, nur das Ziel).
pub(crate) links: Vec<String>,
/// Namen der Dokumente, die per Wikilink hierher zeigen.
pub(crate) backlinks: Vec<String>,
}
/// Scannt den Archiv-Baum rekursiv über alle Markdown-Dateien; versteckte
/// Einträge (Punkt-Präfix) bleiben außen vor. Reihenfolge: relpath sortiert;
/// Backlinks sind nach dem Scan gefüllt.
pub(crate) fn scan_archive(home: &Path) -> Result<Vec<Doc>, String> {
Ok(scan_with_bodies(home)?.into_iter().map(|(doc, _)| doc).collect())
}
/// Wie `scan_archive`, liefert zu jedem Dokument den bereits gelesenen
/// Volltext mit — die Suche indexiert damit ohne zweite Lesung pro Datei.
pub(crate) fn scan_with_bodies(home: &Path) -> Result<Vec<(Doc, String)>, String> {
let mut docs = Vec::new();
walk(home, home, &mut docs)?;
docs.sort_by(|a, b| a.0.relpath.cmp(&b.0.relpath));
fill_backlinks(&mut docs);
Ok(docs)
}
/// Backlinks in einem Durchlauf: Slug → Doc-Index einmal aufbauen, jeden Link
/// genau einmal auflösen und zu Backlinks invertieren.
fn fill_backlinks(docs: &mut [(Doc, String)]) {
let mut lookup: HashMap<String, usize> = HashMap::new();
for (i, (doc, _)) in docs.iter().enumerate() {
let stem = Path::new(&doc.relpath).file_stem().unwrap_or_default().to_string_lossy();
for key in [slugify(&doc.name), slugify(&doc.title), slugify(&stem)] {
lookup.entry(key).or_insert(i);
}
}
let mut back: Vec<Vec<String>> = vec![Vec::new(); docs.len()];
for (i, (doc, _)) in docs.iter().enumerate() {
let mut targets: std::collections::BTreeSet<usize> = docs[i]
.0
.links
.iter()
.filter_map(|l| lookup.get(&slugify(l)).copied())
.collect();
targets.remove(&i);
for t in targets {
back[t].push(doc.name.clone());
}
}
for ((doc, _), b) in docs.iter_mut().zip(back) {
doc.backlinks = b;
}
}
fn walk(home: &Path, dir: &Path, docs: &mut Vec<(Doc, String)>) -> Result<(), String> {
let entries = fs::read_dir(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
for entry in entries {
let entry = entry.map_err(|e| format!("{}: {e}", dir.display()))?;
let path = entry.path();
let file_name = entry.file_name();
let fname = file_name.to_string_lossy();
if fname.starts_with('.') {
continue;
}
if path.is_dir() {
walk(home, &path, docs)?;
} else if fname.ends_with(".md") {
docs.push(read_doc(home, &path)?);
}
}
Ok(())
}
fn read_doc(home: &Path, path: &Path) -> Result<(Doc, String), String> {
let text = fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?;
let stem = path.file_stem().unwrap_or_default().to_string_lossy().to_string();
let name = strip_stamp(&stem).to_string();
let fm = parse_frontmatter(&text);
let relpath = path
.strip_prefix(home)
.map_err(|e| format!("{}: {e}", path.display()))?
.display()
.to_string();
let doc = Doc {
relpath,
title: fm.get("title").unwrap_or(&name).clone(),
description: fm.get("description").cloned(),
tags: fm.get("tags").map(|t| parse_tag_list(t)).unwrap_or_default(),
links: wikilinks(&text),
backlinks: Vec::new(),
name,
};
Ok((doc, text))
}
/// Slug-Vergleich eines Wikilink-Ziels gegen Name, Titel und Datei-Stem.
fn matches(doc: &Doc, want: &str) -> bool {
slugify(&doc.name) == want
|| slugify(&doc.title) == want
|| Path::new(&doc.relpath)
.file_stem()
.is_some_and(|s| slugify(&s.to_string_lossy()) == want)
}
/// Übersichts- bzw. Schlagwort-Seite als strukturierte Wiki-Daten: Dokumente
/// nach Ordnern gruppiert (neueste zuerst), Schlagwort-Leiste mit Zählern.
/// Das Panel rendert daraus die Wiki-Ansicht; `kind` unterscheidet im
/// Wiki-Puffer Seite und Dokument.
#[derive(serde::Serialize)]
pub(crate) struct WikiPage {
pub(crate) kind: &'static str,
pub(crate) home: String,
pub(crate) tag: Option<String>,
pub(crate) total: usize,
pub(crate) tags: Vec<TagCount>,
pub(crate) folders: Vec<WikiFolder>,
}
#[derive(serde::Serialize)]
pub(crate) struct TagCount {
pub(crate) name: String,
pub(crate) count: usize,
}
#[derive(serde::Serialize)]
pub(crate) struct WikiFolder {
/// Ordner relativ zum Archiv-Home; leer für die Wurzel.
pub(crate) name: String,
pub(crate) docs: Vec<WikiDocEntry>,
}
#[derive(serde::Serialize)]
pub(crate) struct WikiDocEntry {
pub(crate) name: String,
pub(crate) title: String,
pub(crate) description: Option<String>,
pub(crate) tags: Vec<String>,
/// Archivierungsdatum aus dem Zeitstempel-Stem (`YYYY-MM-DD`).
pub(crate) date: Option<String>,
}
pub(crate) fn archive_page(home: &Path, tag: Option<&str>) -> Result<WikiPage, String> {
let docs = scan_archive(home)?;
let mut counts: std::collections::BTreeMap<&str, usize> = std::collections::BTreeMap::new();
for doc in &docs {
for t in &doc.tags {
*counts.entry(t).or_default() += 1;
}
}
let selected: Vec<&Doc> = match tag {
Some(t) => docs.iter().filter(|d| d.tags.iter().any(|x| x == t)).collect(),
None => docs.iter().collect(),
};
let mut folders: std::collections::BTreeMap<String, Vec<&Doc>> =
std::collections::BTreeMap::new();
for doc in &selected {
let folder = Path::new(&doc.relpath)
.parent()
.map(|p| p.display().to_string())
.unwrap_or_default();
folders.entry(folder).or_default().push(doc);
}
Ok(WikiPage {
kind: "page",
home: home.display().to_string(),
tag: tag.map(str::to_string),
total: selected.len(),
tags: counts
.into_iter()
.map(|(name, count)| TagCount { name: name.to_string(), count })
.collect(),
folders: folders
.into_iter()
.map(|(name, mut list)| {
// Zeitstempel-Stems sortieren chronologisch — absteigend = neueste oben.
list.sort_by(|a, b| b.relpath.cmp(&a.relpath));
WikiFolder { name, docs: list.into_iter().map(doc_entry).collect() }
})
.collect(),
})
}
fn doc_entry(doc: &Doc) -> WikiDocEntry {
let stem = Path::new(&doc.relpath)
.file_stem()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let date = (stem != doc.name).then(|| stem[..10].to_string());
WikiDocEntry {
name: doc.name.clone(),
title: doc.title.clone(),
description: doc.description.clone(),
tags: doc.tags.clone(),
date,
}
}
/// Ein Archiv-Dokument als Wiki-Ansicht: Markdown-Rumpf ohne Frontmatter plus
/// Metadaten und Backlinks aus dem Scan.
#[derive(serde::Serialize)]
pub(crate) struct WikiDocPage {
pub(crate) kind: &'static str,
pub(crate) home: String,
pub(crate) relpath: String,
pub(crate) name: String,
pub(crate) title: String,
pub(crate) tags: Vec<String>,
pub(crate) backlinks: Vec<String>,
pub(crate) markdown: String,
}
pub(crate) fn wiki_doc(home: &Path, target: &str) -> Result<WikiDocPage, String> {
let pairs = scan_with_bodies(home)?;
let want = slugify(target);
let (doc, body) = pairs
.iter()
.find(|(d, _)| matches(d, &want))
.ok_or_else(|| format!("kein Archiv-Dokument zu „{target}“ gefunden"))?;
Ok(WikiDocPage {
kind: "doc",
home: home.display().to_string(),
relpath: doc.relpath.clone(),
name: doc.name.clone(),
title: doc.title.clone(),
tags: doc.tags.clone(),
backlinks: doc.backlinks.clone(),
markdown: strip_frontmatter(body).to_string(),
})
}
/// Alle `[[ziel]]`-Vorkommen im Text, in Dokumentreihenfolge; bei
/// `[[ziel|label]]` zählt nur das Ziel.
fn wikilinks(text: &str) -> Vec<String> {
let mut out = Vec::new();
let mut rest = text;
while let Some(start) = rest.find("[[") {
rest = &rest[start + 2..];
let Some(end) = rest.find("]]") else {
break;
};
let inner = &rest[..end];
let target = inner.split('|').next().unwrap_or(inner).trim();
if !target.is_empty() {
out.push(target.to_string());
}
rest = &rest[end + 2..];
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::testutil::tmp_paths;
use std::path::PathBuf;
fn write(home: &Path, rel: &str, content: &str) {
let path = home.join(rel);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, content).unwrap();
}
fn archiv() -> PathBuf {
let home = tmp_paths().home.join("archiv");
fs::create_dir_all(&home).unwrap();
write(
&home,
"2026-07-19_1000-adr-logging.md",
"---\ntitle: \"ADR Logging\"\ndescription: \"Logging vereinheitlichen\"\ntags: [\"adr\", \"infra\"]\n---\n\nSiehe [[notiz-deploy|die Deploy-Notiz]].\n",
);
write(
&home,
"konzepte/2026-07-19_1005-notiz-deploy.md",
"---\ntitle: \"Notiz Deploy\"\n---\n\nText ohne Links.\n",
);
write(&home, ".versteckt/ignoriert.md", "unsichtbar");
home
}
#[test]
fn scan_liest_baum_frontmatter_und_links() {
let home = archiv();
let docs = scan_archive(&home).unwrap();
assert_eq!(docs.len(), 2);
let adr = &docs[0];
assert_eq!(adr.name, "adr-logging");
assert_eq!(adr.title, "ADR Logging");
assert_eq!(adr.description.as_deref(), Some("Logging vereinheitlichen"));
assert_eq!(adr.tags, vec!["adr", "infra"]);
assert_eq!(adr.links, vec!["notiz-deploy"]);
assert_eq!(docs[1].relpath, "konzepte/2026-07-19_1005-notiz-deploy.md");
}
#[test]
fn backlinks_aus_wikilinks() {
let home = archiv();
let docs = scan_archive(&home).unwrap();
let deploy = docs.iter().find(|d| d.name == "notiz-deploy").unwrap();
assert_eq!(deploy.backlinks, vec!["adr-logging"]);
let adr = docs.iter().find(|d| d.name == "adr-logging").unwrap();
assert!(adr.backlinks.is_empty());
}
#[test]
fn startseite_gruppiert_und_zaehlt() {
let home = archiv();
let page = archive_page(&home, None).unwrap();
assert_eq!(page.kind, "page");
assert_eq!(page.tag, None);
assert_eq!(page.total, 2);
let tags: Vec<(&str, usize)> =
page.tags.iter().map(|t| (t.name.as_str(), t.count)).collect();
assert_eq!(tags, vec![("adr", 1), ("infra", 1)]);
assert_eq!(page.folders.len(), 2);
assert_eq!(page.folders[0].name, "");
let adr = &page.folders[0].docs[0];
assert_eq!(adr.name, "adr-logging");
assert_eq!(adr.title, "ADR Logging");
assert_eq!(adr.description.as_deref(), Some("Logging vereinheitlichen"));
assert_eq!(adr.date.as_deref(), Some("2026-07-19"));
assert_eq!(page.folders[1].name, "konzepte");
assert_eq!(page.folders[1].docs[0].name, "notiz-deploy");
}
#[test]
fn tag_seite_filtert() {
let home = archiv();
let page = archive_page(&home, Some("adr")).unwrap();
assert_eq!(page.tag.as_deref(), Some("adr"));
assert_eq!(page.total, 1);
assert_eq!(page.folders.len(), 1);
assert_eq!(page.folders[0].docs[0].name, "adr-logging");
// Die Schlagwort-Leiste bleibt vollständig — sie ist die Navigation.
assert_eq!(page.tags.len(), 2);
}
#[test]
fn wiki_doc_mit_rumpf_und_backlinks() {
let home = archiv();
// Auflösung über Titel; Name und Stem gehen über dieselben Slug-Vergleiche.
let doc = wiki_doc(&home, "Notiz Deploy").unwrap();
assert_eq!(doc.kind, "doc");
assert_eq!(doc.name, "notiz-deploy");
assert_eq!(
wiki_doc(&home, "2026-07-19_1000-adr-logging").unwrap().name,
"adr-logging"
);
assert_eq!(doc.title, "Notiz Deploy");
assert_eq!(doc.relpath, "konzepte/2026-07-19_1005-notiz-deploy.md");
assert_eq!(doc.backlinks, vec!["adr-logging"]);
assert_eq!(doc.markdown, "Text ohne Links.\n");
assert!(wiki_doc(&home, "fehlt").is_err());
}
}
+139
View File
@@ -0,0 +1,139 @@
//! Volltext-Suche übers Panel-Archiv: SQLite-FTS5-Index, bei jeder Anfrage
//! frisch in-memory aus dem Archiv-Baum gebaut. Bei den Archiv-Größen dieser
//! App ist der Aufbau Millisekundensache; damit gibt es keinen persistierten
//! Index, keine Staleness und nichts, was gesynct werden könnte. Die
//! Tool-Schnittstelle bleibt engine-unabhängig.
use std::path::Path;
use rusqlite::Connection;
use crate::domain::archive_index::scan_with_bodies;
#[derive(serde::Serialize)]
pub(crate) struct Hit {
/// Pfad relativ zum Archiv-Home.
pub(crate) relpath: String,
pub(crate) title: String,
/// Textausschnitt um die Fundstelle, Treffer in `**…**`.
pub(crate) snippet: String,
}
/// Durchsucht das Archiv unter `home`. `query` ist FTS5-Syntax (Wörter,
/// "Phrasen", Präfix*); `tag` engt auf ein Schlagwort ein. Treffer nach
/// BM25-Rang, höchstens `limit`.
pub(crate) fn search(
home: &Path,
query: &str,
tag: Option<&str>,
limit: usize,
) -> Result<Vec<Hit>, String> {
let conn = build_index(home)?;
let expr = match_expr(query, tag)?;
let mut stmt = conn
.prepare(
"SELECT relpath, title, snippet(docs, 5, '**', '**', ' … ', 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)? })
})
.map_err(|e| format!("Suchausdruck „{query}“: {e}"))?;
rows.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())
}
/// MATCH-Ausdruck aus Query und optionalem Tag-Filter.
fn match_expr(query: &str, tag: Option<&str>) -> Result<String, String> {
let q = query.trim();
let t = tag.map(|t| format!("tags:\"{}\"", t.replace('"', "")));
match (q.is_empty(), t) {
(false, Some(t)) => Ok(format!("({q}) AND {t}")),
(false, None) => Ok(q.to_string()),
(true, Some(t)) => Ok(t),
(true, None) => Err("leere Suchanfrage".into()),
}
}
/// Baut den FTS5-Index in-memory aus dem Archiv-Baum.
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)",
)
.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)")
.map_err(|e| e.to_string())?;
for (doc, body) in &docs {
insert
.execute(rusqlite::params![
doc.relpath,
doc.name,
doc.title,
doc.description.as_deref().unwrap_or(""),
doc.tags.join(" "),
body,
])
.map_err(|e| e.to_string())?;
}
drop(insert);
Ok(conn)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::testutil::tmp_paths;
use std::fs;
fn archiv() -> std::path::PathBuf {
let home = tmp_paths().home.join("archiv");
fs::create_dir_all(home.join("konzepte")).unwrap();
fs::write(
home.join("2026-07-19_1000-adr-logging.md"),
"---\ntitle: \"ADR Logging\"\ntags: [\"adr\", \"infra\"]\n---\n\nStrukturiertes Logging mit tracing vereinheitlichen.\n",
)
.unwrap();
fs::write(
home.join("konzepte/2026-07-19_1005-notiz-deploy.md"),
"---\ntitle: \"Notiz Deploy\"\ntags: [\"infra\"]\n---\n\nDeploy braucht lsregister auf macOS.\n",
)
.unwrap();
home
}
#[test]
fn findet_nach_inhaltswort() {
let home = archiv();
let hits = search(&home, "tracing", None, 10).unwrap();
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].title, "ADR Logging");
assert!(hits[0].snippet.contains("**tracing**"));
}
#[test]
fn tag_filter_engt_ein() {
let home = archiv();
assert_eq!(search(&home, "", Some("infra"), 10).unwrap().len(), 2);
assert_eq!(search(&home, "", Some("adr"), 10).unwrap().len(), 1);
let hits = search(&home, "deploy", Some("adr"), 10).unwrap();
assert!(hits.is_empty());
}
#[test]
fn phrase_und_praefix() {
let home = archiv();
assert_eq!(search(&home, "\"Strukturiertes Logging\"", None, 10).unwrap().len(), 1);
assert_eq!(search(&home, "lsregist*", None, 10).unwrap().len(), 1);
}
#[test]
fn leere_anfrage_scheitert() {
let home = archiv();
assert!(search(&home, " ", None, 10).is_err());
}
}
+2
View File
@@ -3,6 +3,8 @@
//! laufen ausschließlich über crate::platform.
pub(crate) mod archive;
pub(crate) mod archive_index;
pub(crate) mod archive_search;
pub(crate) mod credentials;
pub(crate) mod paths;
pub(crate) mod pool;
+27
View File
@@ -56,6 +56,33 @@ pub(crate) fn panel_file(project: &str) -> PathBuf {
Paths::real().panels_dir().join(format!("{project}.md"))
}
/// Command-History eines Projekts (JSONL, anhängend — flüchtig, wird beim
/// Session-Start geleert). Der Pfad landet als AI_CONTROL_COMMANDS in der
/// PTY-Umgebung.
pub(crate) fn commands_file(project: &str) -> PathBuf {
Paths::real()
.panels_dir()
.join(format!("{project}.commands.jsonl"))
}
/// Suchtreffer-Datei eines Projekts (JSON, letzter search_archive-Aufruf —
/// flüchtig, wird beim Session-Start geleert). Der Pfad landet als
/// AI_CONTROL_SEARCH in der PTY-Umgebung.
pub(crate) fn search_file(project: &str) -> PathBuf {
Paths::real()
.panels_dir()
.join(format!("{project}.search.json"))
}
/// Wiki-Puffer eines Projekts (JSON, jeweils letzte Wiki-Seite bzw. letztes
/// geöffnetes Dokument — flüchtig, wird beim Session-Start geleert). Der Pfad
/// landet als AI_CONTROL_WIKI in der PTY-Umgebung.
pub(crate) fn wiki_file(project: &str) -> PathBuf {
Paths::real()
.panels_dir()
.join(format!("{project}.wiki.json"))
}
/// "~" bzw. "~/x" relativ zum Home auflösen; alles andere unverändert.
pub(crate) fn expand_home(paths: &Paths, p: &str) -> PathBuf {
if p == "~" {
+13 -9
View File
@@ -151,7 +151,7 @@ pub(crate) fn init_pool_config(
let mut settings = serde_json::json!({
"promptSuggestionEnabled": false,
"awaySummaryEnabled": false,
"permissions": { "allow": [PANEL_WRITE_PERMISSION] },
"permissions": { "allow": PANEL_PERMISSIONS },
});
let base = settings.as_object_mut().unwrap();
if let Some(obj) = extra.as_object() {
@@ -180,9 +180,12 @@ const PANEL_SKILL: &str = include_str!("../../resources/panel-skill.md");
/// („text panel"). Bestimmt den Tool-Namespace `mcp__<key>__<tool>`.
const PANEL_MCP_SERVER: &str = "text-panel";
/// Freigabe für das MCP-Tool `write_panel` — damit der Aufruf ohne Rückfrage
/// läuft. Namensschema: `mcp__<server>__<tool>`.
const PANEL_WRITE_PERMISSION: &str = "mcp__text-panel__write_panel";
/// Freigaben für die Panel-MCP-Tools — damit die Aufrufe ohne Rückfrage
/// laufen. Namensschema: `mcp__<server>__<tool>`.
const PANEL_PERMISSIONS: [&str; 2] = [
"mcp__text-panel__write_panel",
"mcp__text-panel__write_commands",
];
/// Schreibt/aktualisiert die Panel-Skill-Datei in einem Pool (überschreibt eine
/// evtl. ältere, tee-basierte Fassung).
@@ -223,13 +226,14 @@ fn ensure_panel_permission(pool_dir: &std::path::Path) {
else {
return;
};
let before = allow.len();
let before = allow.clone();
allow.retain(|e| !e.as_str().is_some_and(|s| STALE_PANEL_PERMISSIONS.contains(&s)));
let has = allow.iter().any(|e| e.as_str() == Some(PANEL_WRITE_PERMISSION));
if !has {
allow.push(serde_json::json!(PANEL_WRITE_PERMISSION));
for perm in PANEL_PERMISSIONS {
if !allow.iter().any(|e| e.as_str() == Some(perm)) {
allow.push(serde_json::json!(perm));
}
}
if has && allow.len() == before {
if *allow == before {
return; // nichts geändert
}
if let Ok(out) = serde_json::to_string_pretty(&v) {
+8 -5
View File
@@ -29,13 +29,16 @@ pub(crate) struct ProjectConfig {
pub(crate) pool: Option<String>,
#[serde(default, skip_serializing_if = "TerminalConfig::is_empty")]
pub(crate) terminal: TerminalConfig,
/// Zielordner fürs Archivieren von Panel-Entwürfen (~-relativ gespeichert).
/// Archiv-Home des Projekts: Zielordner fürs Archivieren von Panel-Entwürfen
/// (~-relativ gespeichert). Liest den früheren Key `archiveDir` weiterhin ein,
/// geschrieben wird nur noch `archiveHome`.
#[serde(
default,
rename = "archiveDir",
rename = "archiveHome",
alias = "archiveDir",
skip_serializing_if = "Option::is_none"
)]
pub(crate) archive_dir: Option<String>,
pub(crate) archive_home: Option<String>,
}
#[derive(Serialize, Deserialize, Default, Clone)]
@@ -223,7 +226,7 @@ pub(crate) fn create_project_full_in(
reg.insert(name.to_string(), dir.clone());
save_registry(paths, &reg)?;
let cfg = ProjectConfig { pool: pool.map(str::to_string), terminal, archive_dir: None };
let cfg = ProjectConfig { pool: pool.map(str::to_string), terminal, archive_home: None };
if cfg.pool.is_some() || !cfg.terminal.is_empty() {
write_project_config_in(paths, name, &cfg)?;
}
@@ -431,7 +434,7 @@ pub(crate) fn assign_pool_in(paths: &Paths, project: &str, pool: &str) -> Result
pub(crate) fn unassign_pool_in(paths: &Paths, project: &str) -> Result<(), String> {
let mut cfg = read_project_config_in(paths, project)?;
cfg.pool = None;
if cfg.terminal.is_empty() && cfg.archive_dir.is_none() {
if cfg.terminal.is_empty() && cfg.archive_home.is_none() {
let cfg_path = project_config_path(paths, project)?;
return fs::remove_file(&cfg_path).map_err(|e| format!("{}: {e}", cfg_path.display()));
}