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::>>(); let mut list = use_signal(WorkspaceList::load); let navigator = use_navigator(); let mut pending = use_signal(|| None::); let mut error = use_signal(|| None::); // 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::>() }) .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 { 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, onopen: EventHandler) -> 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, 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" } } } } } }