From 71ff9534adb3d50d117baebce033448ebb807cb3 Mon Sep 17 00:00:00 2001 From: MDietermann Date: Fri, 28 Aug 2026 09:38:38 +0200 Subject: [PATCH] feat: Workspace-Liste laden und speichern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die anwendungsweite Liste bekannter Workspaces, so wie sie in den Issues #3 und #4 festgelegt wurde. Bewusst ohne Oberfläche, damit dieser Teil sich mit echten Tests pruefen laesst statt nur mit cargo check. Ablageort ueber einen einzigen Ausdruck fuer alle Plattformen: state_dir().or_else(config_local_dir) - state_dir() liefert nur unter Linux Some, wo die XDG-Spec "recently used files" ausdruecklich als State fuehrt, also nach $XDG_STATE_HOME und nicht nach ~/.config. Unter Windows faellt es auf %LOCALAPPDATA% durch, bewusst nicht Roaming: absolute Workspace-Pfade sind maschinenspezifisch und zeigten auf einem anderen Rechner ins Leere. Verifiziert, ein Test haelt den Ort fest. Die Liste enthaelt nur Pfade. Kein Anzeigename, weil der Ordnername ihn liefert und ein eigenes Feld nach dem ersten Umbenennen falsch waere. Kein Zeitstempel, weil die Reihenfolge selbst die Sortierung ist - promote() hebt einen Eintrag an die Spitze und entfernt ihn vorher aus der Liste, wodurch Duplikate gar nicht erst entstehen koennen. Eine unlesbare Datei ergibt eine leere Liste statt eines Fehlers: Sie enthaelt nichts Unwiederbringliches, der Nutzer waehlt seine Ordner einmal neu. Genau deshalb braucht sie auch keine Schema-Version. Neu im Abhaengigkeitsgraph ist allein ron; dirs und serde lagen bereits transitiv vor. directories waere die teurere Variante gewesen - sein einziger Mehrwert waere die Kleinschreibung des Ordnernamens, die hier eine Konstante erledigt. --- Cargo.lock | 23 ++++++++ Cargo.toml | 3 + assets/tailwind.css | 26 +-------- src/main.rs | 1 + src/workspace_list.rs | 130 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 160 insertions(+), 23 deletions(-) create mode 100644 src/workspace_list.rs 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..7bc8045 100644 --- a/assets/tailwind.css +++ b/assets/tailwind.css @@ -7,15 +7,6 @@ 'Noto Color Emoji'; --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace; - --text-xs: 0.75rem; - --text-sm: 0.875rem; - --text-base: 1rem; - --text-lg: 1.125rem; - --text-xl: 1.25rem; - --leading-tight: 1.25; - --leading-normal: 1.5; - --radius-sm: 0.25rem; - --radius-lg: 0.5rem; --default-transition-duration: 150ms; --default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); --default-font-family: var(--font-sans); @@ -171,12 +162,12 @@ .visible { visibility: visible; } + .absolute { + position: absolute; + } .relative { position: relative; } - .static { - position: static; - } .container { width: 100%; @media (width >= 40rem) { @@ -195,9 +186,6 @@ max-width: 96rem; } } - .flex { - display: flex; - } .table { display: table; } @@ -214,9 +202,6 @@ border-style: var(--tw-border-style); border-width: 1px; } - .text-wrap { - text-wrap: wrap; - } .underline { text-decoration-line: underline; } @@ -224,11 +209,6 @@ outline-style: var(--tw-outline-style); outline-width: 1px; } - .transition { - transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to, opacity, box-shadow, transform, translate, scale, rotate, filter, -webkit-backdrop-filter, backdrop-filter, display, visibility, content-visibility, overlay, pointer-events; - transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); - transition-duration: var(--tw-duration, var(--default-transition-duration)); - } } @property --tw-rotate-x { syntax: "*"; 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()); + } +}