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:
@@ -277,6 +277,7 @@ pub(crate) fn main_builder() -> tauri::Builder<tauri::Wry> {
|
||||
pub(crate) fn terminal_builder(project: String) -> tauri::Builder<tauri::Wry> {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_clipboard_manager::init())
|
||||
.manage(terminal::Terminals::default())
|
||||
.setup(move |app| {
|
||||
let cfg = terminal_config(&project)?;
|
||||
@@ -302,10 +303,17 @@ pub(crate) fn terminal_builder(project: String) -> tauri::Builder<tauri::Wry> {
|
||||
terminal::term_write,
|
||||
terminal::term_resize,
|
||||
terminal::panel_read,
|
||||
terminal::commands_read,
|
||||
terminal::commands_delete,
|
||||
terminal::panel_set,
|
||||
terminal::search_read,
|
||||
terminal::search_run,
|
||||
terminal::wiki_read,
|
||||
terminal::wiki_open,
|
||||
terminal::open_panel_window,
|
||||
commands::panel_archive_dir_cmd,
|
||||
commands::panel_archive_cmd,
|
||||
commands::archive_docs_cmd,
|
||||
commands::reveal_path_cmd,
|
||||
// Header im Terminal-Prozess: Projektliste, Icon und Pool-Name.
|
||||
commands::list_projects,
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::domain::archive::{archive_panel_content, project_archive_dir};
|
||||
use crate::domain::archive::{
|
||||
archive_panel_content, project_archive_home, require_archive_home, ArchiveMeta,
|
||||
};
|
||||
use crate::domain::credentials::keychain_service;
|
||||
use crate::domain::paths::Paths;
|
||||
use crate::domain::pool::{
|
||||
@@ -333,18 +335,28 @@ pub(crate) fn usage_stats(days: u32) -> Result<Vec<UsageRow>, String> {
|
||||
|
||||
// ---------- Panel-Archiv ----------
|
||||
|
||||
/// Konfigurierter Archiv-Ordner des Projekts (für den Panel-Button: entscheidet,
|
||||
/// Konfiguriertes Archiv-Home des Projekts (für den Panel-Button: entscheidet,
|
||||
/// ob der Ordner-Dialog nötig ist).
|
||||
#[tauri::command]
|
||||
pub(crate) fn panel_archive_dir_cmd(project: String) -> Option<String> {
|
||||
project_archive_dir(&project).map(|p| p.display().to_string())
|
||||
project_archive_home(&project).map(|p| p.display().to_string())
|
||||
}
|
||||
|
||||
/// Archiviert den aktuellen Panel-Entwurf. `dir` (aus dem Ordner-Dialog) setzt
|
||||
/// den Archiv-Ordner zugleich; ohne `dir` muss er konfiguriert sein.
|
||||
/// das Archiv-Home zugleich; ohne `dir` muss es konfiguriert sein.
|
||||
#[tauri::command]
|
||||
pub(crate) fn panel_archive_cmd(project: String, dir: Option<String>) -> Result<String, String> {
|
||||
archive_panel_content(&project, dir.as_deref()).map(|p| p.display().to_string())
|
||||
archive_panel_content(&project, dir.as_deref(), &ArchiveMeta::default())
|
||||
.map(|p| p.display().to_string())
|
||||
}
|
||||
|
||||
/// Dokumentliste des Archiv-Index (frisch gescannt) fürs Panel.
|
||||
#[tauri::command]
|
||||
pub(crate) fn archive_docs_cmd(
|
||||
project: String,
|
||||
) -> Result<Vec<crate::domain::archive_index::Doc>, String> {
|
||||
let home = require_archive_home(&project)?;
|
||||
crate::domain::archive_index::scan_archive(&home)
|
||||
}
|
||||
|
||||
/// Zeigt einen Pfad im Dateimanager (nach dem Archivieren „im Finder zeigen").
|
||||
|
||||
+180
-15
@@ -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");
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 == "~" {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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, ®)?;
|
||||
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()));
|
||||
}
|
||||
|
||||
+298
-37
@@ -66,35 +66,142 @@ fn handle(method: &str, req: &Value) -> Option<Value> {
|
||||
Commit-Message, Textbaustein) im ai-control-Panel neben dem Terminal \
|
||||
ab, wo er mit der Maus selektierbar und über einen Button kopierbar \
|
||||
ist. Statt den Text zusätzlich als Fließtext auszugeben, dieses Tool \
|
||||
aufrufen und im Chat nur kurz bestätigen.",
|
||||
aufrufen und im Chat nur kurz bestätigen. Für eine bestehende Datei \
|
||||
IMMER `path` statt `text` übergeben — der Server liest die Datei \
|
||||
selbst von der Platte, ohne dass ihr Inhalt generiert werden muss.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"text": {
|
||||
"type": "string",
|
||||
"description": "Vollständiger Entwurf als Markdown-Rohtext.",
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description":
|
||||
"Statt `text`: Pfad einer vorhandenen Datei, deren Inhalt ins \
|
||||
Panel geladen wird. Genau eines von beiden angeben.",
|
||||
}
|
||||
},
|
||||
"required": ["text"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "write_commands",
|
||||
"description":
|
||||
"Listet Shell-Befehle, die der Nutzer ausführen soll, als kopierbare \
|
||||
Kacheln im ai-control-Panel. IMMER nutzen, wenn ein Befehl für den \
|
||||
Nutzer bestimmt ist — statt ihn als Codeblock in den Chat zu \
|
||||
schreiben; im Chat nur kurz einordnen. Die Befehle werden an die \
|
||||
Befehls-History der Session angehängt (flüchtig, startet mit \
|
||||
jeder Session leer).",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"commands": {
|
||||
"type": "array",
|
||||
"description": "Befehle in Ausführungsreihenfolge.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"cmd": {
|
||||
"type": "string",
|
||||
"description": "Der Befehl, exakt so ausführbar.",
|
||||
},
|
||||
"note": {
|
||||
"type": "string",
|
||||
"description": "Optionale Kurznotiz, was der Befehl tut.",
|
||||
}
|
||||
},
|
||||
"required": ["cmd"],
|
||||
},
|
||||
}
|
||||
},
|
||||
"required": ["commands"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "show_commands",
|
||||
"description":
|
||||
"Zeigt die Befehls-History im ai-control-Panel (Kachel-Ansicht mit \
|
||||
allen bisher ausgegebenen Befehlen), ohne etwas anzuhängen. Nutzen, \
|
||||
wenn der Nutzer die Befehlsliste sehen will („zeig die Befehle“, \
|
||||
„zeige die Befehlsliste“).",
|
||||
"inputSchema": { "type": "object", "properties": {} },
|
||||
},
|
||||
{
|
||||
"name": "archive_panel",
|
||||
"description":
|
||||
"Archiviert den aktuell im Panel liegenden Entwurf dauerhaft als \
|
||||
Markdown-Datei im Archiv-Ordner des Projekts. Auf Wunsch nutzen \
|
||||
(Nutzer sagt etwa „archiviere das“). Ist kein Ordner konfiguriert, \
|
||||
den Zielpfad im \
|
||||
Argument `dir` mitgeben (der Nutzer nennt ihn); ohne `dir` und ohne \
|
||||
konfigurierten Ordner meldet das Tool das zurück.",
|
||||
Markdown-Datei im Archiv-Home des Projekts. Auf Wunsch nutzen \
|
||||
(Nutzer sagt etwa „archiviere das“). Beim Archivieren `folder`, \
|
||||
`description` und `tags` mitgeben — einmalige Kuratierung im Moment \
|
||||
des Archivierens, landet im Frontmatter. Ist kein Archiv-Home \
|
||||
konfiguriert, den Zielpfad im Argument `dir` mitgeben (der Nutzer \
|
||||
nennt ihn); ohne `dir` und ohne konfiguriertes Home meldet das Tool \
|
||||
das zurück.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"dir": {
|
||||
"type": "string",
|
||||
"description":
|
||||
"Optionaler Archiv-Ordner. Wird gesetzt und für künftige \
|
||||
Archivierungen gemerkt.",
|
||||
"Optionales Archiv-Home (absoluter Pfad). Wird gesetzt und für \
|
||||
künftige Archivierungen gemerkt.",
|
||||
},
|
||||
"folder": {
|
||||
"type": "string",
|
||||
"description":
|
||||
"Optionaler Unterordner im Archiv-Home, relativ (z. B. \
|
||||
`konzepte/panel`). Wird angelegt.",
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "Einzeiler zum Inhalt fürs Frontmatter.",
|
||||
},
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "Schlagwörter fürs Frontmatter (kurze Slugs).",
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "show_archive",
|
||||
"description":
|
||||
"Zeigt die Archiv-Übersicht des Projekts als Wiki-Seite im Panel: \
|
||||
Dokumente nach Ordnern gruppiert, mit Beschreibungen und \
|
||||
klickbaren Schlagwort-Links. Mit `tag` stattdessen die Seite eines \
|
||||
Schlagworts. Nutzen, wenn der Nutzer das Archiv sehen will \
|
||||
(„zeig das Archiv“).",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"tag": {
|
||||
"type": "string",
|
||||
"description": "Optional: Seite dieses Schlagworts statt der Übersicht.",
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "search_archive",
|
||||
"description":
|
||||
"Volltext-Suche über das Panel-Archiv des Projekts (FTS5-Syntax: \
|
||||
Wörter, \"Phrasen\", Präfix*). Die Treffer erscheinen als Kacheln \
|
||||
im Panel; das Tool liefert sie zusätzlich mit Pfad und Snippet \
|
||||
zurück. Nutzen, wenn der Nutzer im Archiv suchen will („such im \
|
||||
Archiv nach …“).",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "Suchanfrage (FTS5-Syntax).",
|
||||
},
|
||||
"tag": {
|
||||
"type": "string",
|
||||
"description": "Optional: auf ein Schlagwort einengen.",
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -110,44 +217,198 @@ fn handle(method: &str, req: &Value) -> Option<Value> {
|
||||
fn call_tool(req: &Value) -> Value {
|
||||
match req["params"]["name"].as_str().unwrap_or("") {
|
||||
"write_panel" => call_write(req),
|
||||
"write_commands" => call_write_commands(req),
|
||||
"show_commands" => call_show_commands(),
|
||||
"archive_panel" => call_archive(req),
|
||||
other => json!({
|
||||
"content": [{ "type": "text", "text": format!("Unbekanntes Tool: {other}") }],
|
||||
"isError": true,
|
||||
}),
|
||||
"search_archive" => call_search(req),
|
||||
"show_archive" => call_show_archive(req),
|
||||
other => err(format!("Unbekanntes Tool: {other}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Erfolgs-Antwort eines Tool-Aufrufs (ein Text-Content).
|
||||
fn ok(text: String) -> Value {
|
||||
json!({ "content": [{ "type": "text", "text": text }] })
|
||||
}
|
||||
|
||||
/// Fehler-Antwort eines Tool-Aufrufs.
|
||||
fn err(text: String) -> Value {
|
||||
json!({ "content": [{ "type": "text", "text": text }], "isError": true })
|
||||
}
|
||||
|
||||
/// Pfad aus der PTY-Umgebung; fehlt die Variable, läuft das Terminal
|
||||
/// außerhalb von ai-control.
|
||||
fn env_path(var: &str) -> Result<String, Value> {
|
||||
std::env::var(var)
|
||||
.map_err(|_| err(format!("{var} nicht gesetzt (Terminal außerhalb ai-control).")))
|
||||
}
|
||||
|
||||
/// Schreibt Text in die Panel-Datei aus AI_CONTROL_PANEL.
|
||||
fn write_panel_text(text: &str) -> Result<(), Value> {
|
||||
let path = env_path("AI_CONTROL_PANEL")?;
|
||||
std::fs::write(&path, text).map_err(|e| err(format!("Panel-Datei nicht schreibbar: {e}")))
|
||||
}
|
||||
|
||||
fn call_write(req: &Value) -> Value {
|
||||
let text = req["params"]["arguments"]["text"].as_str().unwrap_or("");
|
||||
match std::env::var("AI_CONTROL_PANEL") {
|
||||
Ok(path) => match std::fs::write(&path, text) {
|
||||
Ok(()) => json!({ "content": [{ "type": "text", "text": "Entwurf ins Panel geschrieben." }] }),
|
||||
Err(e) => json!({
|
||||
"content": [{ "type": "text", "text": format!("Panel-Datei nicht schreibbar: {e}") }],
|
||||
"isError": true,
|
||||
}),
|
||||
let args = &req["params"]["arguments"];
|
||||
// `path` lädt eine vorhandene Datei serverseitig — der schnelle Weg, ohne
|
||||
// dass das Modell den Inhalt Token für Token als `text` generieren muss.
|
||||
let (text, ok_msg) = match args["path"].as_str() {
|
||||
Some(src) => match std::fs::read_to_string(src) {
|
||||
Ok(content) => (content, "Datei ins Panel geladen."),
|
||||
Err(e) => return err(format!("Datei {src} nicht lesbar: {e}")),
|
||||
},
|
||||
Err(_) => json!({
|
||||
"content": [{ "type": "text", "text": "AI_CONTROL_PANEL nicht gesetzt (Terminal außerhalb ai-control)." }],
|
||||
"isError": true,
|
||||
}),
|
||||
None => (
|
||||
args["text"].as_str().unwrap_or("").to_string(),
|
||||
"Entwurf ins Panel geschrieben.",
|
||||
),
|
||||
};
|
||||
match write_panel_text(&text) {
|
||||
Ok(()) => ok(ok_msg.into()),
|
||||
Err(e) => e,
|
||||
}
|
||||
}
|
||||
|
||||
/// Merkt, ob dieser Server-Prozess (= eine claude-Session) schon einen
|
||||
/// Session-Marker in die History geschrieben hat.
|
||||
static SESSION_MARKED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
||||
|
||||
fn call_write_commands(req: &Value) -> Value {
|
||||
let commands = req["params"]["arguments"]["commands"].clone();
|
||||
let count = commands.as_array().map(Vec::len).unwrap_or(0);
|
||||
let path = match env_path("AI_CONTROL_COMMANDS") {
|
||||
Ok(path) => path,
|
||||
Err(e) => return e,
|
||||
};
|
||||
let ts = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
// Ein Record pro Aufruf; der erste Aufruf der Session bekommt einen
|
||||
// Marker-Record davor (Session-Trenner in der Kachel-Ansicht).
|
||||
let mut out = String::new();
|
||||
if !SESSION_MARKED.swap(true, std::sync::atomic::Ordering::SeqCst) {
|
||||
out.push_str(&json!({ "ts": ts, "session": true }).to_string());
|
||||
out.push('\n');
|
||||
}
|
||||
out.push_str(&json!({ "ts": ts, "commands": commands }).to_string());
|
||||
out.push('\n');
|
||||
let res = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&path)
|
||||
.and_then(|mut f| std::io::Write::write_all(&mut f, out.as_bytes()));
|
||||
match res {
|
||||
Ok(()) => ok(format!("{count} Befehl(e) als Kacheln im Panel abgelegt.")),
|
||||
Err(e) => err(format!("Command-History nicht schreibbar: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Öffnet die Kachel-Ansicht, ohne der History etwas hinzuzufügen: mtime der
|
||||
/// History-Datei anfassen genügt — der Watcher im Terminal-Prozess meldet sie
|
||||
/// als `commands-update`, das Panel schaltet auf „Befehle“ um.
|
||||
fn call_show_commands() -> Value {
|
||||
let path = match env_path("AI_CONTROL_COMMANDS") {
|
||||
Ok(path) => path,
|
||||
Err(e) => return e,
|
||||
};
|
||||
let res = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&path)
|
||||
.and_then(|f| f.set_modified(std::time::SystemTime::now()));
|
||||
match res {
|
||||
Ok(()) => ok("Befehls-History im Panel geöffnet.".into()),
|
||||
Err(e) => err(format!("Command-History nicht erreichbar: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn call_archive(req: &Value) -> Value {
|
||||
let project = std::env::var("AI_CONTROL_PROJECT").unwrap_or_default();
|
||||
let dir = req["params"]["arguments"]["dir"].as_str();
|
||||
match crate::domain::archive::archive_panel_content(&project, dir) {
|
||||
Ok(path) => json!({
|
||||
"content": [{ "type": "text", "text": format!("Archiviert: {}", path.display()) }],
|
||||
}),
|
||||
Err(e) => json!({
|
||||
"content": [{
|
||||
"type": "text",
|
||||
"text": format!("Nicht archiviert: {e}. Zielordner im Argument `dir` angeben oder im Panel wählen."),
|
||||
}],
|
||||
"isError": true,
|
||||
}),
|
||||
let args = &req["params"]["arguments"];
|
||||
let dir = args["dir"].as_str();
|
||||
let meta = crate::domain::archive::ArchiveMeta {
|
||||
folder: args["folder"].as_str().map(str::to_string),
|
||||
description: args["description"].as_str().map(str::to_string),
|
||||
tags: args["tags"]
|
||||
.as_array()
|
||||
.map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect())
|
||||
.unwrap_or_default(),
|
||||
};
|
||||
match crate::domain::archive::archive_panel_content(&project, dir, &meta) {
|
||||
Ok(path) => ok(format!("Archiviert: {}", path.display())),
|
||||
Err(e) => err(format!(
|
||||
"Nicht archiviert: {e}. Zielordner im Argument `dir` angeben oder im Panel wählen."
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Archiv-Übersicht bzw. Schlagwort-Seite generieren und in den Wiki-Puffer
|
||||
/// schreiben — der Watcher zieht sie als Wiki-Ansicht ins Panel.
|
||||
fn call_show_archive(req: &Value) -> Value {
|
||||
let project = std::env::var("AI_CONTROL_PROJECT").unwrap_or_default();
|
||||
let home = match crate::domain::archive::require_archive_home(&project) {
|
||||
Ok(home) => home,
|
||||
Err(e) => return err(e),
|
||||
};
|
||||
let tag = req["params"]["arguments"]["tag"].as_str();
|
||||
let page = match crate::domain::archive_index::archive_page(&home, tag) {
|
||||
Ok(page) => page,
|
||||
Err(e) => return err(e),
|
||||
};
|
||||
let path = match env_path("AI_CONTROL_WIKI") {
|
||||
Ok(path) => path,
|
||||
Err(e) => return e,
|
||||
};
|
||||
let json = match serde_json::to_string(&page) {
|
||||
Ok(json) => json,
|
||||
Err(e) => return err(e.to_string()),
|
||||
};
|
||||
match std::fs::write(&path, json) {
|
||||
Ok(()) => ok(match tag {
|
||||
Some(t) => format!("Schlagwort-Seite #{t} im Panel."),
|
||||
None => "Archiv-Übersicht im Panel.".to_string(),
|
||||
}),
|
||||
Err(e) => err(format!("Wiki-Datei nicht schreibbar: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Volltext-Suche übers Archiv: Treffer als JSON in die Suchtreffer-Datei
|
||||
/// (der Watcher zieht sie als Kacheln ins Panel) und als Text zurück an claude.
|
||||
fn call_search(req: &Value) -> Value {
|
||||
let project = std::env::var("AI_CONTROL_PROJECT").unwrap_or_default();
|
||||
let home = match crate::domain::archive::require_archive_home(&project) {
|
||||
Ok(home) => home,
|
||||
Err(e) => return err(e),
|
||||
};
|
||||
let args = &req["params"]["arguments"];
|
||||
let query = args["query"].as_str().unwrap_or("");
|
||||
let tag = args["tag"].as_str();
|
||||
let hits = match crate::domain::archive_search::search(&home, query, tag, 20) {
|
||||
Ok(hits) => hits,
|
||||
Err(e) => return err(format!("Suche fehlgeschlagen: {e}")),
|
||||
};
|
||||
let search_path = match env_path("AI_CONTROL_SEARCH") {
|
||||
Ok(path) => path,
|
||||
Err(e) => return e,
|
||||
};
|
||||
let payload = json!({
|
||||
"query": query,
|
||||
"tag": tag,
|
||||
"home": home.display().to_string(),
|
||||
"hits": hits,
|
||||
});
|
||||
if let Err(e) = std::fs::write(&search_path, payload.to_string()) {
|
||||
return err(format!("Suchtreffer-Datei nicht schreibbar: {e}"));
|
||||
}
|
||||
let list: Vec<String> = hits
|
||||
.iter()
|
||||
.map(|h| format!("- {} — {} ({})", h.relpath, h.title, h.snippet))
|
||||
.collect();
|
||||
ok(format!(
|
||||
"{} Treffer, als Kacheln im Panel. Archiv: {}\n{}",
|
||||
hits.len(),
|
||||
home.display(),
|
||||
list.join("\n")
|
||||
))
|
||||
}
|
||||
|
||||
+125
-14
@@ -5,7 +5,7 @@ use std::io::{Read, Write};
|
||||
use std::sync::Mutex;
|
||||
use tauri::{AppHandle, Emitter, Manager, State, WebviewUrl, WebviewWindowBuilder};
|
||||
|
||||
use crate::domain::paths::{panel_file, Paths};
|
||||
use crate::domain::paths::{commands_file, panel_file, search_file, wiki_file, Paths};
|
||||
use crate::domain::project::{project_pool_dir, terminal_config, TerminalConfig};
|
||||
use crate::domain::registry::project_dir;
|
||||
use crate::domain::settings::claude_command;
|
||||
@@ -116,10 +116,28 @@ pub fn term_start(
|
||||
}
|
||||
let _ = std::fs::write(&panel_path, "");
|
||||
|
||||
// Command-Kanal: History-Datei (JSONL) leeren — die Befehls-History ist
|
||||
// flüchtig und gilt nur für diese Session; write_commands hängt Records an.
|
||||
let commands_path = commands_file(&project);
|
||||
let _ = std::fs::write(&commands_path, "");
|
||||
|
||||
// Such-Kanal: Treffer-Datei leeren — search_archive schreibt den jeweils
|
||||
// letzten Suchlauf hinein, der Watcher zieht ihn als Kacheln ins Panel.
|
||||
let search_path = search_file(&project);
|
||||
let _ = std::fs::write(&search_path, "");
|
||||
|
||||
// Wiki-Kanal: Puffer leeren — show_archive und wiki_open schreiben die
|
||||
// jeweils aktuelle Wiki-Seite (JSON) hinein.
|
||||
let wiki_path = wiki_file(&project);
|
||||
let _ = std::fs::write(&wiki_path, "");
|
||||
|
||||
let mut cmd = crate::platform::shell_command(&claude_command(&paths));
|
||||
cmd.cwd(&cwd);
|
||||
cmd.env("TERM", "xterm-256color");
|
||||
cmd.env("AI_CONTROL_PANEL", &panel_path);
|
||||
cmd.env("AI_CONTROL_COMMANDS", &commands_path);
|
||||
cmd.env("AI_CONTROL_SEARCH", &search_path);
|
||||
cmd.env("AI_CONTROL_WIKI", &wiki_path);
|
||||
cmd.env("AI_CONTROL_PROJECT", &project);
|
||||
if let Some(pool_dir) = project_pool_dir(&project)? {
|
||||
cmd.env("CLAUDE_CONFIG_DIR", pool_dir);
|
||||
@@ -177,7 +195,14 @@ pub fn term_start(
|
||||
let _ = app.emit_to(&label, "pty-exit", ());
|
||||
});
|
||||
|
||||
spawn_panel_watcher(window.app_handle().clone(), panel_path);
|
||||
spawn_file_watcher(window.app_handle().clone(), panel_path, "panel-update");
|
||||
spawn_file_watcher(
|
||||
window.app_handle().clone(),
|
||||
commands_path,
|
||||
"commands-update",
|
||||
);
|
||||
spawn_file_watcher(window.app_handle().clone(), search_path, "search-update");
|
||||
spawn_file_watcher(window.app_handle().clone(), wiki_path, "wiki-update");
|
||||
|
||||
terminals.0.lock().unwrap().insert(
|
||||
window.label().to_string(),
|
||||
@@ -186,12 +211,12 @@ pub fn term_start(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Beobachtet die Panel-Datei des Projekts und schickt neuen Inhalt als
|
||||
/// `panel-update` an alle Fenster dieses Terminal-Prozesses (angedocktes Panel
|
||||
/// und ein evtl. abgelöstes Panel-Fenster). Pollt per mtime — kein notify-Crate,
|
||||
/// die Datei ändert sich nur, wenn ein Entwurf geschrieben wird. Der Thread
|
||||
/// endet mit dem Prozess (Fenster zu).
|
||||
fn spawn_panel_watcher(app: AppHandle, path: std::path::PathBuf) {
|
||||
/// Beobachtet eine Panel-Datei des Projekts (Entwurf oder Command-History)
|
||||
/// und schickt neuen Inhalt unter `event` an alle Fenster dieses
|
||||
/// Terminal-Prozesses (angedocktes Panel und ein evtl. abgelöstes
|
||||
/// Panel-Fenster). Pollt per mtime — kein notify-Crate, die Datei ändert sich
|
||||
/// nur, wenn geschrieben wird. Der Thread endet mit dem Prozess (Fenster zu).
|
||||
fn spawn_file_watcher(app: AppHandle, path: std::path::PathBuf, event: &'static str) {
|
||||
std::thread::spawn(move || {
|
||||
let mtime = || std::fs::metadata(&path).and_then(|m| m.modified()).ok();
|
||||
let mut last = mtime();
|
||||
@@ -201,7 +226,7 @@ fn spawn_panel_watcher(app: AppHandle, path: std::path::PathBuf) {
|
||||
if now != last {
|
||||
last = now;
|
||||
if let Ok(content) = std::fs::read_to_string(&path) {
|
||||
let _ = app.emit("panel-update", content);
|
||||
let _ = app.emit(event, content);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -214,6 +239,40 @@ pub fn panel_read(project: String) -> String {
|
||||
std::fs::read_to_string(panel_file(&project)).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Aktuelle Command-History (JSONL; Erstbefüllung der Kachel-Ansicht).
|
||||
#[tauri::command]
|
||||
pub fn commands_read(project: String) -> String {
|
||||
std::fs::read_to_string(commands_file(&project)).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Entfernt einen Befehl aus der Command-History (Löschen einer Kachel im
|
||||
/// Panel). `line` ist der Index der nicht-leeren JSONL-Zeile, `entry` der
|
||||
/// Index im commands-Array des Records; ein leer gewordener Record fällt mit
|
||||
/// weg. Der Watcher meldet den neuen Stand als `commands-update`.
|
||||
#[tauri::command]
|
||||
pub fn commands_delete(project: String, line: usize, entry: usize) -> Result<(), String> {
|
||||
let path = commands_file(&project);
|
||||
let text = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
|
||||
let mut records: Vec<serde_json::Value> = text
|
||||
.lines()
|
||||
.filter(|l| !l.trim().is_empty())
|
||||
.map(|l| serde_json::from_str(l).map_err(|e| e.to_string()))
|
||||
.collect::<Result<_, _>>()?;
|
||||
let cmds = records[line]["commands"]
|
||||
.as_array_mut()
|
||||
.ok_or("Record ohne commands")?;
|
||||
cmds.remove(entry);
|
||||
if cmds.is_empty() {
|
||||
records.remove(line);
|
||||
}
|
||||
let mut out = String::new();
|
||||
for rec in &records {
|
||||
out.push_str(&rec.to_string());
|
||||
out.push('\n');
|
||||
}
|
||||
std::fs::write(&path, out).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Schreibt den Panel-Inhalt (Titel-Edit im Panel). Der Watcher meldet die
|
||||
/// Änderung als `panel-update` an alle Fenster.
|
||||
#[tauri::command]
|
||||
@@ -221,6 +280,53 @@ pub fn panel_set(project: String, text: String) -> Result<(), String> {
|
||||
std::fs::write(panel_file(&project), text).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Letzter Suchlauf (JSON; Erstbefüllung der Treffer-Ansicht).
|
||||
#[tauri::command]
|
||||
pub fn search_read(project: String) -> String {
|
||||
std::fs::read_to_string(search_file(&project)).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
let home = crate::domain::archive::require_archive_home(&project)?;
|
||||
let hits = crate::domain::archive_search::search(&home, &query, None, 20)?;
|
||||
let payload = serde_json::json!({
|
||||
"query": query,
|
||||
"tag": null,
|
||||
"home": home.display().to_string(),
|
||||
"hits": hits,
|
||||
});
|
||||
std::fs::write(search_file(&project), payload.to_string()).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Aktueller Wiki-Puffer (JSON; Erstbefüllung der Wiki-Ansicht).
|
||||
#[tauri::command]
|
||||
pub fn wiki_read(project: String) -> String {
|
||||
std::fs::read_to_string(wiki_file(&project)).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Öffnet ein Wiki-Ziel (Klick auf einen `[[…]]`-Link oder Suchtreffer):
|
||||
/// `tag:x` → Schlagwort-Seite, `tag:` → Archiv-Übersicht, sonst
|
||||
/// Dokument-Auflösung über den Index. Der `tag:`-Namensraum ist damit dort
|
||||
/// interpretiert, wo archive_page ihn erzeugt — nicht im Frontend. Schreibt
|
||||
/// den Wiki-Puffer; der Watcher meldet ihn als `wiki-update`.
|
||||
#[tauri::command]
|
||||
pub fn wiki_open(project: String, name: String) -> Result<(), String> {
|
||||
let home = crate::domain::archive::require_archive_home(&project)?;
|
||||
let json = match name.strip_prefix("tag:") {
|
||||
Some(tag) => serde_json::to_string(&crate::domain::archive_index::archive_page(
|
||||
&home,
|
||||
(!tag.is_empty()).then_some(tag),
|
||||
)?),
|
||||
None => serde_json::to_string(&crate::domain::archive_index::wiki_doc(&home, &name)?),
|
||||
}
|
||||
.map_err(|e| e.to_string())?;
|
||||
std::fs::write(wiki_file(&project), json).map_err(|e| e.to_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.
|
||||
#[tauri::command]
|
||||
@@ -233,16 +339,21 @@ pub fn open_panel_window(app: AppHandle, project: String) -> Result<(), String>
|
||||
let cfg = terminal_config(&project)?;
|
||||
let (r, g, b) = theme_background(cfg.theme.as_deref().unwrap_or_default());
|
||||
let title = cfg.title.as_deref().unwrap_or(project.as_str());
|
||||
WebviewWindowBuilder::new(
|
||||
let builder = WebviewWindowBuilder::new(
|
||||
&app,
|
||||
&label,
|
||||
WebviewUrl::App(format!("panel.html?project={project}").into()),
|
||||
)
|
||||
.title(format!("{title} — Entwurf"))
|
||||
.title(format!("{title} — Dokument"))
|
||||
.inner_size(480.0, 640.0)
|
||||
.background_color(tauri::window::Color(r, g, b, 0xff))
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
.background_color(tauri::window::Color(r, g, b, 0xff));
|
||||
|
||||
// Linux/GNOME: keine GTK-Deko — eigene Kopfleiste in panel.html, wie beim
|
||||
// Terminal-Fenster.
|
||||
#[cfg(target_os = "linux")]
|
||||
let builder = builder.decorations(false);
|
||||
|
||||
builder.build().map_err(|e| e.to_string())?;
|
||||
app.emit("panel-detached", ()).map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user