3 Commits

Author SHA1 Message Date
cf5baebc6f Merge pull request 'Selector-UI: Liste, Zustandsabzeichen, nebenlaeufige Pruefung' (#13) from feat/selector-ui into main
Reviewed-on: #13
2026-08-28 09:00:05 +00:00
2a27749d3d Merge branch 'main' into feat/selector-ui 2026-08-28 08:59:56 +00:00
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
6 changed files with 261 additions and 49 deletions

1
Cargo.lock generated
View File

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

View File

@@ -18,6 +18,7 @@ rfd = { version = "0.17.2", default-features = false, features = ["xdg-portal"]
ron = "0.12.2" ron = "0.12.2"
serde = { version = "1.0.229", features = ["derive"] } serde = { version = "1.0.229", features = ["derive"] }
strum = { version = "0.28.0", features = ["derive"] } strum = { version = "0.28.0", features = ["derive"] }
tokio = { version = "1.53.1", features = ["rt"] }
[features] [features]
default = ["desktop"] default = ["desktop"]

View File

@@ -167,6 +167,92 @@
font-weight: 600; 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 -------------------------------------------------------------------- /* --- Dialog --------------------------------------------------------------------
Für Unterbrechungen einer laufenden Aktion. Zustände gehören dagegen inline Für Unterbrechungen einer laufenden Aktion. Zustände gehören dagegen inline
an ihren Ort, nicht in ein zweites Fenster. an ihren Ort, nicht in ein zweites Fenster.

View File

@@ -10,29 +10,50 @@
} }
/* --- Workspace selector ---------------------------------------------------- /* --- Workspace selector ----------------------------------------------------
The pre-editor screen: pick or create a workspace. Centered, deliberately The pre-editor screen. One view, no tabs: the list of known workspaces
sparse — it has exactly one job. 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; flex: 1;
min-height: 0; min-height: 0;
width: 100%;
max-width: 40rem;
margin: 0 auto;
padding: var(--dn-space-7) var(--dn-space-5);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; gap: var(--dn-space-5);
justify-content: center;
gap: var(--dn-space-6);
padding: var(--dn-space-6);
} }
.workspace-selector-view { .selector-head {
width: 100%; display: flex;
max-width: 34rem; align-items: baseline;
padding: var(--dn-space-6); justify-content: space-between;
background: var(--dn-bg-sunken); gap: var(--dn-space-4);
border: 1px solid var(--dn-border); }
border-radius: var(--dn-radius-lg);
box-shadow: var(--dn-shadow); .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 ---------------------------------------------------------- /* --- Editor shell ----------------------------------------------------------

View File

@@ -2,40 +2,69 @@ use dioxus::prelude::*;
use std::path::PathBuf; use std::path::PathBuf;
use crate::route::Route; use crate::route::Route;
use crate::workspace::{dir_is_empty, Workspace}; use crate::workspace::{dir_is_empty, OpenError, Workspace};
use crate::workspace_list::WorkspaceList; use crate::workspace_list::WorkspaceList;
/// Der Bildschirm vor dem Editor: einen bekannten Workspace öffnen oder einen /// Der Bildschirm vor dem Editor. Eine Ansicht, keine Reiter: Die Liste der
/// hinzufügen. Eine einzige Route, keine Reiter — die Workspace-Liste führt, /// bekannten Workspaces führt, das Hinzufügen ist Nebenaktion in der Fußzeile.
/// das Hinzufügen ist Nebenaktion. /// Öffnen ist der Alltagsfall, Hinzufügen passiert pro Workspace einmal.
#[component] #[component]
pub fn Selector() -> Element { pub fn Selector() -> Element {
let mut workspace = use_context::<Signal<Option<Workspace>>>(); let mut workspace = use_context::<Signal<Option<Workspace>>>();
let mut list = use_signal(WorkspaceList::load); let mut list = use_signal(WorkspaceList::load);
let navigator = use_navigator(); let navigator = use_navigator();
// Ordner, der auf die Bestätigung „ist nicht leer" wartet.
let mut pending = use_signal(|| None::<PathBuf>); let mut pending = use_signal(|| None::<PathBuf>);
let mut error = use_signal(|| None::<String>); let mut error = use_signal(|| None::<String>);
// Übernimmt einen Workspace: Liste voranstellen und sichern, in den // Zustandsprüfung der Einträge, nebenläufig und auf einem eigenen Thread:
// Context legen, in den Editor wechseln. // 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| { let mut accept = move |opened: Workspace| {
list.write().promote(opened.root.clone()); list.write().promote(opened.root.clone());
if let Err(err) = list.read().save() { if let Err(err) = list.read().save() {
// Kein Abbruch: Der Workspace ist offen, nur das Merken schlug fehl. error.set(Some(format!(
error.set(Some(format!("Der Workspace ist offen, aber die Liste ließ sich nicht speichern: {err}"))); "Der Workspace ist offen, aber die Liste ließ sich nicht speichern: {err}"
)));
} }
workspace.set(Some(opened)); workspace.set(Some(opened));
navigator.push(Route::Home {}); 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) { let mut create_at = move |root: PathBuf| match Workspace::create(root) {
Ok(opened) => accept(opened), Ok(opened) => accept(opened),
Err(err) => error.set(Some(format!("Anlegen fehlgeschlagen: {err}"))), 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 { let choose = move |_| async move {
// Abbruch liefert None und ist kein Fehler — keine Meldung. // Abbruch liefert None und ist kein Fehler — keine Meldung.
let Some(handle) = rfd::AsyncFileDialog::new().pick_folder().await else { 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(); let root = handle.path().to_path_buf();
error.set(None); error.set(None);
// Ein bereits vorhandener Workspace wird geöffnet, nicht abgelehnt: // Ein vorhandener Workspace wird geöffnet, nicht abgelehnt: derselbe
// derselbe Knopf nimmt neue und wiedergefundene Workspaces auf. // Knopf nimmt neue und wiedergefundene Workspaces auf.
if Workspace::has_marker(&root) { if Workspace::has_marker(&root) {
match Workspace::open(root) { open_entry(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}"))),
}
return; return;
} }
match dir_is_empty(&root) { match dir_is_empty(&root) {
Ok(true) => create_at(root), Ok(true) => create_at(root),
// Nicht leer ist der Normalfall beim Import — deshalb eine // Nicht leer ist beim Import der Normalfall, keine Ausnahme.
// Rückfrage, keine Ablehnung.
Ok(false) => pending.set(Some(root)), Ok(false) => pending.set(Some(root)),
Err(err) => error.set(Some(format!("Ordner nicht lesbar: {err}"))), 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! { rsx! {
div { class: "workspace-selector-layout", div { class: "selector-shell",
div { class: "workspace-selector-view", div { class: "selector-head",
// TODO: Workspace-Liste darstellen (Ordnername, Pfad, Statusabzeichen) span { class: "selector-title", "Workspace öffnen" }
// statt des leeren Zustands, sobald Einträge vorhanden sind. if !is_empty {
div { class: "empty-state", span { class: "field-hint", "{paths.len()} bekannt" }
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-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() { } else {
span { class: "field-error", "{message}" } 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() { 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. /// Rückfrage vor dem Anlegen in einem Ordner, der bereits Dateien enthält.
/// Sie soll informieren, nicht beunruhigen — deshalb steht ausdrücklich da, /// Sie soll informieren, nicht beunruhigen — deshalb steht ausdrücklich da,
/// dass nichts Vorhandenes verändert wird. /// 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 /// Die ersten drei Varianten sind genau die Zustände, mit denen ein
/// Listeneintrag markiert wird — sie führen zu verschiedenen Handlungen: /// Listeneintrag markiert wird — sie führen zu verschiedenen Handlungen:
/// Laufwerk einhängen, Ordner wiederherstellen, Datei reparieren. /// Laufwerk einhängen, Ordner wiederherstellen, Datei reparieren.
#[derive(Debug)] #[derive(Debug, Clone, PartialEq)]
pub enum OpenError { pub enum OpenError {
/// Der Ordner existiert nicht. Meist ein abgezogener Stick oder ein nicht /// Der Ordner existiert nicht. Meist ein abgezogener Stick oder ein nicht
/// eingehängtes Netzlaufwerk — deshalb wird der Eintrag nie automatisch /// eingehängtes Netzlaufwerk — deshalb wird der Eintrag nie automatisch
@@ -52,7 +52,7 @@ pub enum OpenError {
Damaged, Damaged,
/// Der Marker stammt aus einer neueren Fassung der App. /// Der Marker stammt aus einer neueren Fassung der App.
TooNew { found: u32 }, TooNew { found: u32 },
Io(io::Error), Io(String),
} }
impl fmt::Display for OpenError { impl fmt::Display for OpenError {
@@ -72,7 +72,18 @@ impl fmt::Display for OpenError {
impl From<io::Error> for OpenError { impl From<io::Error> for OpenError {
fn from(err: io::Error) -> Self { 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",
}
} }
} }