From 570c804d3ff15f5595ea483e7eb8b6c2185f967e Mon Sep 17 00:00:00 2001 From: MDietermann Date: Fri, 28 Aug 2026 10:38:13 +0200 Subject: [PATCH] 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. --- Cargo.lock | 1 + Cargo.toml | 1 + assets/css/components.css | 86 +++++++++++++ assets/css/layout.css | 51 +++++--- src/views/workspace_selector/selector.rs | 154 ++++++++++++++++++----- src/workspace.rs | 17 ++- 6 files changed, 261 insertions(+), 49 deletions(-) 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", + } } } -- 2.49.1