Notiz-Baum in der Datei-Leiste #14
22
Cargo.lock
generated
22
Cargo.lock
generated
@@ -2333,7 +2333,6 @@ dependencies = [
|
|||||||
"rfd",
|
"rfd",
|
||||||
"ron",
|
"ron",
|
||||||
"serde",
|
"serde",
|
||||||
"strum",
|
|
||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -6414,27 +6413,6 @@ dependencies = [
|
|||||||
"quote",
|
"quote",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "strum"
|
|
||||||
version = "0.28.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd"
|
|
||||||
dependencies = [
|
|
||||||
"strum_macros",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "strum_macros"
|
|
||||||
version = "0.28.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664"
|
|
||||||
dependencies = [
|
|
||||||
"heck 0.5.0",
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn 2.0.119",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "subsecond"
|
name = "subsecond"
|
||||||
version = "0.7.10"
|
version = "0.7.10"
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ dirs = "6.0.0"
|
|||||||
rfd = { version = "0.17.2", default-features = false, features = ["xdg-portal"] }
|
rfd = { version = "0.17.2", default-features = false, features = ["xdg-portal"] }
|
||||||
ron = "0.12.2"
|
ron = "0.12.2"
|
||||||
serde = { version = "1.0.229", features = ["derive"] }
|
serde = { version = "1.0.229", features = ["derive"] }
|
||||||
strum = { version = "0.28.0", features = ["derive"] }
|
|
||||||
tokio = { version = "1.53.1", features = ["rt"] }
|
tokio = { version = "1.53.1", features = ["rt"] }
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
|
|||||||
@@ -9,8 +9,18 @@
|
|||||||
|
|
||||||
.file-selector {
|
.file-selector {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: block;
|
display: flex;
|
||||||
padding: var(--dn-space-1) var(--dn-space-2) var(--dn-space-1) var(--dn-space-3);
|
align-items: center;
|
||||||
|
gap: var(--dn-space-1);
|
||||||
|
/* Einrückung pro Baumebene — --depth setzt die Komponente je Knoten. */
|
||||||
|
padding: var(--dn-space-1) var(--dn-space-2) var(--dn-space-1)
|
||||||
|
calc(var(--dn-space-3) + var(--depth, 0) * var(--dn-space-4));
|
||||||
|
/* Als <button> im Baum: Browser-Vorgaben zurücknehmen. */
|
||||||
|
width: 100%;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
border-radius: var(--dn-radius-sm);
|
border-radius: var(--dn-radius-sm);
|
||||||
color: var(--dn-fg-muted);
|
color: var(--dn-fg-muted);
|
||||||
font-family: var(--dn-font-code);
|
font-family: var(--dn-font-code);
|
||||||
@@ -23,6 +33,19 @@
|
|||||||
background-color var(--dn-dur-fast) var(--dn-ease);
|
background-color var(--dn-dur-fast) var(--dn-ease);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Das Dreieck hält seinen Platz auch bei Dateien, damit Ordner- und
|
||||||
|
Dateinamen derselben Ebene bündig stehen. */
|
||||||
|
.tree-caret {
|
||||||
|
flex: none;
|
||||||
|
width: 0.9em;
|
||||||
|
color: var(--dn-fg-faint);
|
||||||
|
font-size: 0.8em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-selector.is-folder {
|
||||||
|
color: var(--dn-fg);
|
||||||
|
}
|
||||||
|
|
||||||
.file-selector::before {
|
.file-selector::before {
|
||||||
content: "";
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -88,6 +111,12 @@
|
|||||||
border-color: transparent;
|
border-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Quadratischer Knopf für ein einzelnes Zeichen — Refresh, Schließen. */
|
||||||
|
.btn-icon {
|
||||||
|
padding: var(--dn-space-1) var(--dn-space-2);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.btn-ghost:hover:not(:disabled) {
|
.btn-ghost:hover:not(:disabled) {
|
||||||
background: var(--dn-bg-raised);
|
background: var(--dn-bg-raised);
|
||||||
border-color: transparent;
|
border-color: transparent;
|
||||||
|
|||||||
@@ -80,6 +80,22 @@
|
|||||||
overscroll-behavior: contain;
|
overscroll-behavior: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Kopfzeile der Datei-Leiste: Beschriftung und Neu-einlesen. Bleibt stehen,
|
||||||
|
während der Baum darunter scrollt. */
|
||||||
|
.files-bar-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--dn-space-2);
|
||||||
|
padding: var(--dn-space-1) var(--dn-space-1) var(--dn-space-1) var(--dn-space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-bar-empty {
|
||||||
|
padding: var(--dn-space-3) var(--dn-space-2);
|
||||||
|
color: var(--dn-fg-faint);
|
||||||
|
font-size: var(--dn-text-xs);
|
||||||
|
}
|
||||||
|
|
||||||
.editor-container {
|
.editor-container {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 23 KiB |
@@ -1,5 +1,6 @@
|
|||||||
mod components;
|
mod components;
|
||||||
mod route;
|
mod route;
|
||||||
|
mod tree;
|
||||||
mod views;
|
mod views;
|
||||||
mod workspace;
|
mod workspace;
|
||||||
mod workspace_list;
|
mod workspace_list;
|
||||||
|
|||||||
14
src/route.rs
14
src/route.rs
@@ -1,5 +1,4 @@
|
|||||||
use dioxus::prelude::*;
|
use dioxus::prelude::*;
|
||||||
use strum::EnumMessage;
|
|
||||||
|
|
||||||
use crate::views::editor::dionotes_editor::{Editor, EditorShell, Home};
|
use crate::views::editor::dionotes_editor::{Editor, EditorShell, Home};
|
||||||
use crate::views::workspace_selector::selector::Selector;
|
use crate::views::workspace_selector::selector::Selector;
|
||||||
@@ -9,28 +8,17 @@ use crate::views::workspace_selector::selector::Selector;
|
|||||||
/// Bewusst *ein* Router statt zweier: Zwei Routable-Enums teilen sich dieselbe
|
/// Bewusst *ein* Router statt zweier: Zwei Routable-Enums teilen sich dieselbe
|
||||||
/// History, und beide hatten `/` belegt — beim Umschalten hätte der Editor eine
|
/// History, und beide hatten `/` belegt — beim Umschalten hätte der Editor eine
|
||||||
/// URL zu sehen bekommen, die er nicht kennt.
|
/// URL zu sehen bekommen, die er nicht kennt.
|
||||||
#[derive(Debug, Clone, Routable, PartialEq, EnumMessage)]
|
#[derive(Debug, Clone, Routable, PartialEq)]
|
||||||
#[rustfmt::skip]
|
#[rustfmt::skip]
|
||||||
pub enum Route {
|
pub enum Route {
|
||||||
#[nest("/workspace")]
|
#[nest("/workspace")]
|
||||||
#[layout(EditorShell)]
|
#[layout(EditorShell)]
|
||||||
#[route("/")]
|
#[route("/")]
|
||||||
#[strum(message = "Home")]
|
|
||||||
Home {},
|
Home {},
|
||||||
#[route("/editor")]
|
#[route("/editor")]
|
||||||
#[strum(message = "Editor")]
|
|
||||||
Editor {},
|
Editor {},
|
||||||
#[end_layout]
|
#[end_layout]
|
||||||
#[end_nest]
|
#[end_nest]
|
||||||
#[route("/")]
|
#[route("/")]
|
||||||
Selector {},
|
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 {}];
|
|
||||||
}
|
|
||||||
|
|||||||
194
src/tree.rs
Normal file
194
src/tree.rs
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
use std::fs;
|
||||||
|
use std::io;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
/// Ein Knoten im Notiz-Baum.
|
||||||
|
///
|
||||||
|
/// Verschachtelt statt flache Arena: Der Baum wird beim Öffnen einmal
|
||||||
|
/// aufgebaut und danach nur gelesen — die Indirektion einer Arena zahlt sich
|
||||||
|
/// erst aus, wenn Knoten wandern oder sich gegenseitig referenzieren.
|
||||||
|
///
|
||||||
|
/// Als Enum statt als Struct mit `children: Vec<_>`, weil damit unmöglich
|
||||||
|
/// wird, was ein Struct erlaubt: eine Datei mit Kindern.
|
||||||
|
///
|
||||||
|
/// Eine einzige `File`-Variante für alle Dateitypen. Wer wissen will, ob es
|
||||||
|
/// eine Notiz ist, fragt `path.extension()` — das ist eine Zeile an der
|
||||||
|
/// Stelle, die es braucht, statt einer Klassifizierung, die der Baum beim
|
||||||
|
/// Aufbau festschreibt.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum TreeNode {
|
||||||
|
File {
|
||||||
|
name: String,
|
||||||
|
path: PathBuf,
|
||||||
|
},
|
||||||
|
Folder {
|
||||||
|
name: String,
|
||||||
|
path: PathBuf,
|
||||||
|
children: Vec<TreeNode>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TreeNode {
|
||||||
|
pub fn name(&self) -> &str {
|
||||||
|
match self {
|
||||||
|
Self::File { name, .. } | Self::Folder { name, .. } => name,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn path(&self) -> &Path {
|
||||||
|
match self {
|
||||||
|
Self::File { path, .. } | Self::Folder { path, .. } => path,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_folder(&self) -> bool {
|
||||||
|
matches!(self, Self::Folder { .. })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Liest den Inhalt eines Ordners rekursiv ein und liefert seine Kinder.
|
||||||
|
///
|
||||||
|
/// `std::fs::read_dir` mit eigener Rekursion statt `walkdir`: Letzteres liegt
|
||||||
|
/// zwar bereits im Abhängigkeitsgraph, iteriert aber flach — daraus einen
|
||||||
|
/// Baum zusammenzusetzen wäre mehr Code, nicht weniger.
|
||||||
|
pub fn read_tree(root: &Path) -> io::Result<Vec<TreeNode>> {
|
||||||
|
let mut nodes = Vec::new();
|
||||||
|
|
||||||
|
for entry in fs::read_dir(root)? {
|
||||||
|
let entry = entry?;
|
||||||
|
let name = entry.file_name().to_string_lossy().into_owned();
|
||||||
|
|
||||||
|
// Alles mit führendem Punkt bleibt draußen. Das erledigt `.dionotes`
|
||||||
|
// und `.git` in einem Aufwasch, ohne eine Ausnahmeliste, die bei
|
||||||
|
// `.obsidian` oder `.vscode` wieder wachsen müsste.
|
||||||
|
if name.starts_with('.') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = entry.path();
|
||||||
|
// `symlink_metadata` folgt dem Link nicht, `metadata` schon. Symlinks
|
||||||
|
// zu folgen hieße, einen Zyklusschutz über kanonisierte Pfade
|
||||||
|
// mitzuführen — realer Aufwand für einen Fall, den in einem
|
||||||
|
// Notizordner kaum jemand hat, während ein Hänger beim Öffnen für den
|
||||||
|
// Nutzer schwer zu deuten wäre.
|
||||||
|
let Ok(meta) = fs::symlink_metadata(&path) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if meta.is_symlink() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if meta.is_dir() {
|
||||||
|
// Ein Unterordner ohne Leserecht wird leer dargestellt, statt den
|
||||||
|
// ganzen Baum scheitern zu lassen.
|
||||||
|
let children = read_tree(&path).unwrap_or_default();
|
||||||
|
nodes.push(TreeNode::Folder { name, path, children });
|
||||||
|
} else {
|
||||||
|
nodes.push(TreeNode::File { name, path });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sort(&mut nodes);
|
||||||
|
Ok(nodes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ordner zuerst, dann Dateien, jeweils alphabetisch ohne Rücksicht auf
|
||||||
|
/// Groß-/Kleinschreibung — die Konvention aus jedem Dateibrowser. `read_dir`
|
||||||
|
/// liefert plattformabhängig unsortiert.
|
||||||
|
fn sort(nodes: &mut [TreeNode]) {
|
||||||
|
nodes.sort_by(|a, b| {
|
||||||
|
b.is_folder()
|
||||||
|
.cmp(&a.is_folder())
|
||||||
|
.then_with(|| a.name().to_lowercase().cmp(&b.name().to_lowercase()))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{read_tree, TreeNode};
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
fn scratch(name: &str) -> PathBuf {
|
||||||
|
let dir = std::env::temp_dir().join(format!("dionotes-tree-{name}"));
|
||||||
|
let _ = fs::remove_dir_all(&dir);
|
||||||
|
fs::create_dir_all(&dir).expect("Testverzeichnis anlegbar");
|
||||||
|
dir
|
||||||
|
}
|
||||||
|
|
||||||
|
fn names(nodes: &[TreeNode]) -> Vec<&str> {
|
||||||
|
nodes.iter().map(|n| n.name()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn folders_come_first_then_files_each_alphabetically() {
|
||||||
|
let dir = scratch("sorting");
|
||||||
|
fs::write(dir.join("zebra.md"), "").unwrap();
|
||||||
|
fs::write(dir.join("Apfel.md"), "").unwrap();
|
||||||
|
fs::create_dir(dir.join("Werkzeug")).unwrap();
|
||||||
|
fs::create_dir(dir.join("archiv")).unwrap();
|
||||||
|
|
||||||
|
let tree = read_tree(&dir).unwrap();
|
||||||
|
// Gross-/Kleinschreibung darf die Reihenfolge nicht bestimmen.
|
||||||
|
assert_eq!(names(&tree), ["archiv", "Werkzeug", "Apfel.md", "zebra.md"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_file_type_is_included_not_just_markdown() {
|
||||||
|
let dir = scratch("all-files");
|
||||||
|
fs::write(dir.join("notiz.md"), "").unwrap();
|
||||||
|
fs::write(dir.join("bild.png"), "").unwrap();
|
||||||
|
fs::write(dir.join("ohne-endung"), "").unwrap();
|
||||||
|
|
||||||
|
let tree = read_tree(&dir).unwrap();
|
||||||
|
assert_eq!(names(&tree), ["bild.png", "notiz.md", "ohne-endung"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dotted_names_are_skipped_files_and_folders_alike() {
|
||||||
|
let dir = scratch("hidden");
|
||||||
|
fs::create_dir_all(dir.join(".dionotes")).unwrap();
|
||||||
|
fs::create_dir_all(dir.join(".git").join("objects")).unwrap();
|
||||||
|
fs::write(dir.join(".gitignore"), "").unwrap();
|
||||||
|
fs::write(dir.join("sichtbar.md"), "").unwrap();
|
||||||
|
|
||||||
|
assert_eq!(names(&read_tree(&dir).unwrap()), ["sichtbar.md"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_folders_are_shown() {
|
||||||
|
let dir = scratch("empty-folder");
|
||||||
|
fs::create_dir(dir.join("leer")).unwrap();
|
||||||
|
|
||||||
|
let tree = read_tree(&dir).unwrap();
|
||||||
|
match &tree[..] {
|
||||||
|
[TreeNode::Folder { name, children, .. }] => {
|
||||||
|
assert_eq!(name, "leer");
|
||||||
|
assert!(children.is_empty());
|
||||||
|
}
|
||||||
|
other => panic!("erwartet ein leerer Ordner, war {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nesting_is_recursive() {
|
||||||
|
let dir = scratch("nested");
|
||||||
|
fs::create_dir_all(dir.join("a").join("b")).unwrap();
|
||||||
|
fs::write(dir.join("a").join("b").join("tief.md"), "").unwrap();
|
||||||
|
|
||||||
|
let tree = read_tree(&dir).unwrap();
|
||||||
|
let TreeNode::Folder { children: a, .. } = &tree[0] else {
|
||||||
|
panic!("erwartet Ordner a");
|
||||||
|
};
|
||||||
|
let TreeNode::Folder { children: b, .. } = &a[0] else {
|
||||||
|
panic!("erwartet Ordner b");
|
||||||
|
};
|
||||||
|
assert_eq!(names(b), ["tief.md"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_root_is_an_error_not_an_empty_tree() {
|
||||||
|
let dir = scratch("no-root").join("gibtsnicht");
|
||||||
|
assert!(read_tree(&dir).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,38 +1,23 @@
|
|||||||
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 std::collections::HashSet;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use crate::components::custom_link::custom_link_component::CustomLink;
|
|
||||||
use crate::route::Route;
|
use crate::route::Route;
|
||||||
|
use crate::tree::{read_tree, TreeNode};
|
||||||
use crate::workspace::Workspace;
|
use crate::workspace::Workspace;
|
||||||
|
|
||||||
const HEADER_SVG: Asset = asset!("/assets/header.svg");
|
/// Was rechts steht, solange keine Notiz gewählt ist.
|
||||||
|
///
|
||||||
/// Home page
|
/// 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]
|
#[component]
|
||||||
pub fn Home() -> Element {
|
pub fn Home() -> Element {
|
||||||
rsx! {
|
rsx! {
|
||||||
Hero {}
|
div { class: "empty-state",
|
||||||
}
|
span { class: "empty-state-title", "Keine Notiz geöffnet" }
|
||||||
}
|
span { "Wähle links eine Notiz aus." }
|
||||||
|
|
||||||
// TODO: Unveränderter Dioxus-Vorlagentext. Ersetzen, sobald klar ist, was
|
|
||||||
// beim Öffnen eines Workspace angezeigt wird, bevor eine Notiz gewählt wurde.
|
|
||||||
#[component]
|
|
||||||
pub fn Hero() -> Element {
|
|
||||||
rsx! {
|
|
||||||
div {
|
|
||||||
id: "hero",
|
|
||||||
img { src: HEADER_SVG, id: "header" }
|
|
||||||
div { id: "links",
|
|
||||||
a { href: "https://dioxuslabs.com/learn/0.7/", "📚 Learn Dioxus" }
|
|
||||||
a { href: "https://dioxuslabs.com/awesome", "🚀 Awesome Dioxus" }
|
|
||||||
a { href: "https://github.com/dioxus-community/", "📡 Community Libraries" }
|
|
||||||
a { href: "https://github.com/DioxusLabs/sdk", "⚙️ Dioxus Development Kit" }
|
|
||||||
a { href: "https://marketplace.visualstudio.com/items?itemName=DioxusLabs.dioxus", "💫 VSCode Extension" }
|
|
||||||
a { href: "https://discord.gg/XgGxMSkvUM", "👋 Community Discord" }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -52,7 +37,7 @@ pub fn Editor() -> Element {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Die Editor-Hülle: Datei-Leiste links, Editorfläche rechts.
|
/// Die Editor-Hülle: Notiz-Baum links, Editorfläche rechts.
|
||||||
///
|
///
|
||||||
/// Trägt zugleich den Zustands-Guard — ohne offenen Workspace hat der Editor
|
/// 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,
|
/// nichts anzuzeigen. Der Fall ist real: Hot-Reload setzt den Context zurück,
|
||||||
@@ -68,25 +53,51 @@ pub fn EditorShell() -> Element {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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() {
|
if workspace.read().is_none() {
|
||||||
return rsx! {};
|
return rsx! {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let nodes = tree.read().clone().unwrap_or_default();
|
||||||
|
|
||||||
rsx! {
|
rsx! {
|
||||||
div {
|
div {
|
||||||
class: "layout-container",
|
class: "layout-container",
|
||||||
div {
|
div {
|
||||||
class: "files-bar",
|
class: "files-bar",
|
||||||
// TODO: Durch den Notiz-Baum des offenen Workspace ersetzen
|
div { class: "files-bar-head",
|
||||||
// (rekursiv eingelesen, aufklappbar). Bis dahin die Routen.
|
span { class: "field-label", "Notizen" }
|
||||||
for entry in Route::EDITOR_TABS {
|
// Kein Filewatcher — externe Änderungen holt man sich hier.
|
||||||
CustomLink {
|
button {
|
||||||
key: "{entry}",
|
class: "btn btn-ghost btn-icon",
|
||||||
label: entry.get_message().unwrap_or_default(),
|
title: "Neu einlesen",
|
||||||
href: entry.clone(),
|
onclick: move |_| tree.restart(),
|
||||||
css: "file-selector"
|
"⟳"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if nodes.is_empty() {
|
||||||
|
div { class: "files-bar-empty", "Keine Notizen in diesem Workspace." }
|
||||||
|
} else {
|
||||||
|
TreeLevel { nodes: nodes.clone(), expanded, depth: 0 }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
div {
|
div {
|
||||||
class: "editor-container",
|
class: "editor-container",
|
||||||
@@ -95,3 +106,53 @@ pub fn EditorShell() -> Element {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user