use std::collections::HashSet;
use std::path::{Path, PathBuf};
pub const WIDTH: u16 = 32;
pub struct Entry {
pub path: PathBuf,
pub name: String,
pub depth: usize,
pub is_dir: bool,
pub last: bool,
pub guides: Vec<bool>,
}
pub struct FileTree {
pub root: PathBuf,
expanded: HashSet<PathBuf>,
pub entries: Vec<Entry>,
pub selected: usize,
pub focused: bool,
pub show_hidden: bool,
}
pub enum Activate {
Toggled,
Open(PathBuf),
}
impl FileTree {
pub fn open(root: PathBuf) -> FileTree {
let mut t = FileTree {
expanded: HashSet::from([root.clone()]),
root,
entries: Vec::new(),
selected: 0,
focused: true,
show_hidden: false,
};
t.rebuild();
t
}
pub fn is_expanded(&self, path: &Path) -> bool {
self.expanded.contains(path)
}
pub fn selected_entry(&self) -> Option<&Entry> {
self.entries.get(self.selected)
}
pub fn up(&mut self) {
self.selected = self.selected.saturating_sub(1);
}
pub fn down(&mut self) {
self.selected = (self.selected + 1).min(self.entries.len().saturating_sub(1));
}
pub fn select_first(&mut self) {
self.selected = 0;
}
pub fn select_last(&mut self) {
self.selected = self.entries.len().saturating_sub(1);
}
pub fn refresh(&mut self) {
self.rebuild();
}
pub fn set_hidden(&mut self, hidden: bool) {
if self.show_hidden != hidden {
self.show_hidden = hidden;
self.rebuild();
}
}
pub fn toggle_hidden(&mut self) -> bool {
let was = self.selected_entry().map(|e| e.path.clone());
self.show_hidden = !self.show_hidden;
self.rebuild();
if let Some(path) = was {
if !self.select_path(&path) {
self.selected = self.selected.min(self.entries.len().saturating_sub(1));
}
}
self.show_hidden
}
pub fn target_dir(&self) -> PathBuf {
match self.selected_entry() {
Some(e) if e.is_dir => e.path.clone(),
Some(e) => e.path.parent().map(Path::to_path_buf).unwrap_or_else(|| self.root.clone()),
None => self.root.clone(),
}
}
pub fn reveal(&mut self, path: &Path) {
let mut dir = path.parent();
while let Some(d) = dir {
self.expanded.insert(d.to_path_buf());
if d == self.root {
break;
}
dir = d.parent();
}
self.rebuild();
self.select_path(path);
}
pub fn root_up(&mut self) -> bool {
let Some(parent) = self.root.parent().map(Path::to_path_buf) else {
return false;
};
let was = std::mem::replace(&mut self.root, parent);
self.expanded.insert(self.root.clone());
self.expanded.insert(was.clone());
self.rebuild();
self.select_path(&was);
true
}
pub fn root_into(&mut self) -> bool {
let Some(entry) = self.selected_entry() else {
return false;
};
if !entry.is_dir || entry.path == self.root {
return false;
}
self.root = entry.path.clone();
self.expanded.insert(self.root.clone());
self.rebuild();
self.select_first();
true
}
pub fn select_path(&mut self, path: &Path) -> bool {
match self.entries.iter().position(|e| e.path == path) {
Some(i) => {
self.selected = i;
true
}
None => false,
}
}
pub fn select_nearest(&mut self, was: usize) {
self.selected = was.min(self.entries.len().saturating_sub(1));
}
pub fn selected_is_root(&self) -> bool {
self.selected_entry().is_some_and(|e| e.path == self.root)
}
pub fn relative(&self, path: &Path) -> String {
let rel = path.strip_prefix(&self.root).unwrap_or(path);
if rel.as_os_str().is_empty() {
return self
.root
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| self.root.display().to_string());
}
rel.to_string_lossy().into_owned()
}
pub fn resolve(&self, input: &str) -> PathBuf {
let p = Path::new(input.trim());
if p.is_absolute() {
p.to_path_buf()
} else {
self.root.join(p)
}
}
pub fn activate(&mut self) -> Option<Activate> {
let entry = self.entries.get(self.selected)?;
if entry.is_dir {
let path = entry.path.clone();
if !self.expanded.insert(path.clone()) {
self.expanded.remove(&path);
}
self.rebuild();
Some(Activate::Toggled)
} else {
Some(Activate::Open(entry.path.clone()))
}
}
pub fn collapse_or_parent(&mut self) {
let Some(entry) = self.entries.get(self.selected) else {
return;
};
if entry.is_dir && self.expanded.contains(&entry.path) && entry.depth > 0 {
let path = entry.path.clone();
self.expanded.remove(&path);
self.rebuild();
return;
}
let depth = entry.depth;
if depth == 0 {
return;
}
for i in (0..self.selected).rev() {
if self.entries[i].depth < depth {
self.selected = i;
return;
}
}
}
fn rebuild(&mut self) {
let keep = self.selected_entry().map(|e| e.path.clone());
self.entries.clear();
let name = self
.root
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| self.root.display().to_string());
self.entries.push(Entry {
path: self.root.clone(),
name,
depth: 0,
is_dir: true,
last: true,
guides: Vec::new(),
});
self.push_children(&self.root.clone(), 1, &mut Vec::new());
self.selected = keep
.and_then(|p| self.entries.iter().position(|e| e.path == p))
.unwrap_or(self.selected)
.min(self.entries.len().saturating_sub(1));
}
fn push_children(&mut self, dir: &Path, depth: usize, guides: &mut Vec<bool>) {
if !self.expanded.contains(dir) {
return;
}
let items = read_dir_sorted(dir, self.show_hidden);
let last_index = items.len().saturating_sub(1);
for (i, (path, is_dir, name)) in items.into_iter().enumerate() {
let last = i == last_index;
self.entries.push(Entry {
path: path.clone(),
name,
depth,
is_dir,
last,
guides: guides.clone(),
});
if is_dir {
guides.push(!last);
self.push_children(&path, depth + 1, guides);
guides.pop();
}
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum IconKind {
Prose,
Code,
Data,
Media,
Plain,
}
pub fn file_icon(name: &str) -> (&'static str, IconKind) {
let lower = name.to_lowercase();
if lower.starts_with("cargo.") {
return (RUST, IconKind::Code);
}
if lower.starts_with("readme") {
return (MARKDOWN, IconKind::Prose);
}
let ext = lower.rsplit_once('.').map(|(_, e)| e).unwrap_or("");
match ext {
"md" | "markdown" | "mdx" => (MARKDOWN, IconKind::Prose),
"txt" | "text" | "org" | "rst" => ("\u{f15c}", IconKind::Prose), "rs" => (RUST, IconKind::Code),
"py" => ("\u{e73c}", IconKind::Code), "js" | "mjs" | "cjs" => ("\u{e74e}", IconKind::Code), "ts" | "tsx" => ("\u{e628}", IconKind::Code), "html" | "htm" => ("\u{f13b}", IconKind::Code), "css" | "scss" => ("\u{f13c}", IconKind::Code), "sh" | "bash" | "zsh" | "fish" => ("\u{f489}", IconKind::Code), "c" | "h" | "cpp" | "hpp" | "cc" | "go" | "lua" | "rb" | "java" | "swift" => {
("\u{f121}", IconKind::Code) }
"json" => ("\u{e60b}", IconKind::Data), "toml" | "conf" | "ini" | "cfg" | "yaml" | "yml" => ("\u{f013}", IconKind::Data), "lock" => ("\u{f023}", IconKind::Data), "zip" | "gz" | "tar" | "xz" | "zst" => ("\u{f1c6}", IconKind::Data), "png" | "jpg" | "jpeg" | "gif" | "webp" | "svg" | "ico" => {
("\u{f1c5}", IconKind::Media) }
"pdf" => ("\u{f1c1}", IconKind::Media), "mp4" | "mov" | "mkv" => ("\u{f1c8}", IconKind::Media), "mp3" | "wav" | "flac" => ("\u{f1c7}", IconKind::Media), _ => ("\u{f15b}", IconKind::Plain), }
}
const MARKDOWN: &str = "\u{f48a}"; const RUST: &str = "\u{e7a8}";
pub fn read_dir_sorted(dir: &Path, hidden: bool) -> Vec<(PathBuf, bool, String)> {
let mut items: Vec<(PathBuf, bool, String)> = std::fs::read_dir(dir)
.into_iter()
.flatten()
.flatten()
.filter_map(|e| {
let name = e.file_name().to_string_lossy().into_owned();
if !hidden && name.starts_with('.') {
return None;
}
let is_dir = e.path().is_dir();
Some((e.path(), is_dir, name))
})
.collect();
items.sort_by(|a, b| {
b.1.cmp(&a.1)
.then_with(|| a.2.to_lowercase().cmp(&b.2.to_lowercase()))
});
items
}
#[cfg(test)]
mod tests {
use super::*;
fn fixture() -> PathBuf {
let t = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let d = std::env::temp_dir().join(format!("shoin-tree-{t}"));
std::fs::create_dir_all(d.join("sub")).unwrap();
std::fs::write(d.join("a.md"), "").unwrap();
std::fs::write(d.join("sub").join("b.md"), "").unwrap();
std::fs::write(d.join(".hidden"), "").unwrap();
d
}
#[test]
fn relative_and_target_dir_name_the_right_places() {
let d = fixture();
let mut t = FileTree::open(d.clone());
t.select_path(&d.join("a.md"));
assert_eq!(t.target_dir(), d, "a file's neighbours are its parent dir");
assert_eq!(
t.relative(&t.target_dir()),
d.file_name().unwrap().to_string_lossy(),
"the ROOT shows its own name, not an empty string"
);
assert_eq!(t.relative(&d.join("sub/b.md")), "sub/b.md");
t.select_path(&d.join("sub"));
assert_eq!(t.target_dir(), d.join("sub"), "a dir row means inside it");
assert_eq!(t.relative(&t.target_dir()), "sub");
std::fs::remove_dir_all(&d).ok();
}
#[test]
fn dirs_first_dotfiles_hidden() {
let d = fixture();
let t = FileTree::open(d.clone());
assert_eq!(t.entries[0].depth, 0); assert!(!t.entries.iter().any(|e| e.name == ".hidden"));
let sub = t.entries.iter().position(|e| e.name == "sub").unwrap();
let file = t.entries.iter().position(|e| e.name == "a.md").unwrap();
assert!(sub < file, "directories sort before files");
std::fs::remove_dir_all(&d).ok();
}
#[test]
fn guides_track_the_ancestor_chain() {
let d = fixture();
let mut t = FileTree::open(d.clone());
let sub = t.entries.iter().position(|e| e.name == "sub").unwrap();
let a = t.entries.iter().position(|e| e.name == "a.md").unwrap();
assert!(!t.entries[sub].last, "a.md still follows it");
assert!(t.entries[a].last, "nothing after a.md");
assert!(t.entries[sub].guides.is_empty(), "depth 1 has no ancestor columns");
t.selected = sub;
t.activate();
let b = t.entries.iter().position(|e| e.name == "b.md").unwrap();
assert_eq!(t.entries[b].guides, vec![true], "a `│` runs past sub's subtree");
assert!(t.entries[b].last);
std::fs::remove_dir_all(&d).ok();
}
#[test]
fn icons_follow_the_file_type() {
assert_eq!(file_icon("notes.md").1, IconKind::Prose);
assert_eq!(file_icon("README").1, IconKind::Prose);
assert_eq!(file_icon("app.rs"), (RUST, IconKind::Code));
assert_eq!(file_icon("Cargo.lock"), (RUST, IconKind::Code));
assert_eq!(file_icon("theme.conf").1, IconKind::Data);
assert_eq!(file_icon("shot.PNG").1, IconKind::Media, "extension match is case-blind");
assert_eq!(file_icon("mystery").1, IconKind::Plain);
for name in ["notes.md", "app.rs", "x.json", "x.png", "mystery"] {
assert_eq!(file_icon(name).0.chars().count(), 1);
}
}
#[test]
fn h_reveals_dotfiles_and_keeps_the_cursor_put() {
let d = fixture();
let mut t = FileTree::open(d.clone());
t.select_path(&d.join("a.md"));
assert!(!t.entries.iter().any(|e| e.name == ".hidden"), "hidden by default");
assert!(t.toggle_hidden(), "H turns them on");
assert!(t.entries.iter().any(|e| e.name == ".hidden"), "now listed");
assert_eq!(
t.selected_entry().unwrap().path,
d.join("a.md"),
"the cursor keeps its entry, not its row index"
);
assert!(!t.toggle_hidden(), "H turns them off again");
assert!(!t.entries.iter().any(|e| e.name == ".hidden"));
assert_eq!(t.selected_entry().unwrap().path, d.join("a.md"));
}
#[test]
fn the_root_climbs_out_and_back_in() {
let d = fixture();
let mut t = FileTree::open(d.join("sub"));
assert!(t.root_up());
assert_eq!(t.root, d);
assert_eq!(t.selected_entry().unwrap().path, d.join("sub"));
assert!(t.root_into());
assert_eq!(t.root, d.join("sub"));
t.select_path(&d.join("sub").join("b.md"));
assert!(!t.root_into());
t.select_first();
assert!(!t.root_into());
}
#[test]
fn climbing_stops_at_the_filesystem_root() {
let mut t = FileTree::open(PathBuf::from("/"));
assert!(!t.root_up(), "/ has no parent to climb to");
assert_eq!(t.root, PathBuf::from("/"));
}
#[test]
fn expand_then_open_a_file() {
let d = fixture();
let mut t = FileTree::open(d.clone());
t.selected = t.entries.iter().position(|e| e.name == "sub").unwrap();
assert!(matches!(t.activate(), Some(Activate::Toggled)));
assert!(t.entries.iter().any(|e| e.name == "b.md"), "child now visible");
t.selected = t.entries.iter().position(|e| e.name == "b.md").unwrap();
match t.activate() {
Some(Activate::Open(p)) => assert!(p.ends_with("b.md")),
_ => panic!("activating a file should open it"),
}
std::fs::remove_dir_all(&d).ok();
}
}