Selector-UI: Liste, Zustandsabzeichen, nebenlaeufige Pruefung #13
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -2334,6 +2334,7 @@ dependencies = [
|
||||
"ron",
|
||||
"serde",
|
||||
"strum",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 ----------------------------------------------------------
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user