use std::fs;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use rich_rs::Console;
use textual::message::{AsyncDirectoryEntry, AsyncTaskResult, MessageEvent};
use textual::prelude::*;
use textual::render::FrameBuffer;
struct TempTreeDir {
path: PathBuf,
}
impl TempTreeDir {
fn new(label: &str) -> Self {
let mut path = std::env::temp_dir();
let stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("clock before epoch")
.as_nanos();
path.push(format!("textual-rs-{label}-{}-{stamp}", std::process::id()));
fs::create_dir_all(&path).expect("create temp test directory");
Self { path }
}
}
impl Drop for TempTreeDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.path);
}
}
fn options_for(console: &Console, width: usize, height: usize) -> rich_rs::ConsoleOptions {
let mut options = console.options().clone();
options.size = (width, height);
options.max_width = width;
options.max_height = height;
options
}
#[test]
fn directory_tree_renders_root_and_visible_entries() {
let temp = TempTreeDir::new("directory-tree-visible");
fs::create_dir_all(temp.path.join("src")).expect("create child dir");
fs::write(temp.path.join("Cargo.toml"), "[package]\nname = \"demo\"\n").expect("write file");
fs::write(temp.path.join(".hidden"), "hidden").expect("write hidden file");
let console = Console::new();
let options = options_for(&console, 48, 6);
let mut tree = DirectoryTree::new(&temp.path);
tree.on_layout(48, 6);
let buf = FrameBuffer::from_renderable(&console, &options, &tree, None);
let lines = buf.as_plain_lines();
assert!(lines.iter().any(|line| line.contains("src")));
assert!(lines.iter().any(|line| line.contains("Cargo.toml")));
assert!(!lines.iter().any(|line| line.contains(".hidden")));
}
#[test]
fn directory_tree_lazy_loads_children_on_expand_message_flow() {
let temp = TempTreeDir::new("directory-tree-expand");
let nested_dir = temp.path.join("nested");
fs::create_dir_all(&nested_dir).expect("create nested dir");
fs::write(nested_dir.join("leaf.txt"), "leaf").expect("write nested file");
let mut tree = DirectoryTree::new(&temp.path);
tree.on_layout(60, 8);
let mut message_ctx = EventCtx::default();
tree.on_message(
&MessageEvent {
sender: tree.tree_id(),
message: Message::TreeNodeToggled(TreeNodeToggled {
index: 1,
label: "nested".to_string(),
expanded: true,
}),
control: None,
},
&mut message_ctx,
);
assert!(message_ctx.handled());
let console = Console::new();
let options = options_for(&console, 60, 8);
let before_tick = FrameBuffer::from_renderable(&console, &options, &tree, None);
let before_tick_lines = before_tick.as_plain_lines();
assert!(
!before_tick_lines
.iter()
.any(|line| line.contains("leaf.txt"))
);
tree.on_message(
&MessageEvent {
sender: NodeId::default(),
message: Message::AsyncTaskCompleted(AsyncTaskCompleted {
task_id: 1,
target: NodeId::default(),
result: AsyncTaskResult::DirectoryEntries {
path: nested_dir.display().to_string(),
entries: vec![AsyncDirectoryEntry {
path: nested_dir.join("leaf.txt").display().to_string(),
label: "leaf.txt".to_string(),
is_dir: false,
}],
},
}),
control: None,
},
&mut EventCtx::default(),
);
let after_tick = FrameBuffer::from_renderable(&console, &options, &tree, None);
let lines = after_tick.as_plain_lines();
assert!(lines.iter().any(|line| line.contains("leaf.txt")));
}
#[test]
fn directory_tree_refresh_preserves_expanded_paths() {
let temp = TempTreeDir::new("directory-tree-refresh-expanded");
let nested_dir = temp.path.join("nested");
fs::create_dir_all(&nested_dir).expect("create nested dir");
fs::write(nested_dir.join("leaf.txt"), "leaf").expect("write nested file");
let mut tree = DirectoryTree::new(&temp.path);
tree.on_layout(60, 8);
let mut message_ctx = EventCtx::default();
tree.on_message(
&MessageEvent {
sender: tree.tree_id(),
message: Message::TreeNodeToggled(TreeNodeToggled {
index: 1,
label: "nested".to_string(),
expanded: true,
}),
control: None,
},
&mut message_ctx,
);
assert!(message_ctx.handled());
tree.refresh();
let console = Console::new();
let options = options_for(&console, 60, 8);
let buf = FrameBuffer::from_renderable(&console, &options, &tree, None);
let lines = buf.as_plain_lines();
assert!(lines.iter().any(|line| line.contains("leaf.txt")));
}
#[test]
fn directory_tree_collapsing_node_cancels_pending_lazy_load() {
let temp = TempTreeDir::new("directory-tree-collapse-cancels-pending");
let nested_dir = temp.path.join("nested");
fs::create_dir_all(&nested_dir).expect("create nested dir");
fs::write(nested_dir.join("leaf.txt"), "leaf").expect("write nested file");
let mut tree = DirectoryTree::new(&temp.path);
tree.on_layout(60, 8);
let mut expand_ctx = EventCtx::default();
tree.on_message(
&MessageEvent {
sender: tree.tree_id(),
message: Message::TreeNodeToggled(TreeNodeToggled {
index: 1,
label: "nested".to_string(),
expanded: true,
}),
control: None,
},
&mut expand_ctx,
);
assert!(expand_ctx.handled());
let mut collapse_ctx = EventCtx::default();
tree.on_message(
&MessageEvent {
sender: tree.tree_id(),
message: Message::TreeNodeToggled(TreeNodeToggled {
index: 1,
label: "nested".to_string(),
expanded: false,
}),
control: None,
},
&mut collapse_ctx,
);
assert!(collapse_ctx.handled());
tree.on_message(
&MessageEvent {
sender: NodeId::default(),
message: Message::AsyncTaskCompleted(AsyncTaskCompleted {
task_id: 1,
target: NodeId::default(),
result: AsyncTaskResult::DirectoryEntries {
path: nested_dir.display().to_string(),
entries: vec![AsyncDirectoryEntry {
path: nested_dir.join("leaf.txt").display().to_string(),
label: "leaf.txt".to_string(),
is_dir: false,
}],
},
}),
control: None,
},
&mut EventCtx::default(),
);
let console = Console::new();
let options = options_for(&console, 60, 8);
let buf = FrameBuffer::from_renderable(&console, &options, &tree, None);
let lines = buf.as_plain_lines();
assert!(!lines.iter().any(|line| line.contains("leaf.txt")));
}
#[test]
fn directory_tree_handles_forwarded_selection_messages() {
let temp = TempTreeDir::new("directory-tree-message");
fs::write(temp.path.join("alpha.txt"), "alpha").expect("write file");
let mut tree = DirectoryTree::new(&temp.path);
tree.on_layout(40, 4);
let mut message_ctx = EventCtx::default();
tree.on_message(
&MessageEvent {
sender: tree.tree_id(),
message: Message::TreeNodeSelected(TreeNodeSelected {
index: 1,
label: "alpha.txt".to_string(),
data: None,
}),
control: None,
},
&mut message_ctx,
);
assert!(message_ctx.handled());
}
#[test]
fn directory_tree_emits_directory_selected_message_for_directory_nodes() {
let temp = TempTreeDir::new("directory-tree-directory-message");
fs::create_dir_all(temp.path.join("nested")).expect("create nested dir");
let mut tree = DirectoryTree::new(&temp.path);
tree.on_layout(40, 4);
let mut message_ctx = EventCtx::default();
tree.on_message(
&MessageEvent {
sender: tree.tree_id(),
message: Message::TreeNodeSelected(TreeNodeSelected {
index: 1,
label: "nested".to_string(),
data: None,
}),
control: None,
},
&mut message_ctx,
);
assert!(message_ctx.handled());
}
#[test]
fn directory_tree_keyboard_navigation_is_forwarded_to_inner_tree() {
let temp = TempTreeDir::new("directory-tree-key");
fs::write(temp.path.join("alpha.txt"), "alpha").expect("write file");
fs::write(temp.path.join("beta.txt"), "beta").expect("write file");
let mut tree = DirectoryTree::new(&temp.path);
tree.set_focus(true);
tree.on_layout(40, 4);
let down = KeyEventData::from_crossterm(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
let mut ctx = EventCtx::default();
tree.on_event(&Event::Key(down), &mut ctx);
assert!(ctx.handled());
}
#[test]
fn directory_tree_style_type_is_directory_tree() {
let temp = TempTreeDir::new("directory-tree-style");
let tree = DirectoryTree::new(&temp.path);
assert_eq!(tree.style_type(), "DirectoryTree");
}
#[test]
fn directory_tree_hover_state_is_forwarded() {
let temp = TempTreeDir::new("directory-tree-hover");
let mut tree = DirectoryTree::new(&temp.path);
tree.set_hovered(true);
assert!(tree.is_hovered());
tree.set_hovered(false);
assert!(!tree.is_hovered());
}
#[test]
fn directory_tree_unmount_clears_focus_hover_and_pending_loads() {
let temp = TempTreeDir::new("directory-tree-unmount");
let nested_dir = temp.path.join("nested");
fs::create_dir_all(&nested_dir).expect("create nested dir");
fs::write(nested_dir.join("leaf.txt"), "leaf").expect("write nested file");
let mut tree = DirectoryTree::new(&temp.path);
tree.on_layout(60, 8);
tree.set_focus(true);
tree.set_hovered(true);
let mut expand_ctx = EventCtx::default();
tree.on_message(
&MessageEvent {
sender: tree.tree_id(),
message: Message::TreeNodeToggled(TreeNodeToggled {
index: 1,
label: "nested".to_string(),
expanded: true,
}),
control: None,
},
&mut expand_ctx,
);
assert!(expand_ctx.handled());
tree.on_unmount();
tree.on_message(
&MessageEvent {
sender: NodeId::default(),
message: Message::AsyncTaskCompleted(AsyncTaskCompleted {
task_id: 1,
target: NodeId::default(),
result: AsyncTaskResult::DirectoryEntries {
path: nested_dir.display().to_string(),
entries: vec![AsyncDirectoryEntry {
path: nested_dir.join("leaf.txt").display().to_string(),
label: "leaf.txt".to_string(),
is_dir: false,
}],
},
}),
control: None,
},
&mut EventCtx::default(),
);
assert!(!tree.has_focus());
assert!(!tree.is_hovered());
let console = Console::new();
let options = options_for(&console, 60, 8);
let buf = FrameBuffer::from_renderable(&console, &options, &tree, None);
let lines = buf.as_plain_lines();
assert!(!lines.iter().any(|line| line.contains("leaf.txt")));
}