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
+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());
}
}