notes editor over the vault (Obsidian-compatible)
git clone https://git.lucas.co/cce-notes.git
src/tree.rs (3.4K)
1 //! The file tree pane's model: the vault's notes as folder and note rows,
2 //! folders first and each level sorted case-insensitively, as Obsidian's
3 //! file explorer shows them. Folders exist only as the parents of indexed
4 //! files, so an empty folder does not appear.
5
6 use std::collections::{BTreeMap, BTreeSet};
7
8 #[derive(Debug, Clone, PartialEq, Eq)]
9 pub struct Row {
10 /// The folder's or note's vault path.
11 pub path: String,
12 /// What the row shows: a folder's name, or a note's name without `.md`.
13 pub name: String,
14 pub depth: usize,
15 pub folder: bool,
16 pub open: bool,
17 }
18
19 #[derive(Default)]
20 pub struct Tree {
21 open: BTreeSet<String>,
22 }
23
24 #[derive(Default)]
25 struct Dir<'a> {
26 dirs: BTreeMap<String, Dir<'a>>,
27 files: Vec<&'a str>,
28 }
29
30 impl Tree {
31 pub fn toggle(&mut self, folder: &str) {
32 if !self.open.remove(folder) {
33 self.open.insert(folder.to_string());
34 }
35 }
36
37 /// Open every folder above `path`, so its row is visible.
38 pub fn reveal(&mut self, path: &str) {
39 let mut at = 0;
40 while let Some(i) = path[at..].find('/') {
41 self.open.insert(path[..at + i].to_string());
42 at += i + 1;
43 }
44 }
45
46 /// The visible rows for these note paths.
47 pub fn rows<'a>(&self, paths: impl IntoIterator<Item = &'a str>) -> Vec<Row> {
48 let mut root = Dir::default();
49 for p in paths {
50 let mut dir = &mut root;
51 let mut parts: Vec<&str> = p.split('/').collect();
52 parts.pop();
53 for part in parts {
54 dir = dir.dirs.entry(part.to_string()).or_default();
55 }
56 dir.files.push(p);
57 }
58 let mut out = Vec::new();
59 self.walk(&root, "", 0, &mut out);
60 out
61 }
62
63 fn walk(&self, dir: &Dir, prefix: &str, depth: usize, out: &mut Vec<Row>) {
64 let mut dirs: Vec<(&String, &Dir)> = dir.dirs.iter().collect();
65 dirs.sort_by_key(|(name, _)| name.to_lowercase());
66 for (name, sub) in dirs {
67 let path = if prefix.is_empty() { name.clone() } else { format!("{prefix}/{name}") };
68 let open = self.open.contains(&path);
69 out.push(Row { path: path.clone(), name: name.clone(), depth, folder: true, open });
70 if open {
71 self.walk(sub, &path, depth + 1, out);
72 }
73 }
74 let mut files = dir.files.clone();
75 files.sort_by_key(|p| p.to_lowercase());
76 for p in files {
77 let file = p.rsplit('/').next().unwrap_or(p);
78 let name = file.strip_suffix(".md").unwrap_or(file).to_string();
79 out.push(Row { path: p.to_string(), name, depth, folder: false, open: false });
80 }
81 }
82 }
83
84 #[cfg(test)]
85 mod tests {
86 use super::*;
87
88 fn names(rows: &[Row]) -> Vec<String> {
89 rows.iter().map(|r| format!("{}{}{}", " ".repeat(r.depth), r.name, if r.folder { "/" } else { "" })).collect()
90 }
91
92 #[test]
93 fn folders_first_sorted_and_collapsed() {
94 let paths = ["b.md", "A.md", "Tasks/x.md", "archive/old/y.md"];
95 let mut t = Tree::default();
96 assert_eq!(names(&t.rows(paths)), ["archive/", "Tasks/", "A", "b"]);
97 t.toggle("Tasks");
98 assert_eq!(names(&t.rows(paths)), ["archive/", "Tasks/", " x", "A", "b"]);
99 t.toggle("Tasks");
100 t.reveal("archive/old/y.md");
101 assert_eq!(names(&t.rows(paths)), ["archive/", " old/", " y", "Tasks/", "A", "b"]);
102 assert!(t.rows(paths)[1].open);
103 }
104 }