use base64::{engine::general_purpose::STANDARD, Engine}; use portable_pty::{native_pty_system, ChildKiller, MasterPty, PtySize}; use std::collections::HashMap; use std::io::{Read, Write}; use std::sync::Mutex; use tauri::{AppHandle, Emitter, Manager, State, WebviewUrl, WebviewWindowBuilder}; use crate::domain::paths::{ commands_file, panel_file, panel_source_file, search_file, wiki_file, Paths, }; use crate::domain::project::{ project_config, project_pool_dir, verify_project_dir_in, ProjectConfig, }; use crate::domain::registry::project_dir; use crate::domain::settings::claude_command; /// Eine laufende PTY-Session, gekoppelt an ein Terminal-Fenster (Key = Fenster-Label). pub struct Session { writer: Box, master: Box, killer: Box, } #[derive(Default)] pub struct Terminals(pub Mutex>); /// Öffnet ein Terminal für ein Projekt: startet das eigene Binary als /// Terminal-Prozess (`--terminal `), damit jedes Terminal ein /// eigenes Dock-Icon bekommt. Vorher tritt der rufende Prozess die /// Aktivierung ab — Aktivierung ist seit macOS 14 kooperativ, sonst öffnet /// das Terminal-Fenster hinter der aktiven App. #[tauri::command] pub fn open_terminal(app: AppHandle, project: String) -> Result<(), String> { // Prüft zugleich die Projekt-Identität: die config.json im registrierten // Ordner muss diese Projekt-ID tragen (verschobene/ersetzte Ordner fallen // hier auf, statt eine fremde Session zu starten). let dir = verify_project_dir_in(&Paths::real(), &project)?; if !dir.is_dir() { return Err(format!("Projektordner fehlt: {}", dir.display())); } let bundle_id = app.config().identifier.clone(); app .run_on_main_thread(move || crate::platform::yield_activation(&bundle_id)) .map_err(|e| e.to_string())?; let exe = std::env::current_exe().map_err(|e| e.to_string())?; std::process::Command::new(exe) .args(["--terminal", &project]) .spawn() .map_err(|e| e.to_string())?; Ok(()) } /// Fenster-Hintergrund je Theme — muss zu den Theme-Definitionen in /// terminal.ts passen, sonst blitzt beim Öffnen die falsche Farbe auf. fn theme_background(theme: &str) -> (u8, u8, u8) { match theme { "dracula" => (0x28, 0x2a, 0x36), "solarized-dark" => (0x00, 0x2b, 0x36), "gruvbox" => (0x28, 0x28, 0x28), "one-dark" => (0x28, 0x2c, 0x34), _ => (0x1e, 0x1e, 0x2e), // Catppuccin Mocha } } /// Baut das Terminal-Fenster des Terminal-Prozesses. Die PTY entsteht erst, /// wenn das Fenster geladen ist und `term_start` ruft — so gehen keine /// Ausgaben verloren, bevor der Event-Listener steht. pub fn build_window( app: &AppHandle, project: &str, cfg: &ProjectConfig, ) -> tauri::Result<()> { let (r, g, b) = theme_background(cfg.terminal.theme.as_deref().unwrap_or_default()); let title = cfg .terminal .title .as_deref() .or(cfg.name.as_deref()) .unwrap_or(project); let builder = WebviewWindowBuilder::new( app, format!("term-{project}"), WebviewUrl::App(format!("terminal.html?project={project}").into()), ) .title(format!("{title} — Session")) .inner_size(980.0, 640.0) .background_color(tauri::window::Color(r, g, b, 0xff)); // Titelbar liegt über dem Inhalt; der Header in terminal.html ist Drag-Region. #[cfg(target_os = "macos")] let builder = builder .title_bar_style(tauri::TitleBarStyle::Overlay) .hidden_title(true); // Linux/GNOME: keine GTK-Deko — eigene Titelleiste im Header (terminal.html). #[cfg(target_os = "linux")] let builder = builder.decorations(false); builder.build()?; Ok(()) } /// Startet die PTY für das rufende Fenster: das konfigurierte Claude-Kommando /// (settings.json: claudeCommand, Default claude) im Projektordner, mit dem /// Pool-Verzeichnis als CLAUDE_CONFIG_DIR. Die Login-Shell aus $SHELL (-l) /// baut den PATH aus ihren Profil-Dateien auf — shell-agnostisch. /// /// Nur das Terminal-Fenster selbst darf seine PTY starten: Die Terminals-Map /// ist über das Fensterlabel adressiert, und `term_write` schreibt danach in /// die laufende Shell. Ohne die Label-Prüfung könnte auch das abgelöste /// Panel-Fenster — und damit jedes Skript in dessen Webview — eine Shell /// starten und beschreiben. #[tauri::command] pub fn term_start( window: tauri::WebviewWindow, terminals: State, project: String, rows: u16, cols: u16, ) -> Result<(), String> { if window.label() != format!("term-{project}") { return Err(format!("PTY nur für das Terminal-Fenster: {}", window.label())); } let paths = Paths::real(); let cwd = project_dir(&paths, &project)?; let pty = native_pty_system() .openpty(PtySize { rows, cols, pixel_width: 0, pixel_height: 0 }) .map_err(|e| e.to_string())?; // Puffer-Kanäle aller Module (Modul-Registry): flüchtige Puffer leeren // (Inhalte gelten pro Session), persistente (ToDo) nur anlegen; die Pfade // für Env und Watcher vormerken. Bewusst alle Module, nicht nur aktive: // Die Kanäle sind billig, und ein mitten in der Session zugeschaltetes // Modul findet sie vor; die Abwahl wirkt auf Tool-Liste und Tabs. let buffers: Vec<(&'static crate::domain::modules::BufferDesc, std::path::PathBuf)> = crate::domain::modules::MODULES .iter() .flat_map(|m| m.buffers) .map(|b| (b, (b.file)(&project))) .collect(); for (b, path) in &buffers { if let Some(parent) = path.parent() { let _ = std::fs::create_dir_all(parent); } if !b.persistent || !path.is_file() { let _ = std::fs::write(path, ""); } } // Quell-Verknüpfung der Vorsession lösen — der Dokument-Puffer startet leer, // Edits dürfen nicht in die zuletzt geöffnete Archiv-Datei laufen. match std::fs::remove_file(panel_source_file(&project)) { Ok(()) => {} Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} Err(e) => return Err(e.to_string()), } let mut cmd = crate::platform::shell_command(&claude_command(&paths)); cmd.cwd(&cwd); // Aus der App-Umgebung geerbte Anthropic-Credentials rausnehmen — ein // ANTHROPIC_API_KEY sticht sonst den apiKeyHelper des Pools. Variablen, die // erst das Shell-Profil der Login-Shell setzt, erreicht das nicht. cmd.env_remove("ANTHROPIC_API_KEY"); cmd.env_remove("ANTHROPIC_AUTH_TOKEN"); cmd.env("TERM", "xterm-256color"); for (b, path) in &buffers { cmd.env(b.env, path); } cmd.env("AI_CONTROL_PROJECT", &project); if let Some(pool_dir) = project_pool_dir(&project)? { cmd.env("CLAUDE_CONFIG_DIR", pool_dir); } let mut child = pty.slave.spawn_command(cmd).map_err(|e| e.to_string())?; drop(pty.slave); let killer = child.clone_killer(); let writer = pty.master.take_writer().map_err(|e| e.to_string())?; let mut reader = pty.master.try_clone_reader().map_err(|e| e.to_string())?; let label = window.label().to_string(); let app = window.app_handle().clone(); // Output gebündelt emittieren statt pro read(): der Reader schiebt Chunks // in einen Channel, der Emitter sammelt alles, was innerhalb von 8 ms // ankommt, zu einem Event zusammen. Bei Output-Fluten (Scrollen, TUI- // Redraws) sinkt die Zahl der IPC-Events um Größenordnungen; die 8 ms // Zusatzlatenz beim Tasten-Echo liegen unter einem Frame. let (tx, rx) = std::sync::mpsc::channel::>(); std::thread::spawn(move || { let mut buf = [0u8; 8192]; loop { match reader.read(&mut buf) { Ok(0) | Err(_) => break, Ok(n) => { if tx.send(buf[..n].to_vec()).is_err() { break; } } } } let _ = child.wait(); // tx wird hier gedroppt — der Emitter sieht den geschlossenen Channel // erst, nachdem er alle Rest-Chunks emittiert hat. }); std::thread::spawn(move || { const FLUSH: std::time::Duration = std::time::Duration::from_millis(8); while let Ok(first) = rx.recv() { let mut chunk = first; let deadline = std::time::Instant::now() + FLUSH; loop { let now = std::time::Instant::now(); if now >= deadline { break; } match rx.recv_timeout(deadline - now) { Ok(more) => chunk.extend_from_slice(&more), Err(_) => break, } } let _ = app.emit_to(&label, "pty-output", STANDARD.encode(&chunk)); } let _ = app.emit_to(&label, "pty-exit", ()); }); for (b, path) in buffers { spawn_file_watcher(window.app_handle().clone(), path, b.event); } terminals.0.lock().unwrap().insert( window.label().to_string(), Session { writer, master: pty.master, killer }, ); Ok(()) } /// 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(); loop { std::thread::sleep(std::time::Duration::from_millis(200)); let now = mtime(); if now != last { last = now; if let Ok(content) = std::fs::read_to_string(&path) { let _ = app.emit(event, content); } } } }); } /// Aktueller Inhalt eines Modul-Puffers (Erstbefüllung einer Panel-Ansicht); /// `buffer` ist die Puffer-ID aus der Modul-Registry. Eine noch nicht /// geschriebene Datei liest sich als leer — wie ein leerer Puffer. #[tauri::command] pub fn buffer_read(project: String, buffer: String) -> Result { let b = crate::domain::modules::MODULES .iter() .flat_map(|m| m.buffers) .find(|b| b.id == buffer) .ok_or_else(|| format!("unbekannter Puffer: {buffer}"))?; Ok(std::fs::read_to_string((b.file)(&project)).unwrap_or_default()) } /// Entfernt einen Befehl aus der Command-History (Löschen einer Kachel im /// Panel) über seine stabile ID, die write_commands beim Schreiben vergibt. /// Ein leer gewordener Record fällt mit weg; der Watcher meldet den neuen /// Stand als `commands-update`. Doppelklick oder ein zweites Fenster auf /// derselben Liste laufen ins „bereits entfernt" statt auf falsche Indizes. #[tauri::command] pub fn commands_delete(project: String, id: String) -> 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 = text .lines() .filter(|l| !l.trim().is_empty()) .map(|l| serde_json::from_str(l).map_err(|e| e.to_string())) .collect::>()?; let mut found = false; for rec in records.iter_mut() { if let Some(cmds) = rec["commands"].as_array_mut() { let before = cmds.len(); cmds.retain(|c| c["id"].as_str() != Some(id.as_str())); found = found || cmds.len() != before; } } if !found { return Err("Befehl bereits entfernt".into()); } records.retain(|r| r["commands"].as_array().is_none_or(|c| !c.is_empty())); 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()) } /// Entfernt ein ToDo aus der persistenten Liste (Löschen einer Kachel) über /// seine stabile ID, die write_todos beim Schreiben vergibt. Der Watcher /// meldet den neuen Stand als `todos-update`. #[tauri::command] pub fn todos_delete(project: String, id: String) -> Result<(), String> { let path = crate::domain::paths::todos_file(&project); let text = std::fs::read_to_string(&path).map_err(|e| e.to_string())?; let mut removed = false; let mut out = String::new(); for line in text.lines().filter(|l| !l.trim().is_empty()) { let rec: serde_json::Value = serde_json::from_str(line).map_err(|e| e.to_string())?; if rec["id"].as_str() == Some(id.as_str()) { removed = true; continue; } out.push_str(line); out.push('\n'); } if !removed { return Err("ToDo bereits entfernt".into()); } std::fs::write(&path, out).map_err(|e| e.to_string()) } /// Baut die JSONL-Zeile eines ToDos — gleiche Form wie call_write_todos im /// MCP-Server; `id` und `ts` kommen vom Aufrufer, damit das Update beide /// erhalten kann. fn todo_line( id: &str, ts: u64, text: &str, note: Option<&str>, due: Option<&str>, ) -> Result { let text = text.trim(); if text.is_empty() { return Err("ToDo ohne Text".into()); } let mut rec = serde_json::json!({ "id": id, "ts": ts, "text": text }); if let Some(note) = note { rec["note"] = serde_json::json!(note); } if let Some(due) = due { if !crate::mcp::valid_due(due) { return Err(format!("ungültiges due-Datum: {due} (erwartet YYYY-MM-DD)")); } rec["due"] = serde_json::json!(due); } Ok(rec.to_string()) } /// Ersetzt die Zeile mit passender ID durch Text/Notiz/Fälligkeit aus dem /// Formular; ID und ts der Zeile bleiben, alle anderen Zeilen bleiben /// wörtlich erhalten. fn replace_todo_line( raw: &str, id: &str, text: &str, note: Option<&str>, due: Option<&str>, ) -> Result { let mut found = false; let mut out = String::new(); for line in raw.lines().filter(|l| !l.trim().is_empty()) { let rec: serde_json::Value = serde_json::from_str(line).map_err(|e| e.to_string())?; if rec["id"].as_str() == Some(id) { found = true; let ts = rec["ts"].as_u64().ok_or("ToDo ohne ts")?; out.push_str(&todo_line(id, ts, text, note, due)?); } else { out.push_str(line); } out.push('\n'); } if !found { return Err("ToDo nicht gefunden".into()); } Ok(out) } /// Legt ein ToDo aus dem Panel-Formular an (Plus-Button im ToDo-Tab). Der /// Watcher meldet den neuen Stand als `todos-update`. #[tauri::command] pub fn todos_add( project: String, text: String, note: Option, due: Option, ) -> Result<(), String> { let ts = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0); let line = todo_line( &uuid::Uuid::new_v4().to_string(), ts, &text, note.as_deref(), due.as_deref(), )?; let path = crate::domain::paths::todos_file(&project); std::fs::OpenOptions::new() .create(true) .append(true) .open(&path) .and_then(|mut f| std::io::Write::write_all(&mut f, format!("{line}\n").as_bytes())) .map_err(|e| e.to_string()) } /// Überschreibt Text, Notiz und Fälligkeit eines ToDos (Stift auf der /// Kachel). Der Watcher meldet den neuen Stand als `todos-update`. #[tauri::command] pub fn todos_update( project: String, id: String, text: String, note: Option, due: Option, ) -> Result<(), String> { let path = crate::domain::paths::todos_file(&project); let raw = std::fs::read_to_string(&path).map_err(|e| e.to_string())?; let out = replace_todo_line(&raw, &id, &text, note.as_deref(), due.as_deref())?; std::fs::write(&path, out).map_err(|e| e.to_string()) } /// Quell-Verknüpfung des Dokument-Tabs setzen: ab jetzt schreibt jeder /// Editor-Commit den Body in diese Archiv-Datei zurück. fn link_panel_source(project: &str, path: &std::path::Path) -> Result<(), String> { std::fs::write(panel_source_file(project), path.display().to_string()) .map_err(|e| e.to_string()) } /// Aktuelle Quell-Verknüpfung, falls gesetzt. fn panel_source(project: &str) -> Option { std::fs::read_to_string(panel_source_file(project)) .ok() .map(|s| std::path::PathBuf::from(s.trim())) .filter(|p| !p.as_os_str().is_empty()) } /// Verknüpfung nach einer Dokument-Operation nachziehen: zeigt sie auf den /// alten Pfad, wandert sie mit; `None` löst sie (gelöschtes Dokument). fn relink( project: &str, old: &std::path::Path, new: Option<&std::path::Path>, ) -> Result<(), String> { if panel_source(project).as_deref() != Some(old) { return Ok(()); } match new { Some(p) => link_panel_source(project, p), None => std::fs::remove_file(panel_source_file(project)).map_err(|e| e.to_string()), } } /// Wie `relink`, für einen verschobenen Ordner: eine Verknüpfung auf ein /// Dokument darin wandert mit. fn relink_folder( project: &str, old: &std::path::Path, new: &std::path::Path, ) -> Result<(), String> { let Some(cur) = panel_source(project) else { return Ok(()); }; match cur.strip_prefix(old) { Ok(rest) => link_panel_source(project, &new.join(rest)), Err(_) => Ok(()), } } /// Schreibt den Panel-Inhalt (Titel-Edit im Panel). Der Watcher meldet die /// Änderung als `panel-update` an alle Fenster. Zeigt der Dokument-Tab ein /// Archiv-Dokument (Quell-Verknüpfung), wandert der Body implizit in die /// Archiv-Datei zurück; ihre Frontmatter bleibt. #[tauri::command] pub fn panel_set(project: String, text: String) -> Result<(), String> { std::fs::write(panel_file(&project), &text).map_err(|e| e.to_string())?; match panel_source(&project) { Some(src) => crate::domain::archive_ops::write_body(&src, &text), None => Ok(()), } } /// Lädt ein Archiv-Dokument in den Dokument-Puffer (Treffer-Klick in der /// Suche) — ohne Frontmatter-Block, wie ein frischer Entwurf — und setzt die /// Quell-Verknüpfung fürs Zurückschreiben. Der Watcher meldet den neuen /// Inhalt als `panel-update`. #[tauri::command] pub fn panel_load(project: String, path: String) -> Result<(), String> { let text = std::fs::read_to_string(&path).map_err(|e| format!("{path}: {e}"))?; let body = crate::domain::archive::strip_frontmatter(&text); std::fs::write(panel_file(&project), body).map_err(|e| e.to_string())?; link_panel_source(&project, std::path::Path::new(&path)) } /// 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, tag: Option) -> Result<(), String> { let home = crate::domain::archive::require_archive_home(&project)?; let hits = crate::domain::archive_search::search(&home, &query, tag.as_deref(), 20)?; let payload = serde_json::json!({ "query": query, "tag": tag, "home": home.display().to_string(), "hits": hits, }); std::fs::write(search_file(&project), payload.to_string()).map_err(|e| e.to_string()) } /// Öffnet ein Wiki-Ziel: `tag:x` → Schlagwort-Seite, `tag:` → /// Archiv-Übersicht in den Wiki-Puffer (`wiki-update`); ein Dokumentname /// (Wikilink, Backlink) wird über den Index aufgelöst und wie ein /// Suchtreffer in den Dokument-Puffer geladen (`panel-update`). #[tauri::command] pub fn wiki_open(project: String, name: String) -> Result<(), String> { let home = crate::domain::archive::require_archive_home(&project)?; match name.strip_prefix("tag:") { Some(tag) => { let display = crate::domain::project::display_name_in(&Paths::real(), &project)?; crate::domain::archive_ops::ensure_node_texts(&home, &display)?; crate::domain::archive_ops::ensure_ids(&home)?; let json = serde_json::to_string(&crate::domain::archive_index::archive_page( &home, (!tag.is_empty()).then_some(tag), )?) .map_err(|e| e.to_string())?; std::fs::write(wiki_file(&project), json).map_err(|e| e.to_string()) } None => { let rel = crate::domain::archive_index::resolve_doc(&home, &name)?; let path = home.join(rel); let text = std::fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?; let body = crate::domain::archive::strip_frontmatter(&text); std::fs::write(panel_file(&project), body).map_err(|e| e.to_string())?; link_panel_source(&project, &path) } } } /// Liest den Body eines Archiv-Dokuments (ohne Frontmatter) — Inhalt der /// Notiz-Ansicht im Archiv-Tab. #[tauri::command] pub fn archive_read(project: String, id: String) -> Result { let home = crate::domain::archive::require_archive_home(&project)?; let relpath = crate::domain::archive_index::resolve_id(&home, &id)?; let path = crate::domain::archive_ops::doc_path(&home, &relpath)?; let text = std::fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?; Ok(crate::domain::archive::strip_frontmatter(&text).to_string()) } /// Schreibt den Body einer Archiv-Notiz zurück (Bearbeiten im /// Archiv-Fenster); die Frontmatter der Datei bleibt. #[tauri::command] pub fn archive_write(project: String, id: String, text: String) -> Result<(), String> { let home = crate::domain::archive::require_archive_home(&project)?; let relpath = crate::domain::archive_index::resolve_id(&home, &id)?; let path = crate::domain::archive_ops::doc_path(&home, &relpath)?; crate::domain::archive_ops::write_body(&path, &text) } /// Ordner-Knoten des Archivs (Pfad + Titel, sortiert) — Zielordner-Baum des /// Archiv-Dialogs. #[tauri::command] pub fn archive_folders( project: String, ) -> Result, String> { let home = crate::domain::archive::require_archive_home(&project)?; crate::domain::archive_index::folder_nodes(&home) } /// Legt den Entwurf als Datei an einem frei gewählten Pfad ab (Speichern- /// Dialog) — ohne Archiv, ohne Frontmatter: der Panel-Inhalt, wie er ist. #[tauri::command] pub fn panel_save_as(project: String, path: String) -> Result<(), String> { let text = std::fs::read_to_string(panel_file(&project)).map_err(|e| e.to_string())?; std::fs::write(&path, text).map_err(|e| format!("{path}: {e}")) } /// Setzt den Anzeige-Titel einer Notiz (Klick auf den Titel im Archiv); /// danach die frische Übersicht, damit Baum und Karten den neuen Titel zeigen. #[tauri::command] pub fn archive_set_title(project: String, id: String, title: String) -> Result<(), String> { let home = crate::domain::archive::require_archive_home(&project)?; let relpath = crate::domain::archive_index::resolve_id(&home, &id)?; let path = crate::domain::archive_ops::doc_path(&home, &relpath)?; crate::domain::archive_ops::set_title(&path, &title)?; // Der technische Name folgt dem Titel — beide dürfen nicht auseinander // laufen. Bei einem Knotentext wandert der gleichnamige Ordner mit; die // Archiv-Wurzel (index.md) behält ihren Namen, sie ist die Konvention. let stem = path.file_stem().unwrap_or_default().to_string_lossy().to_string(); let name = crate::domain::archive::strip_stamp(&stem).to_string(); let slug = crate::domain::archive::slugify(&title); if name != slug && name != "index" { let dir = path.with_file_name(&name); if dir.is_dir() { let old_rel = dir.strip_prefix(&home).unwrap().display().to_string(); let new_rel = match old_rel.rsplit_once('/') { Some((parent, _)) => format!("{parent}/{slug}"), None => slug.clone(), }; crate::domain::archive_ops::move_folder(&home, &old_rel, &new_rel)?; relink_folder(&project, &home.join(&old_rel), &home.join(&new_rel))?; } else { let new_rel = crate::domain::archive_ops::rename_doc(&home, &relpath, &title)?; relink(&project, &path, Some(&home.join(new_rel)))?; } } wiki_refresh_page(&project, &home) } /// Frische Archiv-Übersicht in den Wiki-Puffer — Abschluss der /// Dokument-/Ordner-Operationen; der Watcher meldet `wiki-update`. fn wiki_refresh_page(project: &str, home: &std::path::Path) -> Result<(), String> { let display = crate::domain::project::display_name_in(&Paths::real(), project)?; crate::domain::archive_ops::ensure_node_texts(home, &display)?; crate::domain::archive_ops::ensure_ids(home)?; let json = serde_json::to_string(&crate::domain::archive_index::archive_page(home, None)?) .map_err(|e| e.to_string())?; std::fs::write(wiki_file(project), json).map_err(|e| e.to_string()) } /// Löscht ein Archiv-Dokument; danach zeigt das Wiki die Übersicht. #[tauri::command] pub fn archive_delete(project: String, id: String) -> Result<(), String> { let home = crate::domain::archive::require_archive_home(&project)?; let relpath = crate::domain::archive_index::resolve_id(&home, &id)?; crate::domain::archive_ops::delete_doc(&home, &relpath)?; relink(&project, &home.join(&relpath), None)?; wiki_refresh_page(&project, &home) } /// Legt einen Ordner im Archiv an (Plus im Baum); danach Übersicht. #[tauri::command] pub fn archive_create_folder(project: String, parent: String, name: String) -> Result<(), String> { let home = crate::domain::archive::require_archive_home(&project)?; let folder = join_under(&home, &parent, &name)?; crate::domain::archive_ops::create_folder(&home, &folder)?; let display = crate::domain::project::display_name_in(&Paths::real(), &project)?; crate::domain::archive_ops::ensure_node_texts(&home, &display)?; crate::domain::archive_ops::ensure_ids(&home)?; wiki_refresh_page(&project, &home) } /// Pfad eines neuen Kindes unterhalb des Knotens `parent` (ID; leer = /// Wurzel) — der Name wird als Slug angehängt. fn join_under( home: &std::path::Path, parent: &str, name: &str, ) -> Result { let name = name.trim(); if name.is_empty() { return Err("Name fehlt".into()); } let slug = crate::domain::archive::slugify(name); if parent.is_empty() { return Ok(slug); } // Der Zielordner ist der Ordner neben dem Knotentext des Elternteils. let rel = crate::domain::archive_index::resolve_id(home, parent)?; let stem = std::path::Path::new(&rel) .file_stem() .unwrap_or_default() .to_string_lossy() .to_string(); let dir = match rel.rsplit_once('/') { Some((head, _)) => format!("{head}/{stem}"), None => stem, }; Ok(format!("{dir}/{slug}")) } /// Legt ein leeres Dokument an (Plus im Listenkopf) und öffnet es im /// Dokument-Tab: leerer Dokument-Puffer plus Quell-Verknüpfung — das /// Getippte landet über `panel_set` in der Archiv-Datei. Die Übersicht /// bleibt hier unangetastet; der Wiki-Tab lädt sie beim nächsten Aktivieren /// frisch (zwei konkurrierende Puffer-Events würden sonst um den aktiven Tab /// rennen). #[tauri::command] pub fn archive_create_doc(project: String, parent: String, name: String) -> Result<(), String> { let home = crate::domain::archive::require_archive_home(&project)?; let display = crate::domain::project::display_name_in(&Paths::real(), &project)?; let rel_new = join_under(&home, &parent, &name)?; let folder = rel_new.rsplit_once('/').map(|(h, _)| h.to_string()).unwrap_or_default(); let rel = crate::domain::archive_ops::create_doc(&home, &folder, &name, &display)?; let path = home.join(rel); std::fs::write(panel_file(&project), "").map_err(|e| e.to_string())?; link_panel_source(&project, &path) } /// Löst das Panel in ein eigenes Fenster ab. Existiert es schon, kommt es nach /// vorn. `panel-detached` blendet das angedockte Panel im Terminal-Fenster aus. /// Async, weil Fenster-Erzeugung aus einem synchronen Command auf dem /// GTK-Mainloop klemmen kann (Tauri-Vorgabe für window create in Commands). #[tauri::command] pub async fn open_panel_window( app: AppHandle, project: String, mode: Option, ) -> Result<(), String> { let label = format!("panel-{project}"); if let Some(w) = app.get_webview_window(&label) { // Fenster steht schon: nach vorn holen und auf den gewünschten Tab // schalten (Archiv-/Such-Öffner im Terminal-Header). let _ = w.set_focus(); if let Some(m) = &mode { app .emit_to(tauri::EventTarget::webview_window(&label), "panel-mode", m) .map_err(|e| e.to_string())?; } return Ok(()); } let cfg = project_config(&project)?; let (r, g, b) = theme_background(cfg.terminal.theme.as_deref().unwrap_or_default()); let title = cfg .terminal .title .as_deref() .or(cfg.name.as_deref()) .unwrap_or(project.as_str()); // Der beim Ablösen aktive Tab geht als URL-Parameter mit — das neue // Fenster startet dort, statt in seiner Default-Ansicht. let url = match &mode { Some(m) => format!("panel.html?project={project}&mode={m}"), None => format!("panel.html?project={project}"), }; // Zentrale Popup-Größe: EINMAL beim Öffnen gesetzt, für alle Tabs gleich — // 85 % der Bildschirmbreite, 92 % der Höhe (Vorbild Trilium, kein // Vollbild). Danach fasst niemand mehr die Größe an; was der Nutzer zieht, // bleibt. Maßgeblich ist der Monitor des Terminal-Fensters, das den Öffner // geklickt hat — bei mehreren Monitoren zählt der, vor dem der Nutzer // sitzt (Wayland kennt zudem keinen „primären" Monitor). let monitor = app .get_webview_window(&format!("term-{project}")) .and_then(|w| w.current_monitor().ok().flatten()) .or_else(|| app.primary_monitor().ok().flatten()) .or_else(|| app.available_monitors().ok().and_then(|m| m.into_iter().next())); let (w, h) = match &monitor { Some(m) => { let s = m.size().to_logical::(m.scale_factor()); (s.width * 0.85, s.height * 0.92) } // Kein Monitor abfragbar: Standard-Bildschirm 1920×1080 annehmen, // gleiche Faktoren. None => (1920.0 * 0.85, 1080.0 * 0.92), }; let builder = WebviewWindowBuilder::new(&app, &label, WebviewUrl::App(url.into())) .title(format!("{title} — Dokument")) .inner_size(w, h) .background_color(tauri::window::Color(r, g, b, 0xff)); // Zentriert auf DEM Monitor, dessen Größe oben zählte — .center() nähme // sonst einen anderen. let builder = match &monitor { Some(m) => { let scale = m.scale_factor(); let ms = m.size().to_logical::(scale); let mp = m.position().to_logical::(scale); builder.position(mp.x + (ms.width - w) / 2.0, mp.y + (ms.height - h) / 2.0) } None => builder.center(), }; // Linux/GNOME: keine GTK-Deko — eigene Kopfleiste in panel.html, wie beim // Terminal-Fenster. #[cfg(target_os = "linux")] let builder = builder.decorations(false); // Kein panel-detached mehr: das Archiv-Fenster ist eine eigene Fläche, // das angedockte Panel (ToDo/Befehle/Entwurf) bleibt unberührt. builder.build().map_err(|e| e.to_string())?; Ok(()) } /// Debug: JS-Fehler aus dem Terminal-Fenster ins Dev-Log. #[tauri::command] pub fn term_log(window: tauri::WebviewWindow, msg: String) { eprintln!("[{}] {msg}", window.label()); } /// Tastatureingaben des Fensters in die PTY. #[tauri::command] pub fn term_write( window: tauri::WebviewWindow, terminals: State, data: String, ) -> Result<(), String> { let mut map = terminals.0.lock().unwrap(); let s = map .get_mut(window.label()) .ok_or("keine Session für dieses Fenster")?; s.writer.write_all(data.as_bytes()).map_err(|e| e.to_string()) } #[tauri::command] pub fn term_resize( window: tauri::WebviewWindow, terminals: State, rows: u16, cols: u16, ) -> Result<(), String> { let map = terminals.0.lock().unwrap(); let s = map .get(window.label()) .ok_or("keine Session für dieses Fenster")?; s.master .resize(PtySize { rows, cols, pixel_width: 0, pixel_height: 0 }) .map_err(|e| e.to_string()) } /// Beim Schließen eines Terminal-Fensters: Kind killen, Session verwerfen. /// Das Droppen des Masters schließt die PTY — claude bekommt HUP. pub fn close(window: &tauri::Window) { let terminals = window.state::(); let session = terminals.0.lock().unwrap().remove(window.label()); if let Some(mut s) = session { let _ = s.killer.kill(); } } #[cfg(test)] mod tests { use super::{replace_todo_line, todo_line}; #[test] fn todo_zeile_baut_und_prueft() { let line = todo_line("i1", 100, " Aufgabe ", Some("Notiz"), Some("2026-07-24")).unwrap(); let rec: serde_json::Value = serde_json::from_str(&line).unwrap(); assert_eq!(rec["id"], "i1"); assert_eq!(rec["ts"], 100); assert_eq!(rec["text"], "Aufgabe"); assert_eq!(rec["note"], "Notiz"); assert_eq!(rec["due"], "2026-07-24"); // Ohne note/due fehlen die Felder. let rec: serde_json::Value = serde_json::from_str(&todo_line("i1", 100, "x", None, None).unwrap()).unwrap(); assert!(rec.get("note").is_none()); assert!(rec.get("due").is_none()); assert!(todo_line("i1", 100, " ", None, None).is_err()); assert!(todo_line("i1", 100, "x", None, Some("24.07.2026")).is_err()); } #[test] fn update_ersetzt_nur_die_passende_zeile() { let raw = concat!( r#"{"id":"a","ts":100,"text":"alt","note":"n"}"#, "\n", r#"{"id":"b","ts":200,"text":"bleibt"}"#, "\n", ); let out = replace_todo_line(raw, "a", "neu", None, Some("2026-07-24")).unwrap(); let lines: Vec = out.lines().map(|l| serde_json::from_str(l).unwrap()).collect(); // ID und ts bleiben, note ist weg, due ist neu. assert_eq!(lines[0]["id"], "a"); assert_eq!(lines[0]["ts"], 100); assert_eq!(lines[0]["text"], "neu"); assert!(lines[0].get("note").is_none()); assert_eq!(lines[0]["due"], "2026-07-24"); assert_eq!(lines[1]["text"], "bleibt"); assert!(replace_todo_line(raw, "fehlt", "x", None, None).is_err()); } }