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.
195 lines
6.4 KiB
Rust
195 lines
6.4 KiB
Rust
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());
|
|
}
|
|
}
|