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:
@@ -311,6 +311,12 @@ fn invoke_handlers() -> impl Fn(tauri::ipc::Invoke<tauri::Wry>) -> bool + Send +
|
||||
terminal::search_run,
|
||||
terminal::panel_load,
|
||||
terminal::wiki_open,
|
||||
terminal::archive_move,
|
||||
terminal::archive_rename,
|
||||
terminal::archive_delete,
|
||||
terminal::archive_move_folder,
|
||||
terminal::archive_create_folder,
|
||||
terminal::archive_create_doc,
|
||||
terminal::open_panel_window
|
||||
]
|
||||
}
|
||||
|
||||
@@ -275,7 +275,12 @@ fn check_folder(folder: &str) -> Result<&std::path::Path, String> {
|
||||
/// YAML-Frontmatter des Archiv-Dokuments inklusive optionaler
|
||||
/// description/tags aus den Metadaten. Gegenstück: `parse_frontmatter` unten —
|
||||
/// Schreiber und Leser des Formats leben bewusst im selben Modul.
|
||||
fn frontmatter(title: &str, project: &str, iso: &str, meta: &ArchiveMeta) -> String {
|
||||
pub(crate) fn frontmatter(
|
||||
title: &str,
|
||||
project: &str,
|
||||
iso: &str,
|
||||
meta: &ArchiveMeta,
|
||||
) -> String {
|
||||
let mut fm = format!(
|
||||
"---\ntitle: \"{}\"\nproject: {project}\ncreated: {iso}\nsource: ai-control\n",
|
||||
title.replace('"', "'"),
|
||||
@@ -398,6 +403,16 @@ pub(crate) fn slugify(s: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Aktuelle UTC-Zeit als ISO-String — Frontmatter-`created` neu angelegter
|
||||
/// Dokumente.
|
||||
pub(crate) fn utc_now_iso() -> Result<String, String> {
|
||||
let secs = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_err(|e| e.to_string())?
|
||||
.as_secs();
|
||||
Ok(utc_stamp(secs).1)
|
||||
}
|
||||
|
||||
/// UTC aus Epoch-Sekunden: (Dateistempel `YYYY-MM-DD_HHMM`, ISO `…Z`).
|
||||
fn utc_stamp(secs: u64) -> (String, String) {
|
||||
let (h, mi, s) = (secs / 3600 % 24, secs / 60 % 60, secs % 60);
|
||||
|
||||
@@ -8,9 +8,7 @@ use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::domain::archive::{
|
||||
parse_frontmatter, parse_tag_list, slugify, strip_frontmatter, strip_stamp,
|
||||
};
|
||||
use crate::domain::archive::{parse_frontmatter, parse_tag_list, slugify, strip_stamp};
|
||||
|
||||
#[derive(serde::Serialize, Clone)]
|
||||
pub(crate) struct Doc {
|
||||
@@ -22,6 +20,9 @@ pub(crate) struct Doc {
|
||||
pub(crate) title: String,
|
||||
pub(crate) description: Option<String>,
|
||||
pub(crate) tags: Vec<String>,
|
||||
/// Frontmatter-`created` (ISO) — Datumsquelle für Dokumente ohne
|
||||
/// Zeitstempel im Dateinamen.
|
||||
pub(crate) created: Option<String>,
|
||||
/// Wikilink-Ziele im Dokumenttext (`[[ziel]]`/`[[ziel|label]]`, nur das Ziel).
|
||||
pub(crate) links: Vec<String>,
|
||||
/// Namen der Dokumente, die per Wikilink hierher zeigen.
|
||||
@@ -107,6 +108,7 @@ fn read_doc(home: &Path, path: &Path) -> Result<(Doc, String), String> {
|
||||
title: fm.get("title").unwrap_or(&name).clone(),
|
||||
description: fm.get("description").cloned(),
|
||||
tags: fm.get("tags").map(|t| parse_tag_list(t)).unwrap_or_default(),
|
||||
created: fm.get("created").cloned(),
|
||||
links: wikilinks(&text),
|
||||
backlinks: Vec::new(),
|
||||
name,
|
||||
@@ -134,9 +136,6 @@ pub(crate) struct WikiPage {
|
||||
pub(crate) tag: Option<String>,
|
||||
pub(crate) total: usize,
|
||||
pub(crate) tags: Vec<TagCount>,
|
||||
/// Die neuesten Dokumente über alle Ordner; leer, wenn es nur einen
|
||||
/// Ordner gibt (dann wäre die Sektion eine Dublette der Liste).
|
||||
pub(crate) recent: Vec<WikiDocEntry>,
|
||||
pub(crate) folders: Vec<WikiFolder>,
|
||||
}
|
||||
|
||||
@@ -155,6 +154,9 @@ pub(crate) struct WikiFolder {
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
pub(crate) struct WikiDocEntry {
|
||||
/// Pfad relativ zum Archiv-Home — Sprung ins Dokument und Adressat der
|
||||
/// Zeilen-Aktionen (umbenennen, löschen).
|
||||
pub(crate) relpath: String,
|
||||
pub(crate) name: String,
|
||||
pub(crate) title: String,
|
||||
pub(crate) description: Option<String>,
|
||||
@@ -179,6 +181,16 @@ pub(crate) fn archive_page(home: &Path, tag: Option<&str>) -> Result<WikiPage, S
|
||||
};
|
||||
let mut folders: std::collections::BTreeMap<String, Vec<&Doc>> =
|
||||
std::collections::BTreeMap::new();
|
||||
// Die Übersicht führt alle Ordner, auch dokumentlose — der Navigator zeigt
|
||||
// frisch angelegte Ordner sonst nicht. Schlagwort-Seiten bleiben auf die
|
||||
// Treffer-Ordner beschränkt.
|
||||
if tag.is_none() {
|
||||
let mut dirs = Vec::new();
|
||||
folder_paths(home, home, &mut dirs)?;
|
||||
for d in dirs {
|
||||
folders.entry(d).or_default();
|
||||
}
|
||||
}
|
||||
for doc in &selected {
|
||||
let folder = Path::new(&doc.relpath)
|
||||
.parent()
|
||||
@@ -186,17 +198,6 @@ pub(crate) fn archive_page(home: &Path, tag: Option<&str>) -> Result<WikiPage, S
|
||||
.unwrap_or_default();
|
||||
folders.entry(folder).or_default().push(doc);
|
||||
}
|
||||
let recent = if folders.len() > 1 {
|
||||
// Neueste zuerst über alle Ordner — der Zeitstempel-Stem sortiert
|
||||
// chronologisch, verglichen wird der Dateiname.
|
||||
let mut all = selected.clone();
|
||||
all.sort_by(|a, b| {
|
||||
Path::new(&b.relpath).file_name().cmp(&Path::new(&a.relpath).file_name())
|
||||
});
|
||||
all.into_iter().take(5).map(doc_entry).collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
Ok(WikiPage {
|
||||
kind: "page",
|
||||
home: home.display().to_string(),
|
||||
@@ -206,7 +207,6 @@ pub(crate) fn archive_page(home: &Path, tag: Option<&str>) -> Result<WikiPage, S
|
||||
.into_iter()
|
||||
.map(|(name, count)| TagCount { name: name.to_string(), count })
|
||||
.collect(),
|
||||
recent,
|
||||
folders: folders
|
||||
.into_iter()
|
||||
.map(|(name, mut list)| {
|
||||
@@ -218,14 +218,41 @@ pub(crate) fn archive_page(home: &Path, tag: Option<&str>) -> Result<WikiPage, S
|
||||
})
|
||||
}
|
||||
|
||||
/// Alle Ordner-Relpaths unterhalb von `dir`, rekursiv; versteckte Einträge
|
||||
/// (Punkt-Präfix) bleiben außen vor — wie beim Dokument-Scan.
|
||||
fn folder_paths(home: &Path, dir: &Path, out: &mut Vec<String>) -> Result<(), String> {
|
||||
let entries = fs::read_dir(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
for entry in entries {
|
||||
let entry = entry.map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
let path = entry.path();
|
||||
if entry.file_name().to_string_lossy().starts_with('.') || !path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
out.push(path.strip_prefix(home).unwrap().display().to_string());
|
||||
folder_paths(home, &path, out)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn doc_entry(doc: &Doc) -> WikiDocEntry {
|
||||
let stem = Path::new(&doc.relpath)
|
||||
.file_stem()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
let date = (stem != doc.name).then(|| stem[..10].to_string());
|
||||
// Zeitstempel im Dateinamen, sonst der Frontmatter-`created`-Tag —
|
||||
// im Navigator angelegte Dokumente tragen das Datum nur dort.
|
||||
let date = (stem != doc.name)
|
||||
.then(|| stem[..10].to_string())
|
||||
.or_else(|| {
|
||||
doc
|
||||
.created
|
||||
.as_ref()
|
||||
.filter(|c| c.len() >= 10)
|
||||
.map(|c| c[..10].to_string())
|
||||
});
|
||||
WikiDocEntry {
|
||||
relpath: doc.relpath.clone(),
|
||||
name: doc.name.clone(),
|
||||
title: doc.title.clone(),
|
||||
description: doc.description.clone(),
|
||||
@@ -235,37 +262,16 @@ fn doc_entry(doc: &Doc) -> WikiDocEntry {
|
||||
}
|
||||
}
|
||||
|
||||
/// Ein Archiv-Dokument als Wiki-Ansicht: Markdown-Rumpf ohne Frontmatter plus
|
||||
/// Metadaten und Backlinks aus dem Scan.
|
||||
#[derive(serde::Serialize)]
|
||||
pub(crate) struct WikiDocPage {
|
||||
pub(crate) kind: &'static str,
|
||||
pub(crate) home: String,
|
||||
pub(crate) relpath: String,
|
||||
pub(crate) name: String,
|
||||
pub(crate) title: String,
|
||||
pub(crate) tags: Vec<String>,
|
||||
pub(crate) backlinks: Vec<String>,
|
||||
pub(crate) markdown: String,
|
||||
}
|
||||
|
||||
pub(crate) fn wiki_doc(home: &Path, target: &str) -> Result<WikiDocPage, String> {
|
||||
let pairs = scan_with_bodies(home)?;
|
||||
/// Löst ein Wikilink-Ziel (Name, Titel oder Datei-Stem) gegen das Archiv auf
|
||||
/// und liefert den relpath des Dokuments.
|
||||
pub(crate) fn resolve_doc(home: &Path, target: &str) -> Result<String, String> {
|
||||
let docs = scan_archive(home)?;
|
||||
let want = slugify(target);
|
||||
let (doc, body) = pairs
|
||||
docs
|
||||
.iter()
|
||||
.find(|(d, _)| matches(d, &want))
|
||||
.ok_or_else(|| format!("kein Archiv-Dokument zu „{target}“ gefunden"))?;
|
||||
Ok(WikiDocPage {
|
||||
kind: "doc",
|
||||
home: home.display().to_string(),
|
||||
relpath: doc.relpath.clone(),
|
||||
name: doc.name.clone(),
|
||||
title: doc.title.clone(),
|
||||
tags: doc.tags.clone(),
|
||||
backlinks: doc.backlinks.clone(),
|
||||
markdown: strip_frontmatter(body).to_string(),
|
||||
})
|
||||
.find(|d| matches(d, &want))
|
||||
.map(|d| d.relpath.clone())
|
||||
.ok_or_else(|| format!("kein Archiv-Dokument zu „{target}“ gefunden"))
|
||||
}
|
||||
|
||||
/// Alle `[[ziel]]`-Vorkommen im Text, in Dokumentreihenfolge; bei
|
||||
@@ -358,14 +364,38 @@ mod tests {
|
||||
assert_eq!(adr.title, "ADR Logging");
|
||||
assert_eq!(adr.description.as_deref(), Some("Logging vereinheitlichen"));
|
||||
assert_eq!(adr.date.as_deref(), Some("2026-07-19"));
|
||||
assert_eq!(adr.relpath, "2026-07-19_1000-adr-logging.md");
|
||||
assert_eq!(page.folders[1].name, "konzepte");
|
||||
assert_eq!(page.folders[1].docs[0].name, "notiz-deploy");
|
||||
// Zwei Ordner → Zuletzt-Sektion, neuestes Dokument zuerst, mit
|
||||
// Backlink-Zähler aus dem Scan.
|
||||
let recent: Vec<&str> = page.recent.iter().map(|d| d.name.as_str()).collect();
|
||||
assert_eq!(recent, vec!["notiz-deploy", "adr-logging"]);
|
||||
assert_eq!(page.recent[0].backlinks, 1);
|
||||
assert_eq!(page.recent[1].backlinks, 0);
|
||||
assert_eq!(page.folders[1].docs[0].backlinks, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leere_ordner_und_created_datum() {
|
||||
let home = archiv();
|
||||
fs::create_dir_all(home.join("leer/unter")).unwrap();
|
||||
write(
|
||||
&home,
|
||||
"notiz-plain.md",
|
||||
"---\ntitle: \"Plain\"\ncreated: 2026-07-23T10:00:00Z\n---\n\nText.\n",
|
||||
);
|
||||
let page = archive_page(&home, None).unwrap();
|
||||
let names: Vec<&str> = page.folders.iter().map(|f| f.name.as_str()).collect();
|
||||
// Dokumentlose Ordner erscheinen (Navigator), versteckte weiterhin nicht.
|
||||
assert!(names.contains(&"leer"));
|
||||
assert!(names.contains(&"leer/unter"));
|
||||
assert!(!names.iter().any(|n| n.starts_with(".versteckt")));
|
||||
// Ohne Zeitstempel im Namen kommt das Datum aus dem Frontmatter-created.
|
||||
let plain = page
|
||||
.folders
|
||||
.iter()
|
||||
.find(|f| f.name.is_empty())
|
||||
.unwrap()
|
||||
.docs
|
||||
.iter()
|
||||
.find(|d| d.name == "notiz-plain")
|
||||
.unwrap();
|
||||
assert_eq!(plain.date.as_deref(), Some("2026-07-23"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -381,20 +411,17 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wiki_doc_mit_rumpf_und_backlinks() {
|
||||
fn resolve_ueber_name_titel_und_stem() {
|
||||
let home = archiv();
|
||||
// Auflösung über Titel; Name und Stem gehen über dieselben Slug-Vergleiche.
|
||||
let doc = wiki_doc(&home, "Notiz Deploy").unwrap();
|
||||
assert_eq!(doc.kind, "doc");
|
||||
assert_eq!(doc.name, "notiz-deploy");
|
||||
assert_eq!(
|
||||
wiki_doc(&home, "2026-07-19_1000-adr-logging").unwrap().name,
|
||||
"adr-logging"
|
||||
resolve_doc(&home, "Notiz Deploy").unwrap(),
|
||||
"konzepte/2026-07-19_1005-notiz-deploy.md"
|
||||
);
|
||||
assert_eq!(doc.title, "Notiz Deploy");
|
||||
assert_eq!(doc.relpath, "konzepte/2026-07-19_1005-notiz-deploy.md");
|
||||
assert_eq!(doc.backlinks, vec!["adr-logging"]);
|
||||
assert_eq!(doc.markdown, "Text ohne Links.\n");
|
||||
assert!(wiki_doc(&home, "fehlt").is_err());
|
||||
assert_eq!(
|
||||
resolve_doc(&home, "2026-07-19_1000-adr-logging").unwrap(),
|
||||
"2026-07-19_1000-adr-logging.md"
|
||||
);
|
||||
assert!(resolve_doc(&home, "fehlt").is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
//! Dokument- und Ordner-Operationen im Archiv-Baum (Baum-Navigation im
|
||||
//! Panel): verschieben, umbenennen, löschen. Alle Pfade sind relativ zum
|
||||
//! Archiv-Home und strikt darauf begrenzt — die relpaths kommen aus dem
|
||||
//! Webview, ein Traversal-Bestandteil bricht laut ab.
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
use crate::domain::archive::{
|
||||
frontmatter, slugify, strip_frontmatter, strip_stamp, utc_now_iso, ArchiveMeta,
|
||||
};
|
||||
|
||||
/// Relativer Pfad ohne Traversal: nur normale Komponenten, nichts Absolutes.
|
||||
fn checked_rel(rel: &str) -> Result<(), String> {
|
||||
if rel.is_empty() {
|
||||
return Err("leerer Pfad".into());
|
||||
}
|
||||
for c in Path::new(rel).components() {
|
||||
match c {
|
||||
Component::Normal(_) => {}
|
||||
_ => return Err(format!("unzulässiger Pfad: {rel}")),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Vorhandenes Archiv-Dokument zum relpath.
|
||||
fn doc_path(home: &Path, relpath: &str) -> Result<PathBuf, String> {
|
||||
checked_rel(relpath)?;
|
||||
if !relpath.ends_with(".md") {
|
||||
return Err(format!("kein Markdown-Dokument: {relpath}"));
|
||||
}
|
||||
let path = home.join(relpath);
|
||||
if !path.is_file() {
|
||||
return Err(format!("Dokument nicht gefunden: {relpath}"));
|
||||
}
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Ziel für rename: darf noch nicht existieren — stilles Überschreiben wäre
|
||||
/// Datenverlust.
|
||||
fn fresh_target(target: &Path, home: &Path) -> Result<(), String> {
|
||||
if target.exists() {
|
||||
return Err(format!(
|
||||
"Ziel existiert bereits: {}",
|
||||
target.strip_prefix(home).unwrap_or(target).display()
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Verschiebt ein Dokument in einen anderen Ordner (leer = Wurzel); der
|
||||
/// Zielordner entsteht bei Bedarf. Liefert den neuen relpath.
|
||||
pub(crate) fn move_doc(home: &Path, relpath: &str, folder: &str) -> Result<String, String> {
|
||||
let src = doc_path(home, relpath)?;
|
||||
if !folder.is_empty() {
|
||||
checked_rel(folder)?;
|
||||
}
|
||||
let file_name = src.file_name().unwrap();
|
||||
let dir = home.join(folder);
|
||||
let target = dir.join(file_name);
|
||||
if target == src {
|
||||
return Ok(relpath.to_string());
|
||||
}
|
||||
fresh_target(&target, home)?;
|
||||
fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
fs::rename(&src, &target).map_err(|e| format!("{}: {e}", src.display()))?;
|
||||
Ok(target.strip_prefix(home).unwrap().display().to_string())
|
||||
}
|
||||
|
||||
/// Benennt ein Dokument um: neuer Name als Slug hinter dem (erhaltenen)
|
||||
/// Zeitstempel. Liefert den neuen relpath.
|
||||
pub(crate) fn rename_doc(home: &Path, relpath: &str, name: &str) -> Result<String, String> {
|
||||
let src = doc_path(home, relpath)?;
|
||||
let stem = src.file_stem().unwrap().to_string_lossy().to_string();
|
||||
let stamp = &stem[..stem.len() - strip_stamp(&stem).len()];
|
||||
let target = src.with_file_name(format!("{stamp}{}.md", slugify(name)));
|
||||
if target == src {
|
||||
return Ok(relpath.to_string());
|
||||
}
|
||||
fresh_target(&target, home)?;
|
||||
fs::rename(&src, &target).map_err(|e| format!("{}: {e}", src.display()))?;
|
||||
Ok(target.strip_prefix(home).unwrap().display().to_string())
|
||||
}
|
||||
|
||||
pub(crate) fn delete_doc(home: &Path, relpath: &str) -> Result<(), String> {
|
||||
let path = doc_path(home, relpath)?;
|
||||
fs::remove_file(&path).map_err(|e| format!("{}: {e}", path.display()))
|
||||
}
|
||||
|
||||
/// Legt einen (auch leeren) Ordner unterhalb des Archiv-Home an.
|
||||
pub(crate) fn create_folder(home: &Path, folder: &str) -> Result<(), String> {
|
||||
checked_rel(folder)?;
|
||||
let dir = home.join(folder);
|
||||
fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))
|
||||
}
|
||||
|
||||
/// Legt ein leeres Dokument an: `<slug>.md` im Ordner (leer = Wurzel), das
|
||||
/// Datum steht in der Frontmatter (`created`), nicht im Dateinamen. Liefert
|
||||
/// den relpath.
|
||||
pub(crate) fn create_doc(
|
||||
home: &Path,
|
||||
folder: &str,
|
||||
name: &str,
|
||||
project_name: &str,
|
||||
) -> Result<String, String> {
|
||||
let name = name.trim();
|
||||
if name.is_empty() {
|
||||
return Err("Dokumentname fehlt".into());
|
||||
}
|
||||
if !folder.is_empty() {
|
||||
checked_rel(folder)?;
|
||||
}
|
||||
let dir = home.join(folder);
|
||||
fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
let target = dir.join(format!("{}.md", slugify(name)));
|
||||
let fm = frontmatter(name, project_name, &utc_now_iso()?, &ArchiveMeta::default());
|
||||
let mut file = match fs::OpenOptions::new().write(true).create_new(true).open(&target) {
|
||||
Ok(f) => f,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
|
||||
return Err(format!(
|
||||
"Ziel existiert bereits: {}",
|
||||
target.strip_prefix(home).unwrap_or(&target).display()
|
||||
))
|
||||
}
|
||||
Err(e) => return Err(format!("{}: {e}", target.display())),
|
||||
};
|
||||
std::io::Write::write_all(&mut file, fm.as_bytes())
|
||||
.map_err(|e| format!("{}: {e}", target.display()))?;
|
||||
Ok(target.strip_prefix(home).unwrap().display().to_string())
|
||||
}
|
||||
|
||||
/// Schreibt den Panel-Body zurück in ein Archiv-Dokument (Quell-Verknüpfung
|
||||
/// des Dokument-Tabs): der Frontmatter-Block der Datei bleibt, der Rumpf wird
|
||||
/// ersetzt.
|
||||
pub(crate) fn write_body(path: &Path, text: &str) -> Result<(), String> {
|
||||
let doc = fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
let head = doc.len() - strip_frontmatter(&doc).len();
|
||||
let out = format!("{}{}\n", &doc[..head], text.trim_end());
|
||||
fs::write(path, out).map_err(|e| format!("{}: {e}", path.display()))
|
||||
}
|
||||
|
||||
/// Verschiebt bzw. benennt einen ganzen Ordner um (fs::rename des
|
||||
/// Verzeichnisses); Eltern des Ziels entstehen bei Bedarf.
|
||||
pub(crate) fn move_folder(home: &Path, folder: &str, to: &str) -> Result<(), String> {
|
||||
checked_rel(folder)?;
|
||||
checked_rel(to)?;
|
||||
let src = home.join(folder);
|
||||
if !src.is_dir() {
|
||||
return Err(format!("Ordner nicht gefunden: {folder}"));
|
||||
}
|
||||
let target = home.join(to);
|
||||
if target == src {
|
||||
return Ok(());
|
||||
}
|
||||
fresh_target(&target, home)?;
|
||||
if let Some(parent) = target.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
|
||||
}
|
||||
fs::rename(&src, &target).map_err(|e| format!("{}: {e}", src.display()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::domain::testutil::tmp_paths;
|
||||
|
||||
fn archiv() -> PathBuf {
|
||||
let home = tmp_paths().home.join("archiv");
|
||||
fs::create_dir_all(home.join("konzepte")).unwrap();
|
||||
fs::write(home.join("2026-07-19_1000-adr-logging.md"), "a").unwrap();
|
||||
fs::write(home.join("konzepte/2026-07-19_1005-notiz-deploy.md"), "b").unwrap();
|
||||
home
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verschieben_legt_ordner_an_und_liefert_relpath() {
|
||||
let home = archiv();
|
||||
let rel = move_doc(&home, "2026-07-19_1000-adr-logging.md", "adr/2026").unwrap();
|
||||
assert_eq!(rel, "adr/2026/2026-07-19_1000-adr-logging.md");
|
||||
assert!(home.join(&rel).is_file());
|
||||
|
||||
// In die Wurzel zurück: leerer Zielordner.
|
||||
let rel = move_doc(&home, &rel, "").unwrap();
|
||||
assert_eq!(rel, "2026-07-19_1000-adr-logging.md");
|
||||
|
||||
// Gleiches Ziel ist ein No-Op, kein Fehler.
|
||||
assert_eq!(move_doc(&home, &rel, "").unwrap(), rel);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verschieben_ueberschreibt_nicht() {
|
||||
let home = archiv();
|
||||
fs::write(home.join("konzepte/2026-07-19_1000-adr-logging.md"), "alt").unwrap();
|
||||
let err = move_doc(&home, "2026-07-19_1000-adr-logging.md", "konzepte").unwrap_err();
|
||||
assert!(err.contains("existiert bereits"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn traversal_und_fremde_pfade_brechen_ab() {
|
||||
let home = archiv();
|
||||
assert!(move_doc(&home, "../2026-07-19_1000-adr-logging.md", "x").is_err());
|
||||
assert!(move_doc(&home, "2026-07-19_1000-adr-logging.md", "../raus").is_err());
|
||||
assert!(move_doc(&home, "/etc/passwd.md", "x").is_err());
|
||||
assert!(delete_doc(&home, "konzepte").is_err()); // kein .md
|
||||
assert!(move_folder(&home, "konzepte", "../raus").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn umbenennen_erhaelt_stempel_und_sluggt() {
|
||||
let home = archiv();
|
||||
let rel =
|
||||
rename_doc(&home, "konzepte/2026-07-19_1005-notiz-deploy.md", "Deploy Nötiz!").unwrap();
|
||||
assert_eq!(rel, "konzepte/2026-07-19_1005-deploy-noetiz.md");
|
||||
assert!(home.join(&rel).is_file());
|
||||
|
||||
// Ohne Stempel bleibt der Name pur.
|
||||
fs::write(home.join("plain.md"), "c").unwrap();
|
||||
assert_eq!(rename_doc(&home, "plain.md", "Neu").unwrap(), "neu.md");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anlegen_ordner_und_dokument() {
|
||||
let home = archiv();
|
||||
create_folder(&home, "notizen/2026").unwrap();
|
||||
assert!(home.join("notizen/2026").is_dir());
|
||||
assert!(create_folder(&home, "../raus").is_err());
|
||||
|
||||
let rel = create_doc(&home, "notizen/2026", "Deploy Nötiz!", "proj").unwrap();
|
||||
assert_eq!(rel, "notizen/2026/deploy-noetiz.md");
|
||||
let text = fs::read_to_string(home.join(&rel)).unwrap();
|
||||
assert!(text.starts_with("---\ntitle: \"Deploy Nötiz!\"\n"));
|
||||
assert!(text.contains("created: "));
|
||||
|
||||
// In der Wurzel, gleicher Name kollidiert laut, leerer Name bricht ab.
|
||||
assert_eq!(create_doc(&home, "", "Plain", "proj").unwrap(), "plain.md");
|
||||
let err = create_doc(&home, "notizen/2026", "Deploy Nötiz!", "proj").unwrap_err();
|
||||
assert!(err.contains("existiert bereits"));
|
||||
assert!(create_doc(&home, "", " ", "proj").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_body_ersetzt_rumpf_und_erhaelt_frontmatter() {
|
||||
let home = archiv();
|
||||
let path = home.join("mit-fm.md");
|
||||
fs::write(&path, "---\ntitle: \"T\"\n---\n\nalter Text\n").unwrap();
|
||||
write_body(&path, "neuer Text").unwrap();
|
||||
assert_eq!(
|
||||
fs::read_to_string(&path).unwrap(),
|
||||
"---\ntitle: \"T\"\n---\n\nneuer Text\n"
|
||||
);
|
||||
|
||||
// Ohne Frontmatter wird die Datei komplett ersetzt.
|
||||
let plain = home.join("ohne-fm.md");
|
||||
fs::write(&plain, "alt").unwrap();
|
||||
write_body(&plain, "neu").unwrap();
|
||||
assert_eq!(fs::read_to_string(&plain).unwrap(), "neu\n");
|
||||
|
||||
assert!(write_body(&home.join("fehlt.md"), "x").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loeschen_und_ordner_verschieben() {
|
||||
let home = archiv();
|
||||
delete_doc(&home, "2026-07-19_1000-adr-logging.md").unwrap();
|
||||
assert!(!home.join("2026-07-19_1000-adr-logging.md").exists());
|
||||
|
||||
move_folder(&home, "konzepte", "notizen/2026").unwrap();
|
||||
assert!(home.join("notizen/2026/2026-07-19_1005-notiz-deploy.md").is_file());
|
||||
assert!(!home.join("konzepte").exists());
|
||||
|
||||
let err = move_folder(&home, "fehlt", "x").unwrap_err();
|
||||
assert!(err.contains("nicht gefunden"));
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
pub(crate) mod archive;
|
||||
pub(crate) mod archive_index;
|
||||
pub(crate) mod archive_ops;
|
||||
pub(crate) mod archive_search;
|
||||
pub(crate) mod credentials;
|
||||
pub(crate) mod modules;
|
||||
|
||||
@@ -71,6 +71,18 @@ pub(crate) fn panel_file(project: &str) -> PathBuf {
|
||||
Paths::real().panels_dir().join(format!("{}.md", checked(project)))
|
||||
}
|
||||
|
||||
/// Quell-Verknüpfung des Panel-Inhalts: absoluter Pfad des Archiv-Dokuments,
|
||||
/// aus dem der Dokument-Tab gerade geladen ist. Solange sie existiert,
|
||||
/// schreibt jeder Editor-Commit den Body dorthin zurück; ein frischer Entwurf
|
||||
/// (write_panel, Session-Start) entfernt sie. Liegt neben der Panel-Datei als
|
||||
/// `<panel>.source`, damit der MCP-Server sie aus AI_CONTROL_PANEL ableiten
|
||||
/// kann.
|
||||
pub(crate) fn panel_source_file(project: &str) -> PathBuf {
|
||||
Paths::real()
|
||||
.panels_dir()
|
||||
.join(format!("{}.md.source", checked(project)))
|
||||
}
|
||||
|
||||
/// Command-History eines Projekts (JSONL, anhängend — flüchtig, wird beim
|
||||
/// Session-Start geleert). Der Pfad landet als AI_CONTROL_COMMANDS in der
|
||||
/// PTY-Umgebung.
|
||||
|
||||
+10
-2
@@ -324,10 +324,18 @@ fn env_path(var: &str) -> Result<String, Value> {
|
||||
.map_err(|_| err(format!("{var} nicht gesetzt (Terminal außerhalb ai-control).")))
|
||||
}
|
||||
|
||||
/// Schreibt Text in die Panel-Datei aus AI_CONTROL_PANEL.
|
||||
/// Schreibt Text in die Panel-Datei aus AI_CONTROL_PANEL. Ein frischer
|
||||
/// Entwurf löst die Quell-Verknüpfung des Dokument-Tabs (`<panel>.source`,
|
||||
/// gesetzt beim Öffnen eines Archiv-Dokuments) — Edits gehören danach wieder
|
||||
/// nur dem Panel, nicht der zuletzt geöffneten Archiv-Datei.
|
||||
fn write_panel_text(text: &str) -> Result<(), Value> {
|
||||
let path = env_path("AI_CONTROL_PANEL")?;
|
||||
std::fs::write(&path, text).map_err(|e| err(format!("Panel-Datei nicht schreibbar: {e}")))
|
||||
std::fs::write(&path, text).map_err(|e| err(format!("Panel-Datei nicht schreibbar: {e}")))?;
|
||||
match std::fs::remove_file(format!("{path}.source")) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(e) => Err(err(format!("Quell-Verknüpfung nicht gelöst: {e}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn call_write(req: &Value) -> Value {
|
||||
|
||||
+174
-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, 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));
|
||||
|
||||
Reference in New Issue
Block a user