2251a1d45f
Bisher war der Dateipfad die Identität einer Notiz: Umbenennen brach Verweise, und der Pfad tauchte in der Oberfläche auf (Tooltips, Menüpunkte "Dateiname ändern"/"Ordnerpfad ändern"). Jede Notiz trägt jetzt eine technische ID im Frontmatter (id: <uuid>), die nie wechselt; der Systempfad ist nur noch eine Eigenschaft. - ensure_ids ergänzt fehlende IDs beim Seitenaufbau (wie ensure_node_texts); frontmatter() schreibt sie bei jedem Anlegen als erste Zeile. - Doc, WikiDocEntry, FolderNode und Suchtreffer führen die ID mit; resolve_id löst sie auf den aktuellen relpath auf. - Commands sprechen IDs: archive_read/write/set_title/delete nehmen `id`, archive_create_doc/create_folder die ID des Elternknotens plus Namen, panel_archive_cmd den Zielknoten als ID. - Frontend adressiert durchgehend über IDs: Auswahl, Klapp-Zustand, Verlauf des Zurück-Knopfs, Wikilink-Sprünge, Suchtreffer-Vormerkung und der Zielordner-Baum des Archiv-Dialogs. - Der Pfad ist aus der Oberfläche verschwunden: die Menüpunkte zum Umbenennen von Datei und Ordner samt Commands (archive_rename, archive_move, archive_move_folder) sind entfallen — es gibt nur noch den Titel, Name und Titel können nicht mehr auseinanderlaufen. Offen dadurch: Verschieben im Baum fehlt (ToDo: Drag & Drop). Archiv-Dialog nebenbei überarbeitet: modales Popup im Design der Archiv-Ansicht mit Abbrechen und zuverlässigem Escape, Zielordner über einen einklappbaren Baum statt Pfadeingabe, zusätzlicher Knopf "Auf Platte legen" (panel_save_as); Archivieren öffnet keinen Dateimanager mehr.
877 lines
33 KiB
Rust
877 lines
33 KiB
Rust
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<dyn Write + Send>,
|
||
master: Box<dyn MasterPty + Send>,
|
||
killer: Box<dyn ChildKiller + Send + Sync>,
|
||
}
|
||
|
||
#[derive(Default)]
|
||
pub struct Terminals(pub Mutex<HashMap<String, Session>>);
|
||
|
||
/// Öffnet ein Terminal für ein Projekt: startet das eigene Binary als
|
||
/// Terminal-Prozess (`--terminal <projekt>`), 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<Terminals>,
|
||
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::<Vec<u8>>();
|
||
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<String, String> {
|
||
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<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 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<String, String> {
|
||
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<String, String> {
|
||
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<String>,
|
||
due: Option<String>,
|
||
) -> 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<String>,
|
||
due: Option<String>,
|
||
) -> 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::path::PathBuf> {
|
||
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<String>) -> 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<String, 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)?;
|
||
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<Vec<crate::domain::archive_index::FolderNode>, 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<String, String> {
|
||
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<String>,
|
||
) -> 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::<f64>(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::<f64>(scale);
|
||
let mp = m.position().to_logical::<f64>(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<Terminals>,
|
||
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<Terminals>,
|
||
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::<Terminals>();
|
||
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<serde_json::Value> =
|
||
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());
|
||
}
|
||
}
|