refactor: Router zusammenfuehren und offenen Workspace im Context fuehren

Die App hatte zwei Routable-Enums, zwischen denen ein Signal<bool> umschaltete.
Der Schalter wurde nie gesetzt - beim ersten Umlegen waere aufgefallen, dass
sich beide Router dieselbe History teilen und beide "/" belegen: Wer im
Selector auf "Open Workspace" klickt, steht auf /ws_open, und der
Editor-Router kennt nur / und /editor.

Jetzt ein Enum, der Editor-Teil unter #[nest("/workspace")]. An die Stelle des
bool tritt Signal<Option<Workspace>> in einem Context-Provider - Props
scheiden aus, weil sich durch ein Outlet keine durchreichen lassen. Fehlt der
Workspace, schickt ein Guard im EditorShell-Layout zurueck zum Selector; der
Fall tritt real bei Hot-Reload auf, wo der Context faellt und die Route bleibt.

Der Selector verliert seine Reiter und wird eine einzige Route. Das folgt aus
dem UI-Prototyp in Issue #8: Oeffnen ist der Alltagsfall, Hinzufuegen passiert
pro Workspace einmal - zwei gleichrangige Reiter verlangten bei jedem Start
eine Entscheidung, die fast immer dieselbe ist. Damit entfallen der leere
Stub create_new_workspace.rs, die Tab-Konstante fuer den Selector und die
zugehoerigen CSS-Klassen.

Weil EnumIter nicht mehr gebraucht wird, faellt strum_macros als direkte
Abhaengigkeit weg; strum re-exportiert die Derives bereits.

Entscheidungen dazu in den Issues #5, #6 und #8.
This commit is contained in:
2026-08-28 08:57:19 +02:00
parent d2f5316a52
commit 91fd351d59
12 changed files with 143 additions and 131 deletions

1
Cargo.lock generated
View File

@@ -2330,7 +2330,6 @@ dependencies = [
"dioxus-code", "dioxus-code",
"dioxus-code-editor", "dioxus-code-editor",
"strum", "strum",
"strum_macros",
] ]
[[package]] [[package]]

View File

@@ -10,8 +10,7 @@ edition = "2021"
dioxus = { version = "0.7.1", features = ["router"] } dioxus = { version = "0.7.1", features = ["router"] }
dioxus-code = "0.1.2" dioxus-code = "0.1.2"
dioxus-code-editor = { version = "0.1.2", features = ["all-languages"] } dioxus-code-editor = { version = "0.1.2", features = ["all-languages"] }
strum = { version = "0.28.0", features = ["strum_macros", "derive"] } strum = { version = "0.28.0", features = ["derive"] }
strum_macros = "0.28.0"
[features] [features]
default = ["desktop"] default = ["desktop"]

View File

@@ -49,30 +49,6 @@
transform: scaleY(1); transform: scaleY(1);
} }
/* --- Workspace selector tab ------------------------------------------------- */
.workspace-selector {
padding: var(--dn-space-2) var(--dn-space-4);
border-radius: var(--dn-radius);
color: var(--dn-fg-muted);
font-size: var(--dn-text-base);
font-weight: 500;
text-decoration: none;
white-space: nowrap;
transition: color var(--dn-dur-fast) var(--dn-ease),
background-color var(--dn-dur-fast) var(--dn-ease);
}
.workspace-selector:hover {
color: var(--dn-fg);
background: var(--dn-bg-raised);
}
.workspace-selector.active {
color: var(--dn-fg-on-accent);
background: var(--dn-accent);
}
/* --- Buttons ---------------------------------------------------------------- */ /* --- Buttons ---------------------------------------------------------------- */
.btn { .btn {

View File

@@ -25,15 +25,6 @@
padding: var(--dn-space-6); padding: var(--dn-space-6);
} }
.workspace-selector-links {
display: flex;
gap: var(--dn-space-1);
padding: var(--dn-space-1);
background: var(--dn-bg-sunken);
border: 1px solid var(--dn-border);
border-radius: var(--dn-radius-lg);
}
.workspace-selector-view { .workspace-selector-view {
width: 100%; width: 100%;
max-width: 34rem; max-width: 34rem;

View File

@@ -198,9 +198,18 @@
.flex { .flex {
display: flex; display: flex;
} }
.table {
display: table;
}
.border-collapse {
border-collapse: collapse;
}
.transform { .transform {
transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,); transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,);
} }
.resize {
resize: both;
}
.border { .border {
border-style: var(--tw-border-style); border-style: var(--tw-border-style);
border-width: 1px; border-width: 1px;
@@ -208,6 +217,9 @@
.text-wrap { .text-wrap {
text-wrap: wrap; text-wrap: wrap;
} }
.underline {
text-decoration-line: underline;
}
.outline { .outline {
outline-style: var(--tw-outline-style); outline-style: var(--tw-outline-style);
outline-width: 1px; outline-width: 1px;

View File

@@ -1,9 +1,12 @@
mod views;
mod components; mod components;
mod route;
mod views;
mod workspace;
use dioxus::prelude::*; use dioxus::prelude::*;
use crate::views::editor::dionotes_editor::EditorRoute;
use crate::views::workspace_selector::selector::WorkspaceRoute; use crate::route::Route;
use crate::workspace::Workspace;
const FAVICON: Asset = asset!("/assets/favicon.ico"); const FAVICON: Asset = asset!("/assets/favicon.ico");
@@ -22,7 +25,11 @@ fn main() {
#[component] #[component]
fn App() -> Element { fn App() -> Element {
let workspace_selected: Signal<bool> = use_signal(|| false); // Der offene Workspace als Context, nicht als Prop: Er wird im Selector
// gesetzt und in der Datei-Leiste gelesen — beide hängen unter einem
// Outlet, durch das sich keine Props durchreichen lassen.
use_context_provider(|| Signal::new(None::<Workspace>));
rsx! { rsx! {
document::Link { rel: "icon", href: FAVICON } document::Link { rel: "icon", href: FAVICON }
// Tailwind first: its preflight lives in @layer base, our unlayered // Tailwind first: its preflight lives in @layer base, our unlayered
@@ -32,12 +39,6 @@ fn App() -> Element {
document::Link { rel: "stylesheet", href: BASE_CSS } document::Link { rel: "stylesheet", href: BASE_CSS }
document::Link { rel: "stylesheet", href: LAYOUT_CSS } document::Link { rel: "stylesheet", href: LAYOUT_CSS }
document::Link { rel: "stylesheet", href: COMPONENTS_CSS } document::Link { rel: "stylesheet", href: COMPONENTS_CSS }
if workspace_selected() { Router::<Route> {}
Router::<EditorRoute> {}
} else {
Router::<WorkspaceRoute> {}
}
} }
} }

36
src/route.rs Normal file
View File

@@ -0,0 +1,36 @@
use dioxus::prelude::*;
use strum::EnumMessage;
use crate::views::editor::dionotes_editor::{Editor, EditorShell, Home};
use crate::views::workspace_selector::selector::Selector;
/// Alle Routen der App in einem Enum.
///
/// Bewusst *ein* Router statt zweier: Zwei Routable-Enums teilen sich dieselbe
/// History, und beide hatten `/` belegt — beim Umschalten hätte der Editor eine
/// URL zu sehen bekommen, die er nicht kennt.
#[derive(Debug, Clone, Routable, PartialEq, EnumMessage)]
#[rustfmt::skip]
pub enum Route {
#[nest("/workspace")]
#[layout(EditorShell)]
#[route("/")]
#[strum(message = "Home")]
Home {},
#[route("/editor")]
#[strum(message = "Editor")]
Editor {},
#[end_layout]
#[end_nest]
#[route("/")]
Selector {},
}
impl Route {
/// Die Einträge der Datei-Leiste im Editor.
///
/// Explizit statt über `EnumIter`, weil das Enum jetzt auch den Selector
/// enthält und der in dieser Leiste nichts zu suchen hat. Wermutstropfen:
/// eine neue Editor-Route hier einzutragen erzwingt der Compiler nicht.
pub const EDITOR_TABS: [Self; 2] = [Self::Home {}, Self::Editor {}];
}

View File

@@ -1,32 +1,24 @@
use dioxus::prelude::*; use dioxus::prelude::*;
use dioxus_code::Theme; use dioxus_code::Theme;
use dioxus_code_editor::{CodeEditor, Language}; use dioxus_code_editor::{CodeEditor, Language};
use strum::EnumMessage;
use crate::components::custom_link::custom_link_component::CustomLink; use crate::components::custom_link::custom_link_component::CustomLink;
use strum::{EnumMessage, IntoEnumIterator}; use crate::route::Route;
use strum_macros::EnumIter; use crate::workspace::Workspace;
const HEADER_SVG: Asset = asset!("/assets/header.svg"); const HEADER_SVG: Asset = asset!("/assets/header.svg");
#[derive(Debug, Clone, Routable, PartialEq, EnumIter, EnumMessage)]
#[rustfmt::skip]
pub enum EditorRoute {
#[layout(Navbar)]
#[route("/")]
#[strum(message = "Home")]
Home {},
#[route("/editor")]
#[strum(message = "Editor")]
Editor {},
}
/// Home page /// Home page
#[component] #[component]
fn Home() -> Element { pub fn Home() -> Element {
rsx! { rsx! {
Hero {} Hero {}
} }
} }
// TODO: Unveränderter Dioxus-Vorlagentext. Ersetzen, sobald klar ist, was
// beim Öffnen eines Workspace angezeigt wird, bevor eine Notiz gewählt wurde.
#[component] #[component]
pub fn Hero() -> Element { pub fn Hero() -> Element {
rsx! { rsx! {
@@ -46,7 +38,7 @@ pub fn Hero() -> Element {
} }
#[component] #[component]
fn Editor() -> Element { pub fn Editor() -> Element {
let mut source = use_signal(String::new); let mut source = use_signal(String::new);
rsx! { rsx! {
@@ -60,15 +52,34 @@ fn Editor() -> Element {
} }
} }
/// Shared navbar component. /// Die Editor-Hülle: Datei-Leiste links, Editorfläche rechts.
///
/// Trägt zugleich den Zustands-Guard — ohne offenen Workspace hat der Editor
/// nichts anzuzeigen. Der Fall ist real: Hot-Reload setzt den Context zurück,
/// während die Route bestehen bleibt.
#[component] #[component]
fn Navbar() -> Element { pub fn EditorShell() -> Element {
let workspace = use_context::<Signal<Option<Workspace>>>();
let navigator = use_navigator();
use_effect(move || {
if workspace.read().is_none() {
navigator.replace(Route::Selector {});
}
});
if workspace.read().is_none() {
return rsx! {};
}
rsx! { rsx! {
div { div {
class: "layout-container", class: "layout-container",
div { div {
class: "files-bar", class: "files-bar",
for entry in EditorRoute::iter() { // TODO: Durch den Notiz-Baum des offenen Workspace ersetzen
// (rekursiv eingelesen, aufklappbar). Bis dahin die Routen.
for entry in Route::EDITOR_TABS {
CustomLink { CustomLink {
key: "{entry}", key: "{entry}",
label: entry.get_message().unwrap_or_default(), label: entry.get_message().unwrap_or_default(),
@@ -79,7 +90,7 @@ fn Navbar() -> Element {
} }
div { div {
class: "editor-container", class: "editor-container",
Outlet::<EditorRoute> { } Outlet::<Route> { }
} }
} }
} }

View File

@@ -1,11 +0,0 @@
use dioxus::prelude::*;
/// Create new Workspace Layout
// TODO: Platzhalter — Formular zum Anlegen eines Workspace (Name, Pfad) und
// Verdrahtung als Ziel von WorkspaceRoute::NewWorkspace fehlen noch.
#[component]
fn NewWorkspaceLayout() -> Element {
rsx! {
div {}
}
}

View File

@@ -1,2 +1 @@
pub mod selector; pub mod selector;
pub mod create_new_workspace;

View File

@@ -1,61 +1,26 @@
use dioxus::prelude::*; use dioxus::prelude::*;
use strum::{EnumMessage, IntoEnumIterator};
use strum_macros::EnumIter;
use crate::components::custom_link::custom_link_component::CustomLink;
#[derive(Debug, Clone, Routable, PartialEq, EnumIter, EnumMessage)] /// Der Bildschirm vor dem Editor: einen bekannten Workspace öffnen oder einen
#[rustfmt::skip] /// hinzufügen. Eine einzige Route, keine Reiter — die Workspace-Liste führt,
pub enum WorkspaceRoute { /// das Hinzufügen ist Nebenaktion.
#[layout(Workspace)]
#[route("/")]
#[strum(message = "Create new Workspace")]
NewWorkspace {},
#[route("/ws_open")]
#[strum(message = "Open Workspace")]
OpenWorkspace {},
}
/// Page to create new Workspace
#[component] #[component]
fn NewWorkspace() -> Element { pub fn Selector() -> Element {
rsx! { rsx! {
div { div { class: "workspace-selector-layout",
"New Workspace", div { class: "workspace-selector-view",
} // TODO: Workspace-Liste aus workspaces.ron laden und darstellen
} // (Ordnername, Pfad darunter, Statusabzeichen). Bis dahin nur
} // der leere Zustand.
div { class: "empty-state",
/// Page to open available Workspaces span { class: "empty-state-title", "Noch kein Workspace" }
#[component] span { "Der erste Workspace ist ein Ordner deiner Wahl." }
fn OpenWorkspace() -> Element { // TODO: Nativen Ordner-Dialog anbinden, Marker anlegen und
rsx! { // den Workspace in den Context schreiben.
div { button { class: "btn btn-primary", disabled: true,
"Open Workspace", "Workspace hinzufügen…"
}
}
}
/// Workspace Layout
#[component]
fn Workspace() -> Element {
rsx! {
div {
class: "workspace-selector-layout",
div {
class: "workspace-selector-links",
for route in WorkspaceRoute::iter() {
CustomLink {
key: "{route}",
label: route.get_message().unwrap_or_default(),
href: route.clone(),
css: "workspace-selector",
} }
} }
} }
div {
class: "workspace-selector-view",
Outlet::<WorkspaceRoute> { }
}
} }
} }
} }

34
src/workspace.rs Normal file
View File

@@ -0,0 +1,34 @@
use std::path::PathBuf;
/// Ein geöffneter Workspace — der Ordner auf der Platte, in dem die Notizen liegen.
///
/// Heute genau ein Feld. Der Typ existiert trotzdem als eigener Name, weil
/// `CONTEXT.md` „Workspace" als Domänenbegriff führt und die Einstellungen aus
/// `.dionotes/workspace.ron` hier andocken werden.
#[derive(Debug, Clone, PartialEq)]
pub struct Workspace {
pub root: PathBuf,
}
impl Workspace {
/// Anzeigename: der Ordnername. Bewusst abgeleitet statt gespeichert —
/// ein eigenes Namensfeld wäre nach dem ersten Umbenennen des Ordners falsch.
pub fn name(&self) -> String {
self.root
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| self.root.to_string_lossy().into_owned())
}
}
#[cfg(test)]
mod tests {
use super::Workspace;
use std::path::PathBuf;
#[test]
fn name_is_the_folder_name() {
let ws = Workspace { root: PathBuf::from("C:/Users/marvin/Notizen") };
assert_eq!(ws.name(), "Notizen");
}
}