diff --git a/Cargo.lock b/Cargo.lock index f8ce4ed..3827991 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2329,6 +2329,9 @@ dependencies = [ "dioxus", "dioxus-code", "dioxus-code-editor", + "dirs", + "ron", + "serde", "strum", ] @@ -5886,6 +5889,20 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "ron" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81116b9531d61eabc41aeb228e4b6b2435bcca3233b98cf3b3077d4e6e9debb3" +dependencies = [ + "bitflags 2.13.1", + "once_cell", + "serde", + "serde_derive", + "typeid", + "unicode-ident", +] + [[package]] name = "rustc-hash" version = "1.1.0" @@ -7012,6 +7029,12 @@ dependencies = [ "utf-8", ] +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + [[package]] name = "typenum" version = "1.20.1" diff --git a/Cargo.toml b/Cargo.toml index 28a1d47..a70fd54 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,9 @@ edition = "2021" 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" +ron = "0.12.2" +serde = { version = "1.0.229", features = ["derive"] } strum = { version = "0.28.0", features = ["derive"] } [features] diff --git a/assets/tailwind.css b/assets/tailwind.css index 5651d50..e6b5b18 100644 --- a/assets/tailwind.css +++ b/assets/tailwind.css @@ -171,6 +171,9 @@ .visible { visibility: visible; } + .absolute { + position: absolute; + } .relative { position: relative; } diff --git a/src/main.rs b/src/main.rs index 7e18e53..96e8f36 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ mod components; mod route; mod views; mod workspace; +mod workspace_list; use dioxus::prelude::*; diff --git a/src/workspace_list.rs b/src/workspace_list.rs new file mode 100644 index 0000000..8dc4464 --- /dev/null +++ b/src/workspace_list.rs @@ -0,0 +1,130 @@ +use serde::{Deserialize, Serialize}; +use std::fs; +use std::io; +use std::path::PathBuf; + +/// Verzeichnisname der App. Überall kleingeschrieben — unter Linux ist das +/// Konvention, unter Windows in `%LOCALAPPDATA%` unauffällig. Eine Crate nur +/// für diese Normalisierung (`directories`) wäre der teurere Weg gewesen. +const APP_DIR: &str = "dionotes"; +const FILE_NAME: &str = "workspaces.ron"; + +/// Die anwendungsweite Liste bekannter Workspaces. +/// +/// Sie enthält nur Pfade: Der Anzeigename ist der Ordnername und wäre als +/// eigenes Feld nach dem ersten Umbenennen falsch. Auch keinen Zeitstempel — +/// die **Reihenfolge ist die Sortierung**, zuletzt geöffnet steht vorn. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct WorkspaceList { + workspaces: Vec, +} + +impl WorkspaceList { + pub fn entries(&self) -> &[PathBuf] { + &self.workspaces + } + + /// Hebt einen Workspace an die Spitze — beim Öffnen wie beim Hinzufügen. + /// Weil vorher gefiltert wird, kann derselbe Pfad nicht doppelt vorkommen. + pub fn promote(&mut self, path: PathBuf) { + self.workspaces.retain(|known| known != &path); + self.workspaces.insert(0, path); + } + + /// Wo die Liste liegt. + /// + /// Ein Ausdruck für alle Plattformen: `state_dir()` liefert nur unter Linux + /// `Some` — dort führt die XDG-Spec „recently used files" ausdrücklich als + /// State, gehört also nach `$XDG_STATE_HOME` und nicht nach `~/.config`. + /// Windows und macOS fallen auf das lokale Konfigurationsverzeichnis durch; + /// unter Windows bewusst `%LOCALAPPDATA%` statt Roaming, weil absolute + /// Workspace-Pfade maschinenspezifisch sind. + pub fn path() -> Option { + let base = dirs::state_dir().or_else(dirs::config_local_dir)?; + Some(base.join(APP_DIR).join(FILE_NAME)) + } + + /// Lesen. Eine fehlende oder unlesbare Datei ergibt eine leere Liste statt + /// eines Fehlers: Sie enthält nichts Unwiederbringliches — der Nutzer wählt + /// seine Ordner einmal neu, seine Notizen sind nicht betroffen. Deshalb + /// braucht die Datei auch keine Schema-Version. + pub fn load() -> Self { + Self::path() + .and_then(|path| fs::read_to_string(path).ok()) + .map(|text| Self::parse(&text)) + .unwrap_or_default() + } + + pub fn save(&self) -> io::Result<()> { + let path = Self::path().ok_or_else(|| { + io::Error::new(io::ErrorKind::NotFound, "kein Konfigurationsverzeichnis") + })?; + // Das Basisverzeichnis existiert garantiert, das der App nicht. + if let Some(dir) = path.parent() { + fs::create_dir_all(dir)?; + } + fs::write(path, self.to_ron()?) + } + + fn parse(text: &str) -> Self { + ron::from_str(text).unwrap_or_default() + } + + fn to_ron(&self) -> io::Result { + ron::ser::to_string_pretty(self, ron::ser::PrettyConfig::default()) + .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err)) + } +} + +#[cfg(test)] +mod tests { + use super::WorkspaceList; + use std::path::PathBuf; + + fn list_of(paths: &[&str]) -> WorkspaceList { + let mut list = WorkspaceList::default(); + // rückwärts, damit die Reihenfolge am Ende der Eingabe entspricht + for p in paths.iter().rev() { + list.promote(PathBuf::from(p)); + } + list + } + + #[test] + fn promote_puts_new_entries_first() { + let mut list = list_of(&["a", "b"]); + list.promote(PathBuf::from("c")); + assert_eq!(list.entries(), [PathBuf::from("c"), PathBuf::from("a"), PathBuf::from("b")]); + } + + #[test] + fn promote_moves_known_entries_without_duplicating() { + let mut list = list_of(&["a", "b", "c"]); + list.promote(PathBuf::from("c")); + assert_eq!(list.entries(), [PathBuf::from("c"), PathBuf::from("a"), PathBuf::from("b")]); + } + + #[test] + fn survives_a_ron_roundtrip_with_windows_paths() { + let list = list_of(&[r"C:\Users\marvin\Notizen", r"D:\Projekte\Test"]); + let text = list.to_ron().expect("serialisierbar"); + assert_eq!(WorkspaceList::parse(&text), list); + } + + #[test] + fn path_lands_in_the_app_directory() { + let path = WorkspaceList::path().expect("Konfigurationsverzeichnis vorhanden"); + assert!( + path.ends_with("dionotes/workspaces.ron"), + "unerwarteter Ablageort: {}", + path.display() + ); + println!("Ablageort auf dieser Plattform: {}", path.display()); + } + + #[test] + fn unreadable_content_yields_an_empty_list() { + assert_eq!(WorkspaceList::parse("kein RON, nur Text"), WorkspaceList::default()); + assert_eq!(WorkspaceList::parse(""), WorkspaceList::default()); + } +}