Merge pull request 'Ordner-Dialog und Anlegen-Flow' (#12) from feat/ordner-dialog into feat/workspace-liste

Reviewed-on: #12
This commit was merged in pull request #12.
This commit is contained in:
2026-08-28 07:58:22 +00:00
5 changed files with 353 additions and 8 deletions

1
Cargo.lock generated
View File

@@ -2330,6 +2330,7 @@ dependencies = [
"dioxus-code", "dioxus-code",
"dioxus-code-editor", "dioxus-code-editor",
"dirs", "dirs",
"rfd",
"ron", "ron",
"serde", "serde",
"strum", "strum",

View File

@@ -11,6 +11,10 @@ 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" 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" ron = "0.12.2"
serde = { version = "1.0.229", features = ["derive"] } serde = { version = "1.0.229", features = ["derive"] }
strum = { version = "0.28.0", features = ["derive"] } strum = { version = "0.28.0", features = ["derive"] }

View File

@@ -167,6 +167,56 @@
font-weight: 600; 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 ---------------------------------------------------------------- */ /* --- Utilities ---------------------------------------------------------------- */
.visually-hidden { .visually-hidden {

View File

@@ -1,24 +1,126 @@
use dioxus::prelude::*; 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 /// Der Bildschirm vor dem Editor: einen bekannten Workspace öffnen oder einen
/// hinzufügen. Eine einzige Route, keine Reiter — die Workspace-Liste führt, /// hinzufügen. Eine einzige Route, keine Reiter — die Workspace-Liste führt,
/// das Hinzufügen ist Nebenaktion. /// das Hinzufügen ist Nebenaktion.
#[component] #[component]
pub fn Selector() -> Element { pub fn Selector() -> Element {
let mut workspace = use_context::<Signal<Option<Workspace>>>();
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::<PathBuf>);
let mut error = use_signal(|| None::<String>);
// Ü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! { rsx! {
div { class: "workspace-selector-layout", div { class: "workspace-selector-layout",
div { class: "workspace-selector-view", div { class: "workspace-selector-view",
// TODO: Workspace-Liste aus workspaces.ron laden und darstellen // TODO: Workspace-Liste darstellen (Ordnername, Pfad, Statusabzeichen)
// (Ordnername, Pfad darunter, Statusabzeichen). Bis dahin nur // statt des leeren Zustands, sobald Einträge vorhanden sind.
// der leere Zustand.
div { class: "empty-state", div { class: "empty-state",
span { class: "empty-state-title", "Noch kein Workspace" } span { class: "empty-state-title", "Noch kein Workspace" }
span { "Der erste Workspace ist ein Ordner deiner Wahl." } span { "Der erste Workspace ist ein Ordner deiner Wahl." }
// TODO: Nativen Ordner-Dialog anbinden, Marker anlegen und button { class: "btn btn-primary", onclick: choose,
// den Workspace in den Context schreiben.
button { class: "btn btn-primary", disabled: true,
"Workspace hinzufügen…" "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<PathBuf>, 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"
}
} }
} }
} }

View File

@@ -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. /// Ein geöffneter Workspace — der Ordner auf der Platte, in dem die Notizen liegen.
/// ///
@@ -10,6 +32,50 @@ pub struct Workspace {
pub root: PathBuf, 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<io::Error> for OpenError {
fn from(err: io::Error) -> Self {
Self::Io(err)
}
}
impl Workspace { impl Workspace {
/// Anzeigename: der Ordnername. Bewusst abgeleitet statt gespeichert — /// Anzeigename: der Ordnername. Bewusst abgeleitet statt gespeichert —
/// ein eigenes Namensfeld wäre nach dem ersten Umbenennen des Ordners falsch. /// 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()) .map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| self.root.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)] #[cfg(test)]
mod tests { mod tests {
use super::Workspace; use super::{dir_is_empty, OpenError, Workspace};
use std::fs;
use std::path::PathBuf; 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] #[test]
fn name_is_the_folder_name() { fn name_is_the_folder_name() {
let ws = Workspace { root: PathBuf::from("C:/Users/marvin/Notizen") }; let ws = Workspace { root: PathBuf::from("C:/Users/marvin/Notizen") };
assert_eq!(ws.name(), "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());
}
} }