use dioxus::prelude::*; use dioxus_code::Theme; use dioxus_code_editor::{CodeEditor, Language}; use std::collections::HashSet; use std::path::PathBuf; use crate::route::Route; use crate::tree::{read_tree, TreeNode}; use crate::workspace::Workspace; /// Was rechts steht, solange keine Notiz gewählt ist. /// /// TODO: Sobald ein Klick im Baum eine Notiz öffnet, ist das der Ort für den /// zuletzt bearbeiteten Stand oder eine Kurzhilfe. Gehört zum Notiz-Handling. #[component] pub fn Home() -> Element { rsx! { div { class: "empty-state", span { class: "empty-state-title", "Keine Notiz geöffnet" } span { "Wähle links eine Notiz aus." } } } } #[component] pub fn Editor() -> Element { let mut source = use_signal(String::new); rsx! { CodeEditor { class: "editor-class", value: source(), language: Language::Markdown, theme: Theme::CATPPUCCIN_MOCHA, oninput: move |value| source.set(value), } } } /// Die Editor-Hülle: Notiz-Baum 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] pub fn EditorShell() -> Element { let workspace = use_context::>>(); let navigator = use_navigator(); use_effect(move || { if workspace.read().is_none() { navigator.replace(Route::Selector {}); } }); // Offene Ordner liegen **neben** dem Baum, nicht darin: Es gibt keinen // Filewatcher, der Nutzer liest bei Bedarf neu ein — steckte `expanded` // im Knoten, klappte jeder Refresh sämtliche Ordner zu. let expanded = use_signal(HashSet::::new); // Einlesen auf einem eigenen Thread: Ein Workspace auf einem langsamen // Laufwerk würde die Oberfläche sonst blockieren. let mut tree = use_resource(move || { let root = workspace.read().as_ref().map(|ws| ws.root.clone()); async move { let Some(root) = root else { return Vec::new(); }; tokio::task::spawn_blocking(move || read_tree(&root).unwrap_or_default()) .await .unwrap_or_default() } }); if workspace.read().is_none() { return rsx! {}; } let nodes = tree.read().clone().unwrap_or_default(); rsx! { div { class: "layout-container", div { class: "files-bar", div { class: "files-bar-head", span { class: "field-label", "Notizen" } // Kein Filewatcher — externe Änderungen holt man sich hier. button { class: "btn btn-ghost btn-icon", title: "Neu einlesen", onclick: move |_| tree.restart(), "⟳" } } if nodes.is_empty() { div { class: "files-bar-empty", "Keine Notizen in diesem Workspace." } } else { TreeLevel { nodes: nodes.clone(), expanded, depth: 0 } } } div { class: "editor-container", Outlet:: { } } } } } /// Eine Ebene des Baums. Ruft sich für aufgeklappte Ordner selbst auf. #[component] fn TreeLevel(nodes: Vec, expanded: Signal>, depth: usize) -> Element { rsx! { for node in nodes { TreeEntry { key: "{node.path().display()}", node: node.clone(), expanded, depth } } } } #[component] fn TreeEntry(node: TreeNode, mut expanded: Signal>, depth: usize) -> Element { let path = node.path().to_path_buf(); let name = node.name().to_string(); let is_open = expanded.read().contains(&path); match node { TreeNode::Folder { children, .. } => { let toggle_path = path.clone(); rsx! { button { class: "file-selector is-folder", style: "--depth: {depth}", onclick: move |_| { let mut open = expanded.write(); if !open.remove(&toggle_path) { open.insert(toggle_path.clone()); } }, span { class: "tree-caret", if is_open { "▾" } else { "▸" } } "{name}" } if is_open { TreeLevel { nodes: children, expanded, depth: depth + 1 } } } } TreeNode::File { .. } => rsx! { // TODO: Klick öffnet die Notiz im Editor. Gehört zum Notiz-Handling // und damit in den nächsten Effort, nicht in diese Map. button { class: "file-selector", style: "--depth: {depth}", span { class: "tree-caret" } "{name}" } }, } }