mod view;
pub use view::{
gutter_width, read_file, seg_text, selection_text, wrap_ranges, FileLoad, FileView, SIZE_CAP,
};
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Entry {
pub name: String,
pub is_dir: bool,
}
#[derive(Clone, Default)]
struct Dir {
entries: Vec<Entry>,
loaded: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct VisibleRow {
pub path: PathBuf,
pub name: String,
pub depth: u16,
pub is_dir: bool,
pub expanded: bool,
pub loading: bool,
}
pub struct FileTree {
root: PathBuf,
dirs: HashMap<PathBuf, Dir>,
expanded: HashSet<PathBuf>,
pending: HashSet<PathBuf>,
pub cursor: usize,
pub scroll: usize,
pub show_hidden: bool,
cache: Vec<VisibleRow>,
dirty: bool,
cache_hidden: bool,
}
impl FileTree {
pub fn new(root: PathBuf) -> Self {
FileTree {
root,
dirs: HashMap::new(),
expanded: HashSet::new(),
pending: HashSet::new(),
cursor: 0,
scroll: 0,
show_hidden: false,
cache: Vec::new(),
dirty: true,
cache_hidden: false,
}
}
pub fn root(&self) -> &Path {
&self.root
}
pub fn set_root(&mut self, root: PathBuf) {
if root == self.root {
return;
}
self.root = root;
self.expanded.clear();
self.pending.clear();
self.cursor = 0;
self.scroll = 0;
self.dirty = true;
}
pub fn needs_load(&self) -> Vec<PathBuf> {
let mut out = Vec::new();
let consider = |p: &Path, out: &mut Vec<PathBuf>| {
if !self.is_loaded(p) && !self.pending.contains(p) {
out.push(p.to_path_buf());
}
};
consider(&self.root, &mut out);
for p in &self.expanded {
consider(p, &mut out);
}
out
}
pub fn mark_pending(&mut self, path: PathBuf) {
self.pending.insert(path);
}
fn is_loaded(&self, path: &Path) -> bool {
self.dirs.get(path).is_some_and(|d| d.loaded)
}
pub fn apply_dir(&mut self, path: PathBuf, entries: Vec<Entry>) {
self.pending.remove(&path);
if let Some(d) = self.dirs.get(&path) {
if d.loaded && d.entries == entries {
return;
}
}
self.dirty = true;
self.dirs.insert(
path,
Dir {
entries,
loaded: true,
},
);
}
pub fn loaded_visible_dirs(&self) -> Vec<PathBuf> {
let mut out = Vec::new();
if self.is_loaded(&self.root) {
out.push(self.root.clone());
}
for p in &self.expanded {
if self.is_loaded(p) {
out.push(p.clone());
}
}
out
}
pub fn toggle(&mut self, path: &Path) {
self.dirty = true;
if self.expanded.contains(path) {
self.expanded.retain(|p| p != path && !p.starts_with(path));
} else {
self.expanded.insert(path.to_path_buf());
}
}
pub fn reveal(&mut self, path: &Path) {
let mut cur = path.parent();
while let Some(dir) = cur {
if !dir.starts_with(&self.root) && dir != self.root {
break;
}
if dir != self.root {
self.expanded.insert(dir.to_path_buf());
self.dirty = true;
}
if dir == self.root {
break;
}
cur = dir.parent();
}
}
pub fn invalidate(&mut self) {
self.dirs.clear();
self.pending.clear();
self.dirty = true;
}
pub fn visible_rows(&mut self) -> &[VisibleRow] {
if self.dirty || self.cache_hidden != self.show_hidden {
self.cache = self.compute_rows();
self.dirty = false;
self.cache_hidden = self.show_hidden;
}
&self.cache
}
fn compute_rows(&self) -> Vec<VisibleRow> {
let mut out = Vec::new();
self.flatten(&self.root, 0, &mut out);
out
}
fn flatten(&self, dir: &Path, depth: u16, out: &mut Vec<VisibleRow>) {
let Some(d) = self.dirs.get(dir) else {
return;
};
for e in &d.entries {
if e.name == ".git" {
continue;
}
if !self.show_hidden && e.name.starts_with('.') {
continue;
}
let path = dir.join(&e.name);
let expanded = e.is_dir && self.expanded.contains(&path);
out.push(VisibleRow {
path: path.clone(),
name: e.name.clone(),
depth,
is_dir: e.is_dir,
expanded,
loading: expanded && !self.is_loaded(&path),
});
if expanded {
self.flatten(&path, depth + 1, out);
}
}
}
}
pub fn read_dir_entries(path: &Path) -> Vec<Entry> {
let mut entries: Vec<Entry> = match std::fs::read_dir(path) {
Ok(rd) => rd
.flatten()
.map(|de| {
let name = de.file_name().to_string_lossy().into_owned();
let is_dir = de
.file_type()
.map(|ft| ft.is_dir())
.unwrap_or_else(|_| de.path().is_dir());
Entry { name, is_dir }
})
.collect(),
Err(_) => Vec::new(),
};
entries.sort_by(|a, b| {
b.is_dir
.cmp(&a.is_dir)
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
});
entries
}
#[cfg(test)]
mod tests {
use super::*;
fn e(name: &str, is_dir: bool) -> Entry {
Entry {
name: name.into(),
is_dir,
}
}
#[test]
fn only_expanded_dirs_are_flattened() {
let root = PathBuf::from("/r");
let mut t = FileTree::new(root.clone());
t.apply_dir(root.clone(), vec![e("src", true), e("README.md", false)]);
let rows = t.visible_rows();
assert_eq!(rows.len(), 2);
assert_eq!(rows[0].name, "src");
assert!(rows[0].is_dir && !rows[0].expanded);
assert_eq!(rows[1].name, "README.md");
t.toggle(&root.join("src"));
assert_eq!(t.needs_load(), vec![root.join("src")]);
let rows = t.visible_rows();
assert!(rows[0].expanded && rows[0].loading);
t.apply_dir(root.join("src"), vec![e("mod.rs", false)]);
let rows = t.visible_rows();
assert_eq!(rows.len(), 3);
assert_eq!((rows[1].name.as_str(), rows[1].depth), ("mod.rs", 1));
assert!(!rows[0].loading);
}
#[test]
fn collapsing_forgets_open_descendants() {
let root = PathBuf::from("/r");
let mut t = FileTree::new(root.clone());
t.apply_dir(root.clone(), vec![e("a", true)]);
t.apply_dir(root.join("a"), vec![e("b", true)]);
t.toggle(&root.join("a"));
t.toggle(&root.join("a/b"));
assert_eq!(t.visible_rows().iter().filter(|r| r.expanded).count(), 2);
t.toggle(&root.join("a")); let rows = t.visible_rows();
assert_eq!(rows.len(), 1, "only a shows, collapsed");
assert!(!rows[0].expanded, "a is collapsed");
t.toggle(&root.join("a"));
let b = t
.visible_rows()
.iter()
.find(|r| r.name == "b")
.cloned()
.unwrap();
assert!(!b.expanded, "descendant open state forgotten");
}
#[test]
fn hidden_filter_and_git_are_display_only() {
let root = PathBuf::from("/r");
let mut t = FileTree::new(root.clone());
t.apply_dir(
root.clone(),
vec![e(".git", true), e(".env", false), e("main.rs", false)],
);
let names: Vec<_> = t.visible_rows().iter().map(|r| r.name.clone()).collect();
assert_eq!(names, vec!["main.rs"]);
t.show_hidden = true;
let names: Vec<_> = t.visible_rows().iter().map(|r| r.name.clone()).collect();
assert_eq!(names, vec![".env", "main.rs"]);
}
#[test]
fn set_root_resets_open_state_but_reader_sorts() {
let mut t = FileTree::new(PathBuf::from("/r"));
t.apply_dir(PathBuf::from("/r"), vec![e("x", true)]);
t.toggle(&PathBuf::from("/r/x"));
t.cursor = 5;
t.set_root(PathBuf::from("/other"));
assert_eq!(t.root(), Path::new("/other"));
assert!(t.needs_load().contains(&PathBuf::from("/other")));
assert_eq!(t.cursor, 0);
}
}