feat: Workspace-Liste laden und speichern

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.
This commit is contained in:
2026-08-28 09:38:38 +02:00
parent 91fd351d59
commit 7d0c7f93f1
5 changed files with 160 additions and 0 deletions

23
Cargo.lock generated
View File

@@ -2329,6 +2329,9 @@ dependencies = [
"dioxus", "dioxus",
"dioxus-code", "dioxus-code",
"dioxus-code-editor", "dioxus-code-editor",
"dirs",
"ron",
"serde",
"strum", "strum",
] ]
@@ -5886,6 +5889,20 @@ dependencies = [
"windows-sys 0.52.0", "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]] [[package]]
name = "rustc-hash" name = "rustc-hash"
version = "1.1.0" version = "1.1.0"
@@ -7012,6 +7029,12 @@ dependencies = [
"utf-8", "utf-8",
] ]
[[package]]
name = "typeid"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
[[package]] [[package]]
name = "typenum" name = "typenum"
version = "1.20.1" version = "1.20.1"

View File

@@ -10,6 +10,9 @@ edition = "2021"
dioxus = { version = "0.7.1", features = ["router"] } dioxus = { version = "0.7.1", features = ["router"] }
dioxus-code = "0.1.2" dioxus-code = "0.1.2"
dioxus-code-editor = { version = "0.1.2", features = ["all-languages"] } 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"] } strum = { version = "0.28.0", features = ["derive"] }
[features] [features]

View File

@@ -171,6 +171,9 @@
.visible { .visible {
visibility: visible; visibility: visible;
} }
.absolute {
position: absolute;
}
.relative { .relative {
position: relative; position: relative;
} }

View File

@@ -2,6 +2,7 @@ mod components;
mod route; mod route;
mod views; mod views;
mod workspace; mod workspace;
mod workspace_list;
use dioxus::prelude::*; use dioxus::prelude::*;

130
src/workspace_list.rs Normal file
View File

@@ -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<PathBuf>,
}
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<PathBuf> {
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<String> {
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());
}
}