use crate::index::Entry;
use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;
use std::time::SystemTime;
pub enum RowKind {
Folder {
key: String,
name: String,
notes: usize,
open: bool,
},
Note {
entry: usize,
name: String,
modified: SystemTime,
},
}
pub struct Row {
pub depth: usize,
pub parent: Option<String>,
pub kind: RowKind,
}
fn components(folder: &str) -> Vec<String> {
let trimmed = folder.trim_matches('/');
if trimmed.is_empty() {
return Vec::new();
}
if folder.starts_with('~') || folder.starts_with('/') {
return vec![folder.trim_end_matches('/').to_string()];
}
let mut out = Vec::new();
let mut acc = String::new();
for seg in trimmed.split('/').filter(|s| !s.is_empty()) {
if !acc.is_empty() {
acc.push('/');
}
acc.push_str(seg);
out.push(acc.clone());
}
out
}
fn name_of(key: &str) -> String {
if key.starts_with('~') || key.starts_with('/') {
return key.to_string();
}
key.rsplit('/').next().unwrap_or(key).to_string()
}
fn matches(query: &str, entry: &Entry) -> bool {
query.is_empty()
|| crate::search::fuzzy(query, &entry.name()).is_some()
|| crate::search::fuzzy(query, &entry.title).is_some()
|| crate::search::fuzzy(query, &entry.rel).is_some()
}
struct Node {
parent: Option<String>,
name: String,
notes: usize,
kids: Vec<usize>,
}
pub fn rows(entries: &[Entry], open: &BTreeSet<String>, query: &str) -> Vec<Row> {
let unfold_all = !query.is_empty();
let mut nodes: BTreeMap<String, Node> = BTreeMap::new();
let mut loose: Vec<usize> = Vec::new();
for (i, entry) in entries.iter().enumerate() {
if !matches(query, entry) {
continue;
}
let comps = components(&entry.folder);
let Some(last) = comps.last().cloned() else {
loose.push(i);
continue;
};
for (d, key) in comps.iter().enumerate() {
let node = nodes.entry(key.clone()).or_insert_with(|| Node {
parent: if d == 0 {
None
} else {
Some(comps[d - 1].clone())
},
name: name_of(key),
notes: 0,
kids: Vec::new(),
});
node.notes += 1;
}
nodes.get_mut(&last).expect("just inserted").kids.push(i);
}
let mut kids_by_parent: BTreeMap<Option<&str>, Vec<&str>> = BTreeMap::new();
for (key, node) in &nodes {
kids_by_parent
.entry(node.parent.as_deref())
.or_default()
.push(key.as_str());
}
for group in kids_by_parent.values_mut() {
group.sort_by(|a, b| {
nodes[*a]
.name
.to_lowercase()
.cmp(&nodes[*b].name.to_lowercase())
.then_with(|| a.cmp(b))
});
}
let mut out = Vec::new();
emit(
&mut out,
&nodes,
&kids_by_parent,
entries,
None,
0,
open,
unfold_all,
);
push_notes(&mut out, entries, &loose, 0, None);
out
}
#[allow(clippy::too_many_arguments)]
fn emit(
out: &mut Vec<Row>,
nodes: &BTreeMap<String, Node>,
kids_by_parent: &BTreeMap<Option<&str>, Vec<&str>>,
entries: &[Entry],
parent: Option<&str>,
depth: usize,
open: &BTreeSet<String>,
unfold_all: bool,
) {
let Some(kids) = kids_by_parent.get(&parent) else {
return;
};
for key in kids {
let node = &nodes[*key];
let is_open = unfold_all || open.contains(*key);
out.push(Row {
depth,
parent: parent.map(str::to_string),
kind: RowKind::Folder {
key: (*key).to_string(),
name: node.name.clone(),
notes: node.notes,
open: is_open,
},
});
if is_open {
emit(
out,
nodes,
kids_by_parent,
entries,
Some(key),
depth + 1,
open,
unfold_all,
);
push_notes(out, entries, &node.kids, depth + 1, Some(key));
}
}
}
fn push_notes(
out: &mut Vec<Row>,
entries: &[Entry],
kids: &[usize],
depth: usize,
parent: Option<&str>,
) {
let mut kids = kids.to_vec();
kids.sort_by(|&a, &b| {
entries[a]
.name()
.to_lowercase()
.cmp(&entries[b].name().to_lowercase())
.then_with(|| entries[a].rel.cmp(&entries[b].rel))
});
for i in kids {
out.push(Row {
depth,
parent: parent.map(str::to_string),
kind: RowKind::Note {
entry: i,
name: entries[i].name(),
modified: entries[i].modified,
},
});
}
}
pub fn toggle(entries: &[Entry], open: &mut BTreeSet<String>, key: &str, query: &str) -> usize {
if !open.remove(key) {
open.insert(key.to_string());
}
rows(entries, open, query)
.iter()
.position(|r| matches!(&r.kind, RowKind::Folder { key: k, .. } if k == key))
.unwrap_or(0)
}
pub fn reveal(
entries: &[Entry],
open: &mut BTreeSet<String>,
active: Option<&Path>,
query: &str,
) -> usize {
let Some(active) = active else {
return 0;
};
let Some(i) = entries.iter().position(|e| same(&e.path, active)) else {
return 0;
};
for key in components(&entries[i].folder) {
open.insert(key);
}
rows(entries, open, query)
.iter()
.position(|r| matches!(r.kind, RowKind::Note { entry, .. } if entry == i))
.unwrap_or(0)
}
fn same(a: &Path, b: &Path) -> bool {
if a == b {
return true;
}
match (std::fs::canonicalize(a), std::fs::canonicalize(b)) {
(Ok(x), Ok(y)) => x == y,
_ => false,
}
}
pub fn parent_of(rows: &[Row], at: usize) -> Option<usize> {
let parent = rows.get(at)?.parent.as_ref()?;
rows[..at]
.iter()
.rposition(|r| matches!(&r.kind, RowKind::Folder { key, .. } if key == parent))
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn entry(rel: &str, title: &str) -> Entry {
let folder = match rel.rfind('/') {
Some(i) => rel[..i].to_string(),
None => String::new(),
};
Entry {
path: PathBuf::from("/vault").join(rel),
title: title.to_string(),
rel: rel.to_string(),
folder,
modified: SystemTime::UNIX_EPOCH,
}
}
fn far(folder: &str, rel: &str, title: &str) -> Entry {
Entry {
path: PathBuf::from(rel),
title: title.to_string(),
rel: rel.to_string(),
folder: folder.to_string(),
modified: SystemTime::UNIX_EPOCH,
}
}
fn open_set(keys: &[&str]) -> BTreeSet<String> {
keys.iter().map(|k| k.to_string()).collect()
}
fn shape(rows: &[Row]) -> Vec<String> {
rows.iter()
.map(|r| {
let body = match &r.kind {
RowKind::Folder { key, open, .. } => {
format!("{}{key}", if *open { '▾' } else { '▸' })
}
RowKind::Note { name, .. } => name.clone(),
};
format!("{}{body}", " ".repeat(r.depth))
})
.collect()
}
#[test]
fn folders_nest_and_notes_sit_under_them() {
let entries = vec![
entry("interviews/stories/matrix.md", "Story Matrix"),
entry("interviews/prep.md", "Prep"),
];
let rows = rows(
&entries,
&open_set(&["interviews", "interviews/stories"]),
"",
);
assert_eq!(
shape(&rows),
vec![
"▾interviews",
" ▾interviews/stories",
" matrix",
" prep",
]
);
}
#[test]
fn folders_come_before_notes_and_both_sort_by_name() {
let entries = vec![
entry("zebra.md", "Zebra"),
entry("apple.md", "Apple"),
entry("work/a.md", "A"),
entry("archive/b.md", "B"),
];
let rows = rows(&entries, &open_set(&[]), "");
assert_eq!(shape(&rows), vec!["▸archive", "▸work", "apple", "zebra"]);
}
#[test]
fn a_collapsed_folder_counts_every_note_beneath_it_not_just_its_own() {
let entries = vec![
entry("interviews/prep.md", "Prep"),
entry("interviews/stories/one.md", "One"),
entry("interviews/stories/two.md", "Two"),
];
let rows = rows(&entries, &open_set(&[]), "");
match &rows[0].kind {
RowKind::Folder { key, notes, .. } => {
assert_eq!(key, "interviews");
assert_eq!(*notes, 3);
}
_ => panic!("expected a folder row"),
}
}
#[test]
fn a_note_in_the_vault_root_has_no_folder_row_above_it() {
let entries = vec![entry("scratch.md", "Scratch")];
let rows = rows(&entries, &open_set(&[]), "");
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].depth, 0);
assert!(rows[0].parent.is_none());
assert!(matches!(rows[0].kind, RowKind::Note { .. }));
}
#[test]
fn a_folder_outside_the_vault_is_one_row_named_by_its_whole_path() {
let entries = vec![far("~/Code/tinycomputer/notes", "/x/log.md", "Log")];
let rows = rows(&entries, &open_set(&["~/Code/tinycomputer/notes"]), "");
assert_eq!(shape(&rows), vec!["▾~/Code/tinycomputer/notes", " log"]);
}
#[test]
fn unfolding_a_folder_shows_its_children_and_folding_it_hides_them_again() {
let entries = vec![entry("work/a.md", "A")];
let mut open = open_set(&[]);
assert_eq!(shape(&rows(&entries, &open, "")), vec!["▸work"]);
toggle(&entries, &mut open, "work", "");
assert_eq!(shape(&rows(&entries, &open, "")), vec!["▾work", " a"]);
toggle(&entries, &mut open, "work", "");
assert_eq!(shape(&rows(&entries, &open, "")), vec!["▸work"]);
}
#[test]
fn folding_a_folder_leaves_the_selection_on_that_folder_wherever_it_moved_to() {
let entries = vec![
entry("archive/a.md", "A"),
entry("archive/b.md", "B"),
entry("work/c.md", "C"),
];
let mut open = open_set(&["archive"]);
assert_eq!(toggle(&entries, &mut open, "archive", ""), 0);
assert_eq!(toggle(&entries, &mut open, "work", ""), 1);
assert_eq!(toggle(&entries, &mut open, "archive", ""), 0);
assert_eq!(
shape(&rows(&entries, &open, "")),
vec!["▾archive", " a", " b", "▾work", " c"]
);
}
#[test]
fn filtering_keeps_the_folders_a_matching_note_lives_in() {
let entries = vec![
entry("interviews/stories/matrix.md", "Story Matrix"),
entry("interviews/prep.md", "Prep"),
entry("work/invoice.md", "Invoice"),
];
let rows = rows(&entries, &open_set(&[]), "matrix");
assert_eq!(
shape(&rows),
vec!["▾interviews", " ▾interviews/stories", " matrix"]
);
}
#[test]
fn a_filtered_tree_unfolds_its_matches_without_anything_being_clicked() {
let entries = vec![entry("a/b/c/deep.md", "Deep")];
let rows = rows(&entries, &open_set(&[]), "deep");
assert!(rows.iter().all(|r| match &r.kind {
RowKind::Folder { open, .. } => *open,
RowKind::Note { .. } => true,
}));
assert_eq!(rows.len(), 4);
}
#[test]
fn a_query_that_matches_nothing_leaves_an_empty_tree_rather_than_the_whole_vault() {
let entries = vec![entry("work/a.md", "A")];
assert!(rows(&entries, &open_set(&[]), "zzzz").is_empty());
}
#[test]
fn entering_browse_unfolds_the_folder_of_the_note_you_are_in_and_selects_that_note() {
let entries = vec![
entry("archive/old.md", "Old"),
entry("interviews/stories/matrix.md", "Story Matrix"),
];
let mut open = open_set(&[]);
let at = reveal(&entries, &mut open, Some(&entries[1].path.clone()), "");
assert_eq!(at, 3);
assert!(open.contains("interviews") && open.contains("interviews/stories"));
assert!(!open.contains("archive"));
match &rows(&entries, &open, "")[at].kind {
RowKind::Note { entry, .. } => assert_eq!(*entry, 1),
_ => panic!("expected the note row"),
}
}
#[test]
fn entering_browse_on_a_note_the_index_never_saw_selects_the_first_row() {
let entries = vec![entry("work/a.md", "A")];
let mut open = open_set(&[]);
assert_eq!(reveal(&entries, &mut open, None, ""), 0);
assert_eq!(
reveal(&entries, &mut open, Some(Path::new("/nowhere/gone.md")), ""),
0
);
assert!(open.is_empty());
}
#[test]
fn revealing_with_a_query_typed_counts_rows_in_the_tree_the_query_leaves() {
let entries = vec![
entry("archive/old.md", "Old"),
entry("interviews/stories/matrix.md", "Story Matrix"),
];
let mut open = open_set(&[]);
let at = reveal(
&entries,
&mut open,
Some(&entries[0].path.clone()),
"matrix",
);
assert_eq!(
shape(&rows(&entries, &open, "matrix")),
vec!["▾interviews", " ▾interviews/stories", " matrix"]
);
assert_eq!(at, 0);
let at = reveal(
&entries,
&mut open,
Some(&entries[1].path.clone()),
"matrix",
);
assert_eq!(at, 2);
}
#[test]
fn a_folder_outside_the_home_directory_is_one_row_named_by_its_whole_path() {
let entries = vec![
far("/tmp/scratch", "/tmp/scratch/x.md", "Outside"),
entry("tmp/y.md", "Inside"),
];
let rows = rows(&entries, &open_set(&["/tmp/scratch", "tmp"]), "");
assert_eq!(shape(&rows), vec!["▾/tmp/scratch", " x", "▾tmp", " y"]);
}
#[test]
fn the_left_key_finds_the_folder_a_row_lives_in() {
let entries = vec![
entry("interviews/stories/matrix.md", "Story Matrix"),
entry("interviews/prep.md", "Prep"),
];
let rows = rows(
&entries,
&open_set(&["interviews", "interviews/stories"]),
"",
);
assert_eq!(parent_of(&rows, 0), None);
assert_eq!(parent_of(&rows, 1), Some(0));
assert_eq!(parent_of(&rows, 2), Some(1));
assert_eq!(parent_of(&rows, 3), Some(0));
}
}