Archiv-Navigator: Baum nur Ordner + flache Inhaltsliste (Zeilen: öffnen/umbenennen/löschen), Ordner/Dokument anlegen (Dateiname ohne Stempel, Datum im Frontmatter), Write-back geöffneter Archiv-Dokumente über Quell-Verknüpfung; Ablösen übergibt aktiven Tab

This commit is contained in:
marcus hinz
2026-07-23 21:17:41 +02:00
parent 1523856ea7
commit f58b9887c7
26 changed files with 1362 additions and 468 deletions
+174 -25
View File
@@ -5,7 +5,9 @@ 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, search_file, wiki_file, Paths};
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,
};
@@ -145,6 +147,13 @@ pub fn term_start(
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);
@@ -419,21 +428,76 @@ pub fn todos_update(
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.
/// Ä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())
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. Der Watcher
/// meldet den neuen Inhalt als `panel-update`.
/// 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())
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
@@ -452,23 +516,102 @@ pub fn search_run(project: String, query: String, tag: Option<String>) -> Result
std::fs::write(search_file(&project), payload.to_string()).map_err(|e| e.to_string())
}
/// Ö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`.
/// Ö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)?;
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)?),
match name.strip_prefix("tag:") {
Some(tag) => {
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)
}
}
.map_err(|e| e.to_string())?;
std::fs::write(wiki_file(&project), json).map_err(|e| e.to_string())
}
/// 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 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())
}
/// Verschiebt ein Archiv-Dokument in einen anderen Ordner (leer = Wurzel).
#[tauri::command]
pub fn archive_move(project: String, relpath: String, folder: String) -> Result<(), String> {
let home = crate::domain::archive::require_archive_home(&project)?;
let old = home.join(&relpath);
let new_rel = crate::domain::archive_ops::move_doc(&home, &relpath, &folder)?;
relink(&project, &old, Some(&home.join(new_rel)))?;
wiki_refresh_page(&project, &home)
}
/// Benennt ein Archiv-Dokument um (Slug hinter erhaltenem Zeitstempel).
#[tauri::command]
pub fn archive_rename(project: String, relpath: String, name: String) -> Result<(), String> {
let home = crate::domain::archive::require_archive_home(&project)?;
let old = home.join(&relpath);
let new_rel = crate::domain::archive_ops::rename_doc(&home, &relpath, &name)?;
relink(&project, &old, Some(&home.join(new_rel)))?;
wiki_refresh_page(&project, &home)
}
/// Löscht ein Archiv-Dokument; danach zeigt das Wiki die Übersicht.
#[tauri::command]
pub fn archive_delete(project: String, relpath: String) -> Result<(), String> {
let home = crate::domain::archive::require_archive_home(&project)?;
crate::domain::archive_ops::delete_doc(&home, &relpath)?;
relink(&project, &home.join(&relpath), None)?;
wiki_refresh_page(&project, &home)
}
/// Verschiebt/benennt einen Archiv-Ordner um; danach Übersicht.
#[tauri::command]
pub fn archive_move_folder(project: String, folder: String, to: String) -> Result<(), String> {
let home = crate::domain::archive::require_archive_home(&project)?;
crate::domain::archive_ops::move_folder(&home, &folder, &to)?;
relink_folder(&project, &home.join(&folder), &home.join(&to))?;
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, folder: String) -> Result<(), String> {
let home = crate::domain::archive::require_archive_home(&project)?;
crate::domain::archive_ops::create_folder(&home, &folder)?;
wiki_refresh_page(&project, &home)
}
/// 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, folder: 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 = 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
@@ -476,7 +619,11 @@ pub fn wiki_open(project: String, name: String) -> Result<(), String> {
/// 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) -> Result<(), String> {
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) {
let _ = w.set_focus();
@@ -490,11 +637,13 @@ pub async fn open_panel_window(app: AppHandle, project: String) -> Result<(), St
.as_deref()
.or(cfg.name.as_deref())
.unwrap_or(project.as_str());
let builder = WebviewWindowBuilder::new(
&app,
&label,
WebviewUrl::App(format!("panel.html?project={project}").into()),
)
// 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}"),
};
let builder = WebviewWindowBuilder::new(&app, &label, WebviewUrl::App(url.into()))
.title(format!("{title} — Dokument"))
.inner_size(480.0, 640.0)
.background_color(tauri::window::Color(r, g, b, 0xff));