use std::path::{Path, PathBuf};
use crate::buffer::ScreenBuffer;
use crate::error::SaorsaTuiError;
use crate::event::Event;
use crate::geometry::Rect;
use crate::segment::Segment;
use crate::style::Style;
use super::tree::{Tree, TreeNode};
use super::{BorderStyle, EventResult, InteractiveWidget, Widget};
pub struct DirectoryTree {
tree: Tree<PathBuf>,
show_hidden: bool,
}
impl DirectoryTree {
pub fn new(root: PathBuf) -> Result<Self, SaorsaTuiError> {
if !root.exists() {
return Err(SaorsaTuiError::Widget(format!(
"path does not exist: {}",
root.display()
)));
}
if !root.is_dir() {
return Err(SaorsaTuiError::Widget(format!(
"path is not a directory: {}",
root.display()
)));
}
let root_node = TreeNode::branch(root);
let show_hidden = false;
let tree = Tree::new(vec![root_node])
.with_render_fn(|data: &PathBuf, _depth, _expanded, is_leaf| {
let name = data
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| data.display().to_string());
let icon = if is_leaf {
"\u{1f4c4}" } else {
"\u{1f4c1}" };
vec![Segment::new(format!("{icon} {name}"))]
})
.with_lazy_load(move |path: &PathBuf| load_directory(path, false));
Ok(Self { tree, show_hidden })
}
#[must_use]
pub fn with_show_hidden(mut self, enabled: bool) -> Self {
self.show_hidden = enabled;
let show = self.show_hidden;
self.tree = self
.tree
.with_lazy_load(move |path: &PathBuf| load_directory(path, show));
self
}
#[must_use]
pub fn with_node_style(mut self, style: Style) -> Self {
self.tree = self.tree.with_node_style(style);
self
}
#[must_use]
pub fn with_selected_style(mut self, style: Style) -> Self {
self.tree = self.tree.with_selected_style(style);
self
}
#[must_use]
pub fn with_border(mut self, border: BorderStyle) -> Self {
self.tree = self.tree.with_border(border);
self
}
pub fn selected_path(&self) -> Option<&PathBuf> {
self.tree.selected_node().map(|node| &node.data)
}
pub fn toggle_selected(&mut self) {
self.tree.toggle_selected();
}
pub fn expand_selected(&mut self) {
self.tree.expand_selected();
}
pub fn collapse_selected(&mut self) {
self.tree.collapse_selected();
}
pub fn visible_count(&self) -> usize {
self.tree.visible_count()
}
pub fn show_hidden(&self) -> bool {
self.show_hidden
}
}
impl Widget for DirectoryTree {
fn render(&self, area: Rect, buf: &mut ScreenBuffer) {
self.tree.render(area, buf);
}
}
impl InteractiveWidget for DirectoryTree {
fn handle_event(&mut self, event: &Event) -> EventResult {
self.tree.handle_event(event)
}
}
fn load_directory(path: &Path, show_hidden: bool) -> Vec<TreeNode<PathBuf>> {
let entries = match std::fs::read_dir(path) {
Ok(e) => e,
Err(_) => return Vec::new(),
};
let mut dirs: Vec<PathBuf> = Vec::new();
let mut files: Vec<PathBuf> = Vec::new();
for entry in entries.flatten() {
let entry_path = entry.path();
let name = entry.file_name().to_string_lossy().to_string();
if !show_hidden && name.starts_with('.') {
continue;
}
if entry_path.is_dir() {
dirs.push(entry_path);
} else {
files.push(entry_path);
}
}
dirs.sort_by(|a, b| {
let a_name = a.file_name().map(|n| n.to_string_lossy().to_lowercase());
let b_name = b.file_name().map(|n| n.to_string_lossy().to_lowercase());
a_name.cmp(&b_name)
});
files.sort_by(|a, b| {
let a_name = a.file_name().map(|n| n.to_string_lossy().to_lowercase());
let b_name = b.file_name().map(|n| n.to_string_lossy().to_lowercase());
a_name.cmp(&b_name)
});
let mut nodes = Vec::with_capacity(dirs.len() + files.len());
for dir in dirs {
nodes.push(TreeNode::branch(dir));
}
for file in files {
nodes.push(TreeNode::new(file));
}
nodes
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use crate::geometry::Size;
use std::fs;
fn create_test_dir() -> tempfile::TempDir {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
fs::create_dir_all(root.join("alpha")).unwrap();
fs::create_dir_all(root.join("beta")).unwrap();
fs::create_dir_all(root.join("alpha/nested")).unwrap();
fs::write(root.join("file_a.txt"), "hello").unwrap();
fs::write(root.join("file_b.txt"), "world").unwrap();
fs::write(root.join("alpha/child.txt"), "child").unwrap();
fs::write(root.join(".hidden_file"), "secret").unwrap();
fs::create_dir_all(root.join(".hidden_dir")).unwrap();
tmp
}
#[test]
fn create_directory_tree() {
let tmp = create_test_dir();
let dt = DirectoryTree::new(tmp.path().to_path_buf());
assert!(dt.is_ok());
}
#[test]
fn error_on_nonexistent_path() {
let result = DirectoryTree::new(PathBuf::from("/nonexistent/path/abc123"));
assert!(result.is_err());
}
#[test]
fn error_on_file_path() {
let tmp = create_test_dir();
let result = DirectoryTree::new(tmp.path().join("file_a.txt"));
assert!(result.is_err());
}
#[test]
fn lazy_load_expand_directory() {
let tmp = create_test_dir();
let mut dt = DirectoryTree::new(tmp.path().to_path_buf()).unwrap();
assert_eq!(dt.visible_count(), 1);
dt.expand_selected();
assert_eq!(dt.visible_count(), 5);
}
#[test]
fn hidden_files_filtered_by_default() {
let tmp = create_test_dir();
let mut dt = DirectoryTree::new(tmp.path().to_path_buf()).unwrap();
dt.expand_selected();
assert_eq!(dt.visible_count(), 5);
}
#[test]
fn show_hidden_files() {
let tmp = create_test_dir();
let mut dt = DirectoryTree::new(tmp.path().to_path_buf())
.unwrap()
.with_show_hidden(true);
dt.expand_selected();
assert_eq!(dt.visible_count(), 7);
}
#[test]
fn selected_path_retrieval() {
let tmp = create_test_dir();
let dt = DirectoryTree::new(tmp.path().to_path_buf()).unwrap();
match dt.selected_path() {
Some(p) => assert_eq!(p, tmp.path()),
None => unreachable!("should have selected path"),
}
}
#[test]
fn navigate_and_expand_nested() {
let tmp = create_test_dir();
let mut dt = DirectoryTree::new(tmp.path().to_path_buf()).unwrap();
dt.expand_selected();
let down = Event::Key(crate::event::KeyEvent {
code: crate::event::KeyCode::Down,
modifiers: crate::event::Modifiers::NONE,
});
dt.handle_event(&down);
dt.expand_selected();
assert_eq!(dt.visible_count(), 7);
}
#[test]
fn empty_directory_expands_to_no_children() {
let tmp = tempfile::tempdir().unwrap();
let empty = tmp.path().join("empty");
fs::create_dir_all(&empty).unwrap();
let mut dt = DirectoryTree::new(empty).unwrap();
dt.expand_selected();
assert_eq!(dt.visible_count(), 1);
}
#[test]
fn render_directory_tree() {
let tmp = create_test_dir();
let dt = DirectoryTree::new(tmp.path().to_path_buf()).unwrap();
let mut buf = ScreenBuffer::new(Size::new(40, 10));
dt.render(Rect::new(0, 0, 40, 10), &mut buf);
let cell = buf.get(0, 0);
assert!(cell.is_some());
}
#[test]
fn directories_sorted_before_files() {
let tmp = create_test_dir();
let mut dt = DirectoryTree::new(tmp.path().to_path_buf()).unwrap();
dt.expand_selected();
let down = Event::Key(crate::event::KeyEvent {
code: crate::event::KeyCode::Down,
modifiers: crate::event::Modifiers::NONE,
});
dt.handle_event(&down);
match dt.selected_path() {
Some(p) => {
let name = p.file_name().map(|n| n.to_string_lossy().to_string());
assert_eq!(name.as_deref(), Some("alpha"));
}
None => unreachable!("should have selected path"),
}
dt.handle_event(&down);
match dt.selected_path() {
Some(p) => {
let name = p.file_name().map(|n| n.to_string_lossy().to_string());
assert_eq!(name.as_deref(), Some("beta"));
}
None => unreachable!("should have selected path"),
}
dt.handle_event(&down);
match dt.selected_path() {
Some(p) => {
let name = p.file_name().map(|n| n.to_string_lossy().to_string());
assert_eq!(name.as_deref(), Some("file_a.txt"));
}
None => unreachable!("should have selected path"),
}
}
#[test]
fn border_rendering() {
let tmp = create_test_dir();
let dt = DirectoryTree::new(tmp.path().to_path_buf())
.unwrap()
.with_border(BorderStyle::Single);
let mut buf = ScreenBuffer::new(Size::new(40, 10));
dt.render(Rect::new(0, 0, 40, 10), &mut buf);
assert_eq!(buf.get(0, 0).map(|c| c.grapheme.as_str()), Some("\u{250c}"));
}
}