Selector-UI: Liste, Zustandsabzeichen, nebenlaeufige Pruefung #13

Merged
RDietermann merged 2 commits from feat/selector-ui into main 2026-08-28 09:00:05 +00:00
6 changed files with 261 additions and 49 deletions

1
Cargo.lock generated
View File

@@ -2334,6 +2334,7 @@ dependencies = [
"ron",
"serde",
"strum",
"tokio",
]
[[package]]

View File

@@ -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"]

View File

@@ -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.

View File

@@ -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 ----------------------------------------------------------

View File

@@ -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::<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.
// 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() {
// 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<OpenError> {
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<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.

View File

@@ -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<io::Error> 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",
}
}
}