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
+125 -14
View File
@@ -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(())
}