diff --git a/Cargo.lock b/Cargo.lock index 3827991..0de6385 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2330,6 +2330,7 @@ dependencies = [ "dioxus-code", "dioxus-code-editor", "dirs", + "rfd", "ron", "serde", "strum", diff --git a/Cargo.toml b/Cargo.toml index a70fd54..6400aa0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,10 @@ dioxus = { version = "0.7.1", features = ["router"] } dioxus-code = "0.1.2" dioxus-code-editor = { version = "0.1.2", features = ["all-languages"] } dirs = "6.0.0" +# Dieselbe Konfiguration, die dioxus-desktop ohnehin zieht. Mit den +# Default-Features kaemen neun Wayland-Pakete dazu, die xdg-portal nicht +# braucht — der Dialog laeuft unter Linux ueber das Portal. +rfd = { version = "0.17.2", default-features = false, features = ["xdg-portal"] } ron = "0.12.2" serde = { version = "1.0.229", features = ["derive"] } strum = { version = "0.28.0", features = ["derive"] } diff --git a/assets/css/components.css b/assets/css/components.css index e935786..6dba562 100644 --- a/assets/css/components.css +++ b/assets/css/components.css @@ -167,6 +167,56 @@ font-weight: 600; } +/* --- Dialog -------------------------------------------------------------------- + Für Unterbrechungen einer laufenden Aktion. Zustände gehören dagegen inline + an ihren Ort, nicht in ein zweites Fenster. + -------------------------------------------------------------------------- */ + +.dialog-scrim { + position: fixed; + inset: 0; + display: grid; + place-items: center; + padding: var(--dn-space-4); + background: rgb(0 0 0 / 0.55); + z-index: 50; +} + +.dialog { + width: min(28rem, 100%); + display: flex; + flex-direction: column; + gap: var(--dn-space-4); + padding: var(--dn-space-5); + border: 1px solid var(--dn-border-strong); + border-radius: var(--dn-radius-lg); + background: var(--dn-bg-sunken); + box-shadow: var(--dn-shadow); +} + +.dialog h2 { + font-size: var(--dn-text-md); + font-weight: 600; +} + +.dialog p { + color: var(--dn-fg-muted); + line-height: var(--dn-leading-normal); +} + +.dialog code { + font-family: var(--dn-font-code); + font-size: var(--dn-text-sm); + color: var(--dn-fg); + overflow-wrap: anywhere; +} + +.dialog-actions { + display: flex; + justify-content: flex-end; + gap: var(--dn-space-2); +} + /* --- Utilities ---------------------------------------------------------------- */ .visually-hidden { diff --git a/src/views/workspace_selector/selector.rs b/src/views/workspace_selector/selector.rs index e7b48ad..b3c94a0 100644 --- a/src/views/workspace_selector/selector.rs +++ b/src/views/workspace_selector/selector.rs @@ -1,24 +1,126 @@ use dioxus::prelude::*; +use std::path::PathBuf; + +use crate::route::Route; +use crate::workspace::{dir_is_empty, Workspace}; +use crate::workspace_list::WorkspaceList; /// Der Bildschirm vor dem Editor: einen bekannten Workspace öffnen oder einen /// hinzufügen. Eine einzige Route, keine Reiter — die Workspace-Liste führt, /// das Hinzufügen ist Nebenaktion. #[component] pub fn Selector() -> Element { + let mut workspace = use_context::>>(); + let mut list = use_signal(WorkspaceList::load); + let navigator = use_navigator(); + + // Ordner, der auf die Bestätigung „ist nicht leer" wartet. + let mut pending = use_signal(|| None::); + let mut error = use_signal(|| None::); + + // Übernimmt einen Workspace: Liste voranstellen und sichern, in den + // Context legen, in den Editor wechseln. + let mut accept = move |opened: Workspace| { + list.write().promote(opened.root.clone()); + if let Err(err) = list.read().save() { + // Kein Abbruch: Der Workspace ist offen, nur das Merken schlug fehl. + error.set(Some(format!("Der Workspace ist offen, aber die Liste ließ sich nicht speichern: {err}"))); + } + workspace.set(Some(opened)); + navigator.push(Route::Home {}); + }; + + // Wird sowohl nach dem Ordner-Dialog als auch nach der Rückfrage gebraucht. + let mut create_at = move |root: PathBuf| match Workspace::create(root) { + Ok(opened) => accept(opened), + Err(err) => error.set(Some(format!("Anlegen fehlgeschlagen: {err}"))), + }; + + let choose = move |_| async move { + // Abbruch liefert None und ist kein Fehler — keine Meldung. + let Some(handle) = rfd::AsyncFileDialog::new().pick_folder().await else { + return; + }; + let root = handle.path().to_path_buf(); + error.set(None); + + // Ein bereits vorhandener Workspace wird geöffnet, nicht abgelehnt: + // derselbe Knopf nimmt neue und wiedergefundene Workspaces auf. + if Workspace::has_marker(&root) { + match Workspace::open(root) { + Ok(opened) => accept(opened), + // Die Display-Impl benennt den Zustand bereits: „beschädigt", + // „kein Workspace mehr", „nicht erreichbar". + Err(err) => error.set(Some(format!("Öffnen fehlgeschlagen: {err}"))), + } + return; + } + + match dir_is_empty(&root) { + Ok(true) => create_at(root), + // Nicht leer ist der Normalfall beim Import — deshalb eine + // Rückfrage, keine Ablehnung. + Ok(false) => pending.set(Some(root)), + Err(err) => error.set(Some(format!("Ordner nicht lesbar: {err}"))), + } + }; + rsx! { div { class: "workspace-selector-layout", div { class: "workspace-selector-view", - // TODO: Workspace-Liste aus workspaces.ron laden und darstellen - // (Ordnername, Pfad darunter, Statusabzeichen). Bis dahin nur - // der leere Zustand. + // TODO: Workspace-Liste darstellen (Ordnername, Pfad, Statusabzeichen) + // statt des leeren Zustands, sobald Einträge vorhanden sind. div { class: "empty-state", span { class: "empty-state-title", "Noch kein Workspace" } span { "Der erste Workspace ist ein Ordner deiner Wahl." } - // TODO: Nativen Ordner-Dialog anbinden, Marker anlegen und - // den Workspace in den Context schreiben. - button { class: "btn btn-primary", disabled: true, + button { class: "btn btn-primary", onclick: choose, "Workspace hinzufügen…" } + if let Some(message) = error() { + span { class: "field-error", "{message}" } + } + } + } + } + + if let Some(root) = pending() { + NotEmptyDialog { + root: root.clone(), + onconfirm: move |root| { + pending.set(None); + create_at(root); + }, + oncancel: move |_| pending.set(None), + } + } + } +} + +/// Rückfrage vor dem Anlegen in einem Ordner, der bereits Dateien enthält. +/// Sie soll informieren, nicht beunruhigen — deshalb steht ausdrücklich da, +/// dass nichts Vorhandenes verändert wird. +#[component] +fn NotEmptyDialog(root: PathBuf, onconfirm: EventHandler, oncancel: EventHandler<()>) -> Element { + let shown = root.display().to_string(); + let confirmed = root.clone(); + + rsx! { + div { class: "dialog-scrim", + div { class: "dialog", + h2 { "Ordner ist nicht leer" } + p { + code { "{shown}" } + " enthält bereits Dateien. DioNotes legt darin nur den Ordner " + code { ".dionotes" } + " an und verändert nichts Vorhandenes." + } + div { class: "dialog-actions", + button { class: "btn btn-ghost", onclick: move |_| oncancel.call(()), "Abbrechen" } + button { + class: "btn btn-primary", + onclick: move |_| onconfirm.call(confirmed.clone()), + "Workspace anlegen" + } } } } diff --git a/src/workspace.rs b/src/workspace.rs index 15e8217..783f145 100644 --- a/src/workspace.rs +++ b/src/workspace.rs @@ -1,4 +1,26 @@ -use std::path::PathBuf; +use serde::{Deserialize, Serialize}; +use std::fmt; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; + +const MARKER_DIR: &str = ".dionotes"; +const MARKER_FILE: &str = "workspace.ron"; + +/// Aktuelle Fassung des Marker-Schemas. Erhöhen, sobald Felder dazukommen, +/// die eine ältere App nicht bloß ignorieren kann. +const SCHEMA_VERSION: u32 = 1; + +/// Inhalt von `.dionotes/workspace.ron`. +/// +/// Heute genau ein Feld. Kein `created_at` — das Erstellungsdatum kennt das +/// Dateisystem, es hier zu duplizieren schafft eine zweite Wahrheit, die beim +/// Kopieren des Ordners auseinanderläuft. Weitere Felder entstehen mit dem +/// Feature, das sie braucht; `#[serde(default)]` macht das bruchfrei. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +struct Marker { + schema_version: u32, +} /// Ein geöffneter Workspace — der Ordner auf der Platte, in dem die Notizen liegen. /// @@ -10,6 +32,50 @@ pub struct Workspace { pub root: PathBuf, } +/// Warum ein Ordner sich nicht als Workspace öffnen ließ. +/// +/// Die ersten drei Varianten sind genau die Zustände, mit denen ein +/// Listeneintrag markiert wird — sie führen zu verschiedenen Handlungen: +/// Laufwerk einhängen, Ordner wiederherstellen, Datei reparieren. +#[derive(Debug)] +pub enum OpenError { + /// Der Ordner existiert nicht. Meist ein abgezogener Stick oder ein nicht + /// eingehängtes Netzlaufwerk — deshalb wird der Eintrag nie automatisch + /// aus der Liste entfernt. + Unreachable, + /// Der Ordner ist da, trägt aber keinen Marker. + NotAWorkspace, + /// Marker vorhanden, aber nicht lesbar. Der Workspace wird **nicht** + /// geöffnet und der Marker **nicht** neu angelegt: Sobald dort ein + /// Passwort-Hash liegt, wäre stillschweigendes Neuanlegen ein Weg, den + /// Schutz auszuhebeln. + Damaged, + /// Der Marker stammt aus einer neueren Fassung der App. + TooNew { found: u32 }, + Io(io::Error), +} + +impl fmt::Display for OpenError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Unreachable => write!(f, "nicht erreichbar"), + Self::NotAWorkspace => write!(f, "kein Workspace mehr"), + Self::Damaged => write!(f, "beschädigt"), + Self::TooNew { found } => write!( + f, + "mit einer neueren Version angelegt (Schema {found}, unterstützt wird {SCHEMA_VERSION})" + ), + Self::Io(err) => write!(f, "{err}"), + } + } +} + +impl From for OpenError { + fn from(err: io::Error) -> Self { + Self::Io(err) + } +} + impl Workspace { /// Anzeigename: der Ordnername. Bewusst abgeleitet statt gespeichert — /// ein eigenes Namensfeld wäre nach dem ersten Umbenennen des Ordners falsch. @@ -19,16 +85,138 @@ impl Workspace { .map(|n| n.to_string_lossy().into_owned()) .unwrap_or_else(|| self.root.to_string_lossy().into_owned()) } + + fn marker_path(root: &Path) -> PathBuf { + root.join(MARKER_DIR).join(MARKER_FILE) + } + + /// Ob dieser Ordner einen Marker trägt — die Frage „ist das hier überhaupt + /// ein Workspace?". Sagt nichts darüber, ob der Marker lesbar ist. + pub fn has_marker(root: &Path) -> bool { + Self::marker_path(root).is_file() + } + + /// Einen bestehenden Workspace öffnen. + pub fn open(root: PathBuf) -> Result { + if !root.is_dir() { + return Err(OpenError::Unreachable); + } + let marker_path = Self::marker_path(&root); + if !marker_path.is_file() { + return Err(OpenError::NotAWorkspace); + } + + let text = fs::read_to_string(&marker_path).map_err(|_| OpenError::Damaged)?; + let marker: Marker = ron::from_str(&text).map_err(|_| OpenError::Damaged)?; + if marker.schema_version > SCHEMA_VERSION { + return Err(OpenError::TooNew { found: marker.schema_version }); + } + + Ok(Self { root }) + } + + /// Einen Ordner zum Workspace machen: `.dionotes/workspace.ron` anlegen. + /// + /// Vorhandene Dateien im Ordner bleiben unberührt — der Marker ist additiv. + pub fn create(root: PathBuf) -> Result { + if !root.is_dir() { + return Err(OpenError::Unreachable); + } + let marker_path = Self::marker_path(&root); + if let Some(dir) = marker_path.parent() { + fs::create_dir_all(dir)?; + } + let marker = Marker { schema_version: SCHEMA_VERSION }; + let text = ron::ser::to_string_pretty(&marker, ron::ser::PrettyConfig::default()) + .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?; + fs::write(&marker_path, text)?; + Ok(Self { root }) + } +} + +/// Ob ein Ordner leer ist — Grundlage für die Rückfrage vor dem Anlegen. +pub fn dir_is_empty(path: &Path) -> io::Result { + Ok(fs::read_dir(path)?.next().is_none()) } #[cfg(test)] mod tests { - use super::Workspace; + use super::{dir_is_empty, OpenError, Workspace}; + use std::fs; use std::path::PathBuf; + /// Frisches Testverzeichnis. Der Testname macht es eindeutig, deshalb + /// braucht es keine Zufallszahl und keine tempfile-Crate. + fn scratch(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("dionotes-test-{name}")); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).expect("Testverzeichnis anlegbar"); + dir + } + #[test] fn name_is_the_folder_name() { let ws = Workspace { root: PathBuf::from("C:/Users/marvin/Notizen") }; assert_eq!(ws.name(), "Notizen"); } + + #[test] + fn create_then_open_roundtrip() { + let dir = scratch("create-open"); + let created = Workspace::create(dir.clone()).expect("anlegbar"); + assert!(Workspace::has_marker(&dir)); + assert_eq!(Workspace::open(dir.clone()).expect("oeffenbar"), created); + } + + #[test] + fn create_leaves_existing_files_alone() { + let dir = scratch("create-keeps-files"); + fs::write(dir.join("notiz.md"), "# Hallo").unwrap(); + Workspace::create(dir.clone()).expect("anlegbar"); + assert_eq!(fs::read_to_string(dir.join("notiz.md")).unwrap(), "# Hallo"); + } + + #[test] + fn missing_folder_is_unreachable() { + let dir = scratch("gone").join("weg"); + assert!(matches!(Workspace::open(dir), Err(OpenError::Unreachable))); + } + + #[test] + fn folder_without_marker_is_not_a_workspace() { + let dir = scratch("no-marker"); + assert!(matches!(Workspace::open(dir), Err(OpenError::NotAWorkspace))); + } + + #[test] + fn unreadable_marker_is_damaged_and_not_recreated() { + let dir = scratch("damaged"); + Workspace::create(dir.clone()).expect("anlegbar"); + let marker = dir.join(".dionotes").join("workspace.ron"); + fs::write(&marker, "kaputt").unwrap(); + + assert!(matches!(Workspace::open(dir), Err(OpenError::Damaged))); + // Der Marker wurde nicht stillschweigend ersetzt. + assert_eq!(fs::read_to_string(&marker).unwrap(), "kaputt"); + } + + #[test] + fn newer_schema_is_refused_with_the_version() { + let dir = scratch("too-new"); + Workspace::create(dir.clone()).expect("anlegbar"); + fs::write(dir.join(".dionotes").join("workspace.ron"), "(schema_version: 99)").unwrap(); + + match Workspace::open(dir) { + Err(OpenError::TooNew { found }) => assert_eq!(found, 99), + other => panic!("erwartet TooNew, war {other:?}"), + } + } + + #[test] + fn empty_folder_is_recognised() { + let dir = scratch("emptiness"); + assert!(dir_is_empty(&dir).unwrap()); + fs::write(dir.join("etwas.txt"), "x").unwrap(); + assert!(!dir_is_empty(&dir).unwrap()); + } }