Files
dionotes-client/src/views/workspace_selector/selector.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

221 lines
8.3 KiB
Rust

use dioxus::prelude::*;
use std::path::PathBuf;
use crate::route::Route;
use crate::workspace::{dir_is_empty, OpenError, Workspace};
use crate::workspace_list::WorkspaceList;
/// Der Bildschirm vor dem Editor. Eine Ansicht, keine Reiter: Die Liste der
/// bekannten Workspaces führt, das Hinzufügen ist Nebenaktion in der Fußzeile.
/// Öffnen ist der Alltagsfall, Hinzufügen passiert pro Workspace einmal.
#[component]
pub fn Selector() -> Element {
let mut workspace = use_context::<Signal<Option<Workspace>>>();
let mut list = use_signal(WorkspaceList::load);
let navigator = use_navigator();
let mut pending = use_signal(|| None::<PathBuf>);
let mut error = use_signal(|| None::<String>);
// Zustandsprüfung der Einträge, nebenläufig und auf einem eigenen Thread:
// Auf einem nicht eingehängten Netzlaufwerk läuft schon `is_dir()` in ein
// Timeout von Sekunden, und das würde sonst den Selector einfrieren —
// ausgerechnet den Bildschirm, den jeder Start zuerst zeigt.
//
// spawn_blocking setzt die Tokio-Runtime voraus, die dioxus-desktop
// mitbringt. Für die web-/mobile-Features müsste das anders laufen —
// die sind laut Entscheidung in Issue #1 aber ausdrücklich nicht Ziel.
let probed = use_resource(move || {
let paths = list.read().entries().to_vec();
async move {
tokio::task::spawn_blocking(move || {
paths
.into_iter()
.map(|path| {
let state = Workspace::open(path.clone()).err();
(path, state)
})
.collect::<Vec<_>>()
})
.await
.unwrap_or_default()
}
});
let mut accept = move |opened: Workspace| {
list.write().promote(opened.root.clone());
if let Err(err) = list.read().save() {
error.set(Some(format!(
"Der Workspace ist offen, aber die Liste ließ sich nicht speichern: {err}"
)));
}
workspace.set(Some(opened));
navigator.push(Route::Home {});
};
let mut create_at = move |root: PathBuf| match Workspace::create(root) {
Ok(opened) => accept(opened),
Err(err) => error.set(Some(format!("Anlegen fehlgeschlagen: {err}"))),
};
// Ein Klick versucht es erneut — damit ist „Stick wieder eingesteckt"
// ohne eigenen Knopf abgedeckt.
let mut open_entry = move |root: PathBuf| match Workspace::open(root) {
Ok(opened) => accept(opened),
Err(err) => error.set(Some(format!("Öffnen 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 vorhandener Workspace wird geöffnet, nicht abgelehnt: derselbe
// Knopf nimmt neue und wiedergefundene Workspaces auf.
if Workspace::has_marker(&root) {
open_entry(root);
return;
}
match dir_is_empty(&root) {
Ok(true) => create_at(root),
// Nicht leer ist beim Import der Normalfall, keine Ausnahme.
Ok(false) => pending.set(Some(root)),
Err(err) => error.set(Some(format!("Ordner nicht lesbar: {err}"))),
}
};
let paths = list.read().entries().to_vec();
let states = probed.read().clone();
// Solange die Prüfung läuft, bleiben die Einträge unmarkiert. Die lineare
// Suche ist bei einer Workspace-Liste billiger als eine Map aufzubauen.
let state_of = |path: &PathBuf| -> Option<OpenError> {
states
.as_ref()?
.iter()
.find(|(known, _)| known == path)
.and_then(|(_, state)| state.clone())
};
let is_empty = paths.is_empty();
rsx! {
div { class: "selector-shell",
div { class: "selector-head",
span { class: "selector-title", "Workspace öffnen" }
if !is_empty {
span { class: "field-hint", "{paths.len()} bekannt" }
}
}
div { class: "selector-list-wrap",
if is_empty {
div { class: "empty-state",
span { class: "empty-state-title", "Hier ist noch nichts" }
span { "Der erste Workspace ist ein Ordner deiner Wahl." }
}
} else {
div { class: "ws-list",
for path in paths.iter() {
WorkspaceEntry {
key: "{path.display()}",
path: path.clone(),
state: state_of(path),
onopen: move |path| open_entry(path),
}
}
}
}
}
div { class: "selector-foot",
span { class: "field-hint", "Ein Workspace ist ein Ordner mit Notizen." }
button {
class: if is_empty { "btn btn-primary" } else { "btn" },
onclick: choose,
"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),
}
}
}
}
/// Ein Eintrag der Workspace-Liste: Ordnername, darunter der Pfad, rechts bei
/// Bedarf das Statusabzeichen. Ein defekter Eintrag bleibt anklickbar und wird
/// nie automatisch entfernt — meist fehlt nur gerade das Laufwerk.
#[component]
fn WorkspaceEntry(path: PathBuf, state: Option<OpenError>, onopen: EventHandler<PathBuf>) -> Element {
let name = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| path.to_string_lossy().into_owned());
let full = path.display().to_string();
let clicked = path.clone();
// Klasse und Beschriftung des Abzeichens vorab, damit rsx! den Zustand
// nicht zweimal gleichzeitig braucht.
let broken = state.is_some();
let badge = state.as_ref().map(|state| (state.badge_class(), state.to_string()));
rsx! {
button {
class: if broken { "ws-item is-broken" } else { "ws-item" },
onclick: move |_| onopen.call(clicked.clone()),
span { class: "ws-item-body",
span { class: "ws-item-name", "{name}" }
span { class: "ws-item-path", "{full}" }
}
if let Some((class, label)) = badge {
span { class: "ws-badge {class}", "{label}" }
}
}
}
}
/// 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"
}
}
}
}
}
}