Archiv als eigenes Fenster: Trilium raus, Notizbaum mit In-Place-Bearbeitung
Trilium-Integration komplett zurückgenommen (Revert von 47951a6): keine
verwaltete Instanz, kein Runtime-Download, kein ETAPI, kein Keyring-Token,
kein Setup-Wizard. Das Archiv sind wieder Markdown-Dateien unter archiveHome
samt FTS5-Suche und Wikilinks.
Notizmodell (an Trilium orientiert, ohne dessen Überbau):
- Jeder Knoten besitzt eine Textdatei — ensure_node_texts legt fehlende beim
Seitenaufbau an (Ordner: gleichnamiges .md daneben, Wurzel: index.md);
Ordner und Zwillingsdatei sind EINE Notiz mit Inhalt und Kindern.
- Der angezeigte Titel steht im Frontmatter der Datei, der Datei-/Ordnername
ist technischer Verwaltungsname; move_folder nimmt den Knotentext mit.
Archiv-Ansicht (wiki-view):
- Baum links: Knoten- und Blatt-Symbole, Führungslinien pro Ebene, Dokumente
näher an der Linie als Ordner; ganze Zeile wählt aus, nur der Pfeil klappt.
- Notiz-Ansicht rechts: großer Titel (Klick bearbeitet ihn → archive_set_title),
Zurück-Knopf mit Auswahl-Verlauf, Details als Popup am Info-Knopf, Kindliste
als abgesetzter Karten-Block mit Anlage-/Änderungsdatum.
- Bearbeiten in place: Split-View aus Rohtext und Live-Vorschau, Speichern per
Knopf oder Ctrl+S (archive_read/archive_write; Frontmatter bleibt).
- Rechte Maustaste im Baum als einziger Aktionsweg (eigenes Kontextmenü,
Standard-Menü des Webviews aus); Anlegen/Umbenennen über modale Dialoge.
Feste Flächenaufteilung statt Ablösen/Andocken:
- Angedocktes Panel = Session-Begleiter (ToDo, Befehle, flüchtiger Entwurf);
der Dokument-Tab ist aus der Leiste raus.
- Archiv-Fenster = Archiv + Suche, Größe und Position einmalig beim Öffnen
aus dem Monitor des rufenden Terminal-Fensters (85 % / 92 %); Öffner-Klick
bei stehendem Fenster fokussiert und schaltet den Tab (panel-mode).
- Terminal-Fenster zu → Archiv-Fenster geht mit.
Neue Commands: archive_read, archive_write, archive_set_title.
This commit is contained in:
+259
-25
@@ -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, search_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,19 +147,12 @@ pub fn term_start(
|
||||
let _ = std::fs::write(path, "");
|
||||
}
|
||||
}
|
||||
// Trilium-Modul an: Projekt-Instanz zur Session hochfahren (im Thread —
|
||||
// der Start wartet bis zu 20 s auf den Port). Fehler landen im Dev-Log;
|
||||
// die Tools melden eine fehlende Instanz ohnehin laut.
|
||||
if crate::domain::modules::active_in(&paths, &project)?
|
||||
.iter()
|
||||
.any(|m| m.id == "trilium")
|
||||
{
|
||||
let proj = project.clone();
|
||||
std::thread::spawn(move || {
|
||||
if let Err(e) = crate::domain::trilium::ensure_instance(&Paths::real(), &proj) {
|
||||
log::warn!("Trilium-Start ({proj}): {e}");
|
||||
}
|
||||
});
|
||||
// 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));
|
||||
@@ -434,24 +429,224 @@ pub fn todos_update(
|
||||
}
|
||||
|
||||
/// Quell-Verknüpfung des Dokument-Tabs setzen: ab jetzt schreibt jeder
|
||||
/// Suche aus dem Panel-Suchfeld: läuft wie das MCP-Tool search_archive über
|
||||
/// die ETAPI der Projekt-Instanz und schreibt die Treffer-Datei; der Watcher
|
||||
/// zieht sie in die Ansicht (beide Fenster).
|
||||
#[tauri::command]
|
||||
pub async fn search_run(
|
||||
project: String,
|
||||
query: String,
|
||||
tag: Option<String>,
|
||||
/// 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> {
|
||||
let hits = crate::domain::trilium::search_notes(&project, &query, tag.as_deref(), 20)?;
|
||||
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)?;
|
||||
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, relpath: String) -> Result<String, String> {
|
||||
let home = crate::domain::archive::require_archive_home(&project)?;
|
||||
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, relpath: String, text: String) -> Result<(), String> {
|
||||
let home = crate::domain::archive::require_archive_home(&project)?;
|
||||
let path = crate::domain::archive_ops::doc_path(&home, &relpath)?;
|
||||
crate::domain::archive_ops::write_body(&path, &text)
|
||||
}
|
||||
|
||||
/// 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, relpath: String, title: String) -> Result<(), String> {
|
||||
let home = crate::domain::archive::require_archive_home(&project)?;
|
||||
let path = crate::domain::archive_ops::doc_path(&home, &relpath)?;
|
||||
crate::domain::archive_ops::set_title(&path, &title)?;
|
||||
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)?;
|
||||
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
|
||||
/// vorn. `panel-detached` blendet das angedockte Panel im Terminal-Fenster aus.
|
||||
/// Async, weil Fenster-Erzeugung aus einem synchronen Command auf dem
|
||||
@@ -464,7 +659,14 @@ pub async fn open_panel_window(
|
||||
) -> 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)?;
|
||||
@@ -481,18 +683,50 @@ pub async fn open_panel_window(
|
||||
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(480.0, 640.0)
|
||||
.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())?;
|
||||
app.emit("panel-detached", ()).map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user