Files
dionotes-client/src/workspace.rs
MDietermann 570c804d3f feat: Selector-UI nach der gewaehlten Prototyp-Variante
Setzt Issue #8 um: eine Ansicht statt Reiter. Die Liste der bekannten
Workspaces fuehrt, das Hinzufuegen sitzt als Nebenaktion in der Fusszeile und
wird nur beim leeren Erststart zur Primaeraktion. Oeffnen ist der
Alltagsfall, Hinzufuegen passiert pro Workspace genau einmal - zwei
gleichrangige Reiter haetten bei jedem Start eine Entscheidung verlangt, die
fast immer dieselbe ist.

Ein Eintrag zeigt den Ordnernamen, darunter den Pfad in der Code-Schrift. Der
Pfad wird bei Ueberlaenge vorne gekuerzt, weil sein Ende mehr aussagt als sein
Anfang.

Die Zustandspruefung laeuft nebenlaeufig ueber use_resource und
spawn_blocking. Ohne das wuerde ein nicht eingehaengtes Netzlaufwerk den
Selector einfrieren: dort laeuft schon is_dir() in ein Timeout von Sekunden,
und zwar auf dem Bildschirm, den jeder Start zuerst zeigt. Solange die
Pruefung laeuft, bleiben die Eintraege unmarkiert.

Die drei Zustaende aus #6 bekommen eigene Farben und Formulierungen - nicht
erreichbar, kein Workspace mehr, beschaedigt -, weil sie zu verschiedenen
Handlungen fuehren. Ein defekter Eintrag wird gedaempft, bleibt aber
anklickbar und verschwindet nie automatisch: Meist fehlt nur gerade das
Laufwerk, und ein Klick versucht es erneut.

OpenError ist dafuer Clone und PartialEq geworden; Io traegt jetzt die
Meldung als String statt io::Error, der beides nicht kann.
2026-08-28 10:38:13 +02:00

234 lines
8.2 KiB
Rust

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.
///
/// Heute genau ein Feld. Der Typ existiert trotzdem als eigener Name, weil
/// `CONTEXT.md` „Workspace" als Domänenbegriff führt und die Einstellungen aus
/// `.dionotes/workspace.ron` hier andocken werden.
#[derive(Debug, Clone, PartialEq)]
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, Clone, PartialEq)]
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(String),
}
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<io::Error> for OpenError {
fn from(err: io::Error) -> Self {
Self::Io(err.to_string())
}
}
impl OpenError {
/// CSS-Klasse fuer das Statusabzeichen am Listeneintrag.
pub fn badge_class(&self) -> &'static str {
match self {
Self::Unreachable => "unreachable",
Self::NotAWorkspace => "no-marker",
Self::Damaged | Self::TooNew { .. } | Self::Io(_) => "damaged",
}
}
}
impl Workspace {
/// Anzeigename: der Ordnername. Bewusst abgeleitet statt gespeichert —
/// ein eigenes Namensfeld wäre nach dem ersten Umbenennen des Ordners falsch.
pub fn name(&self) -> String {
self.root
.file_name()
.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<Self, OpenError> {
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<Self, OpenError> {
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<bool> {
Ok(fs::read_dir(path)?.next().is_none())
}
#[cfg(test)]
mod tests {
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());
}
}