git.lucas.co / cce-notes
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 }