Setzt Issue #7 um und schliesst damit die Workspace-Map ab. Die files-bar zeigt nicht mehr zwei Platzhalter-Routen, sondern den rekursiv eingelesenen Inhalt des offenen Workspace. TreeNode ist ein verschachteltes Enum mit einer einzigen File-Variante fuer alle Dateitypen - wer wissen will, ob es eine Notiz ist, fragt path.extension(). Aufgebaut wird ueber std::fs::read_dir mit eigener Rekursion; walkdir liegt zwar im Graph, iteriert aber flach, woraus einen Baum zu bauen mehr Code waere. Eingesammelt wird alles ausser Namen mit fuehrendem Punkt - Dateien wie Ordner, womit .dionotes und .git ohne Ausnahmeliste wegfallen - und ausser Symlinks, deren Verfolgung einen Zyklusschutz erfordern wuerde. Leere Ordner werden angezeigt, sortiert wird Ordner-vor-Dateien und alphabetisch ohne Ruecksicht auf Gross-/Kleinschreibung. Der Aufklapp-Zustand liegt als HashSet<PathBuf> neben dem Baum, nicht darin: Es gibt keinen Filewatcher, der Nutzer liest bei Bedarf neu ein - steckte expanded im Knoten, klappte jeder Refresh saemtliche Ordner zu. Persistiert wird er nicht. Mit dem Baum entfaellt die Tab-Leiste im Editor und damit EDITOR_TABS, der letzte Rest des in #5 dokumentierten Pflegepunkts. strum wird dadurch nirgends mehr gebraucht und faellt samt strum_macros aus dem Graph. Ausserdem entfernt: der Dioxus-Vorlagentext Hero samt header.svg, der nach dem Oeffnen eines Workspace die rechte Haelfte mit ungestylten Werbelinks fuellte. Home ist jetzt ein Empty-State.
159 lines
5.1 KiB
Rust
159 lines
5.1 KiB
Rust
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::<Signal<Option<Workspace>>>();
|
|
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::<PathBuf>::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::<Route> { }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Eine Ebene des Baums. Ruft sich für aufgeklappte Ordner selbst auf.
|
|
#[component]
|
|
fn TreeLevel(nodes: Vec<TreeNode>, expanded: Signal<HashSet<PathBuf>>, 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<HashSet<PathBuf>>, 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}"
|
|
}
|
|
},
|
|
}
|
|
}
|