use std::collections::HashSet;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone)]
pub struct Entry {
pub path: PathBuf,
pub name: String,
pub is_dir: bool,
pub is_open: bool,
pub depth: usize,
}
#[derive(Debug)]
pub struct Tree {
root: PathBuf,
entries: Vec<Entry>,
expanded: HashSet<PathBuf>,
}
impl Tree {
#[must_use]
pub fn new(root: PathBuf) -> Self {
let mut tree = Self {
root,
entries: Vec::new(),
expanded: HashSet::new(),
};
tree.expanded.insert(tree.root.clone());
tree.refresh();
tree
}
#[must_use]
pub fn root(&self) -> &Path {
&self.root
}
#[must_use]
pub fn entries(&self) -> &[Entry] {
&self.entries
}
#[must_use]
pub fn creation_directory(&self, index: usize) -> PathBuf {
let Some(entry) = self.entries.get(index) else {
return self.root.clone();
};
if entry.is_dir {
entry.path.clone()
} else {
entry
.path
.parent()
.map_or_else(|| self.root.clone(), Path::to_path_buf)
}
}
pub fn reveal(&mut self, path: &Path) -> Option<usize> {
if !path.starts_with(&self.root) {
self.refresh();
return None;
}
let mut parent = path.parent();
while let Some(directory) = parent {
if !directory.starts_with(&self.root) {
break;
}
self.expanded.insert(directory.to_path_buf());
if directory == self.root {
break;
}
parent = directory.parent();
}
self.refresh();
self.entries.iter().position(|entry| entry.path == path)
}
pub fn refresh(&mut self) {
self.entries.clear();
let root = self.root.clone();
self.collect(&root, 0);
}
pub fn activate(&mut self, index: usize) -> Option<PathBuf> {
let entry = self.entries.get(index)?;
if !entry.is_dir {
return Some(entry.path.clone());
}
let path = entry.path.clone();
if !self.expanded.remove(&path) {
self.expanded.insert(path);
}
self.refresh();
None
}
fn collect(&mut self, directory: &Path, depth: usize) {
let Ok(read) = std::fs::read_dir(directory) else {
return;
};
let mut children: Vec<Entry> = read
.flatten()
.filter_map(|item| {
let path = item.path();
let name = path.file_name()?.to_str()?.to_string();
if name.starts_with('.') {
return None;
}
let is_dir = item.file_type().is_ok_and(|kind| kind.is_dir());
Some(Entry {
is_open: is_dir && self.expanded.contains(&path),
path,
name,
is_dir,
depth,
})
})
.collect();
children.sort_by(|a, b| {
b.is_dir
.cmp(&a.is_dir)
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
});
for child in children {
let recurse = child.is_open.then(|| child.path.clone());
self.entries.push(child);
if let Some(path) = recurse {
self.collect(&path, depth + 1);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn fixture(name: &str) -> PathBuf {
let root = std::env::temp_dir().join(format!("termi-tree-{name}"));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(root.join("src")).expect("create fixture");
std::fs::create_dir_all(root.join(".hidden")).expect("create fixture");
std::fs::write(root.join("Cargo.toml"), "").expect("create fixture");
std::fs::write(root.join("src/main.rs"), "").expect("create fixture");
root
}
#[test]
fn directories_sort_before_files() {
let tree = Tree::new(fixture("sorting"));
let names: Vec<&str> = tree.entries().iter().map(|e| e.name.as_str()).collect();
assert_eq!(names, vec!["src", "Cargo.toml"]);
}
#[test]
fn hidden_entries_are_skipped() {
let tree = Tree::new(fixture("hidden"));
assert!(tree.entries().iter().all(|e| e.name != ".hidden"));
}
#[test]
fn expanding_a_directory_reveals_its_children() {
let mut tree = Tree::new(fixture("expand"));
assert_eq!(tree.entries().len(), 2);
assert_eq!(tree.activate(0), None);
let names: Vec<&str> = tree.entries().iter().map(|e| e.name.as_str()).collect();
assert_eq!(names, vec!["src", "main.rs", "Cargo.toml"]);
assert_eq!(tree.entries()[1].depth, 1);
assert_eq!(tree.activate(0), None);
assert_eq!(tree.entries().len(), 2);
}
#[test]
fn activating_a_file_returns_its_path() {
let root = fixture("activate");
let mut tree = Tree::new(root.clone());
assert_eq!(tree.activate(1), Some(root.join("Cargo.toml")));
}
#[test]
fn an_out_of_range_index_is_ignored() {
let mut tree = Tree::new(fixture("range"));
assert_eq!(tree.activate(99), None);
}
#[test]
fn creation_uses_a_directory_or_a_files_parent() {
let root = fixture("creation-directory");
let tree = Tree::new(root.clone());
assert_eq!(tree.creation_directory(0), root.join("src"));
assert_eq!(tree.creation_directory(1), root);
}
#[test]
fn creation_in_an_empty_tree_uses_the_root() {
let root = fixture("empty-creation");
std::fs::remove_dir_all(root.join("src")).expect("remove fixture directory");
std::fs::remove_file(root.join("Cargo.toml")).expect("remove fixture file");
let tree = Tree::new(root.clone());
assert_eq!(tree.creation_directory(0), root);
}
#[test]
fn revealing_a_file_opens_its_parent_and_finds_its_row() {
let root = fixture("reveal");
let mut tree = Tree::new(root.clone());
let path = root.join("src/main.rs");
let index = tree.reveal(&path).expect("file should be visible");
assert_eq!(tree.entries()[index].path, path);
assert!(tree.entries()[0].is_open);
}
}