diff --git a/Cargo.lock b/Cargo.lock index 0de6385..9144dc6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2334,6 +2334,7 @@ dependencies = [ "ron", "serde", "strum", + "tokio", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 6400aa0..3e02918 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ rfd = { version = "0.17.2", default-features = false, features = ["xdg-portal"] ron = "0.12.2" serde = { version = "1.0.229", features = ["derive"] } strum = { version = "0.28.0", features = ["derive"] } +tokio = { version = "1.53.1", features = ["rt"] } [features] default = ["desktop"] diff --git a/assets/css/components.css b/assets/css/components.css index 6dba562..a47eeaa 100644 --- a/assets/css/components.css +++ b/assets/css/components.css @@ -167,6 +167,92 @@ font-weight: 600; } +/* --- Workspace-Listeneintrag --------------------------------------------------- + Ordnername, darunter der Pfad, rechts bei Bedarf ein Statusabzeichen. Ein + defekter Eintrag wird gedämpft, bleibt aber anklickbar: Ein Klick versucht + es erneut, womit „Stick wieder eingesteckt" ohne eigenen Knopf abgedeckt ist. + -------------------------------------------------------------------------- */ + +.ws-list { + display: flex; + flex-direction: column; + gap: 1px; +} + +.ws-item { + display: flex; + align-items: center; + gap: var(--dn-space-3); + width: 100%; + padding: var(--dn-space-3); + border: 0; + border-radius: var(--dn-radius); + background: transparent; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; + transition: background-color var(--dn-dur-fast) var(--dn-ease); +} + +.ws-item:hover { + background: var(--dn-bg-raised); +} + +.ws-item-body { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.ws-item-name { + color: var(--dn-fg); + font-weight: 500; +} + +/* Bei Überlänge vorne kürzen: Das Ende eines Pfades sagt mehr als sein Anfang. */ +.ws-item-path { + direction: rtl; + text-align: left; + color: var(--dn-fg-faint); + font-family: var(--dn-font-code); + font-size: var(--dn-text-xs); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.ws-item.is-broken .ws-item-name { + color: var(--dn-fg-muted); +} + +.ws-badge { + flex: none; + padding: 2px var(--dn-space-2); + border-radius: 999px; + font-size: var(--dn-text-xs); + white-space: nowrap; +} + +/* Drei Zustände, drei Farben — sie führen zu verschiedenen Handlungen: + Laufwerk einhängen, Ordner wiederherstellen, Datei reparieren. */ +.ws-badge.unreachable { + background: color-mix(in oklab, var(--dn-warning) 18%, transparent); + color: var(--dn-warning); +} + +.ws-badge.no-marker { + background: color-mix(in oklab, var(--dn-fg-faint) 22%, transparent); + color: var(--dn-fg-muted); +} + +.ws-badge.damaged { + background: color-mix(in oklab, var(--dn-danger) 18%, transparent); + color: var(--dn-danger); +} + /* --- Dialog -------------------------------------------------------------------- Für Unterbrechungen einer laufenden Aktion. Zustände gehören dagegen inline an ihren Ort, nicht in ein zweites Fenster. diff --git a/assets/css/layout.css b/assets/css/layout.css index a45b77f..97e7cc3 100644 --- a/assets/css/layout.css +++ b/assets/css/layout.css @@ -10,29 +10,50 @@ } /* --- Workspace selector ---------------------------------------------------- - The pre-editor screen: pick or create a workspace. Centered, deliberately - sparse — it has exactly one job. + The pre-editor screen. One view, no tabs: the list of known workspaces + leads, adding one is the secondary action in the footer. Opening is the + everyday case; adding happens once per workspace. -------------------------------------------------------------------------- */ -.workspace-selector-layout { +.selector-shell { flex: 1; min-height: 0; + width: 100%; + max-width: 40rem; + margin: 0 auto; + padding: var(--dn-space-7) var(--dn-space-5); display: flex; flex-direction: column; - align-items: center; - justify-content: center; - gap: var(--dn-space-6); - padding: var(--dn-space-6); + gap: var(--dn-space-5); } -.workspace-selector-view { - width: 100%; - max-width: 34rem; - padding: var(--dn-space-6); - background: var(--dn-bg-sunken); - border: 1px solid var(--dn-border); - border-radius: var(--dn-radius-lg); - box-shadow: var(--dn-shadow); +.selector-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: var(--dn-space-4); +} + +.selector-title { + font-size: var(--dn-text-xl); + font-weight: 600; +} + +/* Nur die Liste scrollt — Kopf und Fuß bleiben stehen. */ +.selector-list-wrap { + flex: 1; + min-height: 0; + overflow-y: auto; + overscroll-behavior: contain; +} + +.selector-foot { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--dn-space-3); + padding-top: var(--dn-space-4); + border-top: 1px solid var(--dn-border); } /* --- Editor shell ---------------------------------------------------------- diff --git a/src/views/workspace_selector/selector.rs b/src/views/workspace_selector/selector.rs index b3c94a0..058ffb8 100644 --- a/src/views/workspace_selector/selector.rs +++ b/src/views/workspace_selector/selector.rs @@ -2,40 +2,69 @@ use dioxus::prelude::*; use std::path::PathBuf; use crate::route::Route; -use crate::workspace::{dir_is_empty, Workspace}; +use crate::workspace::{dir_is_empty, OpenError, Workspace}; use crate::workspace_list::WorkspaceList; -/// Der Bildschirm vor dem Editor: einen bekannten Workspace öffnen oder einen -/// hinzufügen. Eine einzige Route, keine Reiter — die Workspace-Liste führt, -/// das Hinzufügen ist Nebenaktion. +/// 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(); - // Ordner, der auf die Bestätigung „ist nicht leer" wartet. let mut pending = use_signal(|| None::); let mut error = use_signal(|| None::); - // Übernimmt einen Workspace: Liste voranstellen und sichern, in den - // Context legen, in den Editor wechseln. + // 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() { - // 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}"))); + 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}"))), }; + // 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 { @@ -44,43 +73,75 @@ pub fn Selector() -> Element { 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. + // Ein 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}"))), - } + open_entry(root); return; } match dir_is_empty(&root) { Ok(true) => create_at(root), - // Nicht leer ist der Normalfall beim Import — deshalb eine - // Rückfrage, keine Ablehnung. + // 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: "workspace-selector-layout", - div { class: "workspace-selector-view", - // TODO: Workspace-Liste darstellen (Ordnername, Pfad, Statusabzeichen) - // statt des leeren Zustands, sobald Einträge vorhanden sind. - div { class: "empty-state", - span { class: "empty-state-title", "Noch kein Workspace" } - span { "Der erste Workspace ist ein Ordner deiner Wahl." } - button { class: "btn btn-primary", onclick: choose, - "Workspace hinzufügen…" + 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." } } - if let Some(message) = error() { - span { class: "field-error", "{message}" } + } 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() { @@ -96,6 +157,37 @@ pub fn Selector() -> Element { } } +/// 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. diff --git a/src/workspace.rs b/src/workspace.rs index 783f145..562ee74 100644 --- a/src/workspace.rs +++ b/src/workspace.rs @@ -37,7 +37,7 @@ pub struct Workspace { /// 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)] +#[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 @@ -52,7 +52,7 @@ pub enum OpenError { Damaged, /// Der Marker stammt aus einer neueren Fassung der App. TooNew { found: u32 }, - Io(io::Error), + Io(String), } impl fmt::Display for OpenError { @@ -72,7 +72,18 @@ impl fmt::Display for OpenError { impl From for OpenError { fn from(err: io::Error) -> Self { - Self::Io(err) + 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", + } } }