Files
dionotes-client/src/views/editor/dionotes_editor.rs
MDietermann 7917fa08cc feat: Notiz-Baum in der Datei-Leiste
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.
2026-08-28 10:49:15 +02:00

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}"
}
},
}
}