use std::{
collections::HashSet,
fs,
path::{Path, PathBuf},
};
use crossterm::event::{KeyCode, KeyEvent};
use crate::types::{ExplorerOutcome, FsEntry, SortMode};
#[derive(Debug)]
pub struct FileExplorer {
pub current_dir: PathBuf,
pub entries: Vec<FsEntry>,
pub cursor: usize,
pub(crate) scroll_offset: usize,
pub extension_filter: Vec<String>,
pub show_hidden: bool,
pub(crate) status: String,
pub sort_mode: SortMode,
pub search_query: String,
pub search_active: bool,
pub marked: HashSet<PathBuf>,
pub mkdir_active: bool,
pub mkdir_input: String,
pub touch_active: bool,
pub touch_input: String,
}
impl FileExplorer {
pub fn new(initial_dir: PathBuf, extension_filter: Vec<String>) -> Self {
let mut explorer = Self {
current_dir: initial_dir,
entries: Vec::new(),
cursor: 0,
scroll_offset: 0,
extension_filter,
show_hidden: false,
status: String::new(),
sort_mode: SortMode::default(),
search_query: String::new(),
search_active: false,
marked: HashSet::new(),
mkdir_active: false,
mkdir_input: String::new(),
touch_active: false,
touch_input: String::new(),
};
explorer.reload();
explorer
}
pub fn builder(initial_dir: PathBuf) -> FileExplorerBuilder {
FileExplorerBuilder::new(initial_dir)
}
pub fn navigate_to(&mut self, path: impl Into<PathBuf>) {
self.current_dir = path.into();
self.cursor = 0;
self.scroll_offset = 0;
self.reload();
}
pub fn marked_paths(&self) -> &HashSet<PathBuf> {
&self.marked
}
pub fn toggle_mark(&mut self) {
if let Some(entry) = self.entries.get(self.cursor) {
let path = entry.path.clone();
if self.marked.contains(&path) {
self.marked.remove(&path);
} else {
self.marked.insert(path);
}
}
self.move_down();
}
pub fn clear_marks(&mut self) {
self.marked.clear();
}
pub fn handle_key(&mut self, key: KeyEvent) -> ExplorerOutcome {
if self.touch_active {
match key.code {
KeyCode::Char(c)
if key.modifiers.is_empty()
|| key.modifiers == crossterm::event::KeyModifiers::SHIFT =>
{
self.touch_input.push(c);
return ExplorerOutcome::Pending;
}
KeyCode::Backspace => {
self.touch_input.pop();
return ExplorerOutcome::Pending;
}
KeyCode::Enter => {
let name = self.touch_input.trim().to_string();
self.touch_active = false;
self.touch_input.clear();
if name.is_empty() {
return ExplorerOutcome::Pending;
}
let new_file = self.current_dir.join(&name);
let create_result = (|| -> std::io::Result<()> {
if let Some(parent) = new_file.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(false)
.open(&new_file)?;
Ok(())
})();
match create_result {
Ok(()) => {
self.reload();
if let Some(idx) = self.entries.iter().position(|e| e.path == new_file)
{
self.cursor = idx;
}
return ExplorerOutcome::TouchCreated(new_file);
}
Err(e) => {
self.status = format!("touch failed: {e}");
return ExplorerOutcome::Pending;
}
}
}
KeyCode::Esc => {
self.touch_active = false;
self.touch_input.clear();
return ExplorerOutcome::Pending;
}
_ => return ExplorerOutcome::Pending,
}
}
if self.mkdir_active {
match key.code {
KeyCode::Char(c)
if key.modifiers.is_empty()
|| key.modifiers == crossterm::event::KeyModifiers::SHIFT =>
{
self.mkdir_input.push(c);
return ExplorerOutcome::Pending;
}
KeyCode::Backspace => {
self.mkdir_input.pop();
return ExplorerOutcome::Pending;
}
KeyCode::Enter => {
let name = self.mkdir_input.trim().to_string();
self.mkdir_active = false;
self.mkdir_input.clear();
if name.is_empty() {
return ExplorerOutcome::Pending;
}
let new_dir = self.current_dir.join(&name);
match std::fs::create_dir_all(&new_dir) {
Ok(()) => {
self.reload();
if let Some(idx) = self.entries.iter().position(|e| e.path == new_dir) {
self.cursor = idx;
}
return ExplorerOutcome::MkdirCreated(new_dir);
}
Err(e) => {
self.status = format!("mkdir failed: {e}");
return ExplorerOutcome::Pending;
}
}
}
KeyCode::Esc => {
self.mkdir_active = false;
self.mkdir_input.clear();
return ExplorerOutcome::Pending;
}
_ => return ExplorerOutcome::Pending,
}
}
if self.search_active {
match key.code {
KeyCode::Char(c) if key.modifiers.is_empty() => {
self.search_query.push(c);
self.cursor = 0;
self.scroll_offset = 0;
self.reload();
return ExplorerOutcome::Pending;
}
KeyCode::Backspace => {
if self.search_query.is_empty() {
self.search_active = false;
} else {
self.search_query.pop();
self.cursor = 0;
self.scroll_offset = 0;
self.reload();
}
return ExplorerOutcome::Pending;
}
KeyCode::Esc => {
self.search_active = false;
self.search_query.clear();
self.cursor = 0;
self.scroll_offset = 0;
self.reload();
return ExplorerOutcome::Pending;
}
_ => {} }
}
match key.code {
KeyCode::Esc => ExplorerOutcome::Dismissed,
KeyCode::Char('q') if key.modifiers.is_empty() => ExplorerOutcome::Dismissed,
KeyCode::Up | KeyCode::Char('k') => {
self.move_up();
ExplorerOutcome::Pending
}
KeyCode::Down | KeyCode::Char('j') => {
self.move_down();
ExplorerOutcome::Pending
}
KeyCode::PageUp => {
for _ in 0..10 {
self.move_up();
}
ExplorerOutcome::Pending
}
KeyCode::PageDown => {
for _ in 0..10 {
self.move_down();
}
ExplorerOutcome::Pending
}
KeyCode::Home | KeyCode::Char('g') => {
self.cursor = 0;
self.scroll_offset = 0;
ExplorerOutcome::Pending
}
KeyCode::End | KeyCode::Char('G') => {
if !self.entries.is_empty() {
self.cursor = self.entries.len() - 1;
}
ExplorerOutcome::Pending
}
KeyCode::Left | KeyCode::Backspace | KeyCode::Char('h') => {
self.ascend();
ExplorerOutcome::Pending
}
KeyCode::Right => self.navigate(),
KeyCode::Enter | KeyCode::Char('l') => self.confirm(),
KeyCode::Char('.') => {
self.show_hidden = !self.show_hidden;
let was = self.cursor;
self.reload();
self.cursor = was.min(self.entries.len().saturating_sub(1));
ExplorerOutcome::Pending
}
KeyCode::Char('/') if key.modifiers.is_empty() => {
self.search_active = true;
ExplorerOutcome::Pending
}
KeyCode::Char('s') if key.modifiers.is_empty() => {
self.sort_mode = self.sort_mode.next();
let was = self.cursor;
self.reload();
self.cursor = was.min(self.entries.len().saturating_sub(1));
ExplorerOutcome::Pending
}
KeyCode::Char(' ') => {
self.toggle_mark();
ExplorerOutcome::Pending
}
KeyCode::Char('n') if key.modifiers.is_empty() => {
self.mkdir_active = true;
self.mkdir_input.clear();
ExplorerOutcome::Pending
}
KeyCode::Char('N') if key.modifiers.is_empty() => {
self.touch_active = true;
self.touch_input.clear();
ExplorerOutcome::Pending
}
_ => ExplorerOutcome::Unhandled,
}
}
pub fn current_entry(&self) -> Option<&FsEntry> {
self.entries.get(self.cursor)
}
pub fn is_mkdir_active(&self) -> bool {
self.mkdir_active
}
pub fn mkdir_input(&self) -> &str {
&self.mkdir_input
}
pub fn is_touch_active(&self) -> bool {
self.touch_active
}
pub fn touch_input(&self) -> &str {
&self.touch_input
}
pub fn is_at_root(&self) -> bool {
self.current_dir.parent().is_none()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn entry_count(&self) -> usize {
self.entries.len()
}
pub fn status(&self) -> &str {
&self.status
}
pub fn sort_mode(&self) -> SortMode {
self.sort_mode
}
pub fn search_query(&self) -> &str {
&self.search_query
}
pub fn is_searching(&self) -> bool {
self.search_active
}
pub fn set_show_hidden(&mut self, show: bool) {
self.show_hidden = show;
self.reload();
}
pub fn set_extension_filter<I, S>(&mut self, filter: I)
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.extension_filter = filter.into_iter().map(Into::into).collect();
self.reload();
}
pub fn set_sort_mode(&mut self, mode: SortMode) {
self.sort_mode = mode;
self.reload();
}
fn move_up(&mut self) {
if self.cursor > 0 {
self.cursor -= 1;
}
self.clamp_cursor();
}
fn move_down(&mut self) {
let last = self.entries.len().saturating_sub(1);
if !self.entries.is_empty() && self.cursor < last {
self.cursor += 1;
}
self.clamp_cursor();
}
fn clamp_cursor(&mut self) {
let max = self.entries.len().saturating_sub(1);
if self.cursor > max {
self.cursor = max;
}
if self.scroll_offset > self.cursor {
self.scroll_offset = self.cursor;
}
}
fn ascend(&mut self) {
if let Some(parent) = self.current_dir.parent().map(|p| p.to_path_buf()) {
let prev = self.current_dir.clone();
self.current_dir = parent;
self.cursor = 0;
self.scroll_offset = 0;
self.search_active = false;
self.search_query.clear();
self.marked.clear();
self.reload();
if let Some(idx) = self.entries.iter().position(|e| e.path == prev) {
self.cursor = idx;
}
self.clamp_cursor();
} else {
self.status = "Already at the filesystem root.".to_string();
}
}
fn navigate(&mut self) -> ExplorerOutcome {
let Some(entry) = self.entries.get(self.cursor) else {
return ExplorerOutcome::Pending;
};
if entry.is_dir {
let path = entry.path.clone();
self.search_active = false;
self.search_query.clear();
self.marked.clear();
self.navigate_to(path);
} else {
self.move_down();
}
ExplorerOutcome::Pending
}
fn confirm(&mut self) -> ExplorerOutcome {
let Some(entry) = self.entries.get(self.cursor) else {
return ExplorerOutcome::Pending;
};
if entry.is_dir {
let path = entry.path.clone();
self.search_active = false;
self.search_query.clear();
self.marked.clear();
self.navigate_to(path);
ExplorerOutcome::Pending
} else {
ExplorerOutcome::Selected(entry.path.clone())
}
}
pub fn reload(&mut self) {
self.status.clear();
self.entries = load_entries(
&self.current_dir,
self.show_hidden,
&self.extension_filter,
self.sort_mode,
&self.search_query,
);
self.clamp_cursor();
}
}
pub struct FileExplorerBuilder {
initial_dir: PathBuf,
extension_filter: Vec<String>,
show_hidden: bool,
sort_mode: SortMode,
}
impl FileExplorerBuilder {
pub fn new(initial_dir: PathBuf) -> Self {
Self {
initial_dir,
extension_filter: Vec::new(),
show_hidden: false,
sort_mode: SortMode::default(),
}
}
pub fn extension_filter(mut self, filter: Vec<String>) -> Self {
self.extension_filter = filter;
self
}
pub fn allow_extension(mut self, ext: impl Into<String>) -> Self {
self.extension_filter.push(ext.into());
self
}
pub fn show_hidden(mut self, show: bool) -> Self {
self.show_hidden = show;
self
}
pub fn sort_mode(mut self, mode: SortMode) -> Self {
self.sort_mode = mode;
self
}
pub fn build(self) -> FileExplorer {
let mut explorer = FileExplorer {
current_dir: self.initial_dir,
entries: Vec::new(),
cursor: 0,
scroll_offset: 0,
extension_filter: self.extension_filter,
show_hidden: self.show_hidden,
status: String::new(),
sort_mode: self.sort_mode,
search_query: String::new(),
search_active: false,
marked: HashSet::new(),
mkdir_active: false,
mkdir_input: String::new(),
touch_active: false,
touch_input: String::new(),
};
explorer.reload();
explorer
}
}
pub(crate) fn load_entries(
dir: &Path,
show_hidden: bool,
ext_filter: &[String],
sort_mode: SortMode,
search_query: &str,
) -> Vec<FsEntry> {
let read = match fs::read_dir(dir) {
Ok(r) => r,
Err(_) => return Vec::new(),
};
let mut dirs: Vec<FsEntry> = Vec::new();
let mut files: Vec<FsEntry> = Vec::new();
for entry in read.flatten() {
let path = entry.path();
let name = entry.file_name().to_string_lossy().to_string();
if !show_hidden && name.starts_with('.') {
continue;
}
let is_dir = path.is_dir();
let extension = if is_dir {
String::new()
} else {
path.extension()
.map(|e| e.to_string_lossy().to_lowercase())
.unwrap_or_default()
};
if !is_dir && !ext_filter.is_empty() {
let matches = ext_filter
.iter()
.any(|f| f.eq_ignore_ascii_case(&extension));
if !matches {
continue;
}
}
if !search_query.is_empty() {
let q = search_query.to_lowercase();
if !name.to_lowercase().contains(&q) {
continue;
}
}
let size = if is_dir {
None
} else {
entry.metadata().ok().map(|m| m.len())
};
let fs_entry = FsEntry {
name,
path,
is_dir,
size,
extension,
};
if is_dir {
dirs.push(fs_entry);
} else {
files.push(fs_entry);
}
}
dirs.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
match sort_mode {
SortMode::Name => {
files.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
}
SortMode::SizeDesc => {
files.sort_by(|a, b| b.size.unwrap_or(0).cmp(&a.size.unwrap_or(0)));
}
SortMode::Extension => {
files.sort_by(|a, b| {
a.extension
.cmp(&b.extension)
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
});
}
}
dirs.extend(files);
dirs
}
pub fn entry_icon(entry: &FsEntry) -> &'static str {
if entry.is_dir {
return "📁";
}
match entry.extension.as_str() {
"iso" | "dmg" => "💿",
"img" => "🖼 ",
"zip" | "gz" | "xz" | "zst" | "bz2" | "tar" | "7z" | "rar" | "tgz" | "tbz2" => "📦",
"pdf" => "📕",
"txt" | "log" | "rst" => "📄",
"md" | "mdx" | "markdown" => "📝",
"toml" | "yaml" | "yml" | "json" | "xml" | "ini" | "cfg" | "conf" | "env" => "⚙ ",
"lock" => "🔒",
"rs" => "🦀",
"py" | "pyw" => "🐍",
"js" | "mjs" | "cjs" => "📜",
"ts" | "mts" | "cts" => "📜",
"jsx" | "tsx" => "📜",
"go" => "📜",
"c" | "h" => "📜",
"cpp" | "cc" | "cxx" | "hpp" | "hxx" => "📜",
"java" | "kt" | "kts" => "📜",
"rb" | "erb" => "📜",
"php" => "📜",
"swift" => "📜",
"cs" => "📜",
"lua" => "📜",
"zig" => "📜",
"ex" | "exs" => "📜",
"hs" | "lhs" => "📜",
"ml" | "mli" => "📜",
"sh" | "bash" | "zsh" | "fish" | "nu" => "📜",
"bat" | "cmd" | "ps1" => "📜",
"html" | "htm" | "xhtml" => "🌐",
"css" | "scss" | "sass" | "less" => "🎨",
"svg" => "🎨",
"png" | "jpg" | "jpeg" | "gif" | "bmp" | "webp" | "ico" | "tiff" | "tif" | "avif"
| "heic" | "heif" => "🖼 ",
"mp4" | "mkv" | "avi" | "mov" | "webm" | "flv" | "wmv" | "m4v" => "🎬",
"mp3" | "wav" | "flac" | "ogg" | "aac" | "m4a" | "opus" | "wma" => "🎵",
"ttf" | "otf" | "woff" | "woff2" | "eot" => "🔤",
"exe" | "msi" | "deb" | "rpm" | "appimage" | "apk" => "⚙ ",
_ => "📄",
}
}
pub fn fmt_size(bytes: u64) -> String {
const KB: u64 = 1_024;
const MB: u64 = 1_024 * KB;
const GB: u64 = 1_024 * MB;
if bytes >= GB {
format!("{:.1} GB", bytes as f64 / GB as f64)
} else if bytes >= MB {
format!("{:.1} MB", bytes as f64 / MB as f64)
} else if bytes >= KB {
format!("{:.1} KB", bytes as f64 / KB as f64)
} else {
format!("{bytes} B")
}
}
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::{KeyEvent, KeyModifiers};
use std::fs;
use tempfile::{tempdir, TempDir};
fn temp_dir_with_files() -> TempDir {
let dir = tempfile::tempdir().expect("temp dir");
fs::write(dir.path().join("ubuntu.iso"), b"fake iso content").unwrap();
fs::write(dir.path().join("debian.img"), b"fake img content").unwrap();
fs::write(dir.path().join("readme.txt"), b"some text").unwrap();
fs::create_dir(dir.path().join("subdir")).unwrap();
dir
}
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
#[test]
fn new_loads_entries() {
let tmp = temp_dir_with_files();
let explorer =
FileExplorer::new(tmp.path().to_path_buf(), vec!["iso".into(), "img".into()]);
assert!(explorer
.entries
.iter()
.any(|e| e.name == "subdir" && e.is_dir));
assert!(explorer.entries.iter().any(|e| e.name == "ubuntu.iso"));
assert!(explorer.entries.iter().any(|e| e.name == "debian.img"));
assert!(!explorer.entries.iter().any(|e| e.name == "readme.txt"));
}
#[test]
fn no_filter_shows_all_files() {
let tmp = temp_dir_with_files();
let explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
assert!(explorer.entries.iter().any(|e| e.name == "readme.txt"));
}
#[test]
fn dirs_listed_before_files() {
let tmp = temp_dir_with_files();
let explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
let first_file_idx = explorer
.entries
.iter()
.position(|e| !e.is_dir)
.unwrap_or(usize::MAX);
let last_dir_idx = explorer.entries.iter().rposition(|e| e.is_dir).unwrap_or(0);
assert!(
last_dir_idx < first_file_idx,
"all dirs must appear before any file"
);
}
#[test]
fn move_down_increments_cursor() {
let tmp = temp_dir_with_files();
let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
explorer.move_down();
assert_eq!(explorer.cursor, 1);
}
#[test]
fn move_up_clamps_at_zero() {
let tmp = temp_dir_with_files();
let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
explorer.move_up();
assert_eq!(explorer.cursor, 0);
}
#[test]
fn move_down_clamps_at_last() {
let tmp = temp_dir_with_files();
let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
let last = explorer.entries.len() - 1;
explorer.cursor = last;
explorer.move_down();
assert_eq!(explorer.cursor, last);
}
#[test]
fn handle_key_down_moves_cursor() {
let tmp = temp_dir_with_files();
let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
let before = explorer.cursor;
explorer.handle_key(key(KeyCode::Down));
assert_eq!(explorer.cursor, before + 1);
}
#[test]
fn handle_key_esc_dismisses() {
let tmp = temp_dir_with_files();
let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
assert_eq!(
explorer.handle_key(key(KeyCode::Esc)),
ExplorerOutcome::Dismissed
);
}
#[test]
fn handle_key_enter_on_dir_descends() {
let tmp = temp_dir_with_files();
let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
let dir_idx = explorer
.entries
.iter()
.position(|e| e.is_dir)
.expect("no dir in fixture");
explorer.cursor = dir_idx;
let expected_path = explorer.entries[dir_idx].path.clone();
let outcome = explorer.handle_key(key(KeyCode::Enter));
assert_eq!(outcome, ExplorerOutcome::Pending);
assert_eq!(explorer.current_dir, expected_path);
}
#[test]
fn handle_key_enter_on_valid_file_selects() {
let tmp = temp_dir_with_files();
let mut explorer =
FileExplorer::new(tmp.path().to_path_buf(), vec!["iso".into(), "img".into()]);
let file_idx = explorer
.entries
.iter()
.position(|e| !e.is_dir)
.expect("no file in fixture");
explorer.cursor = file_idx;
let expected = explorer.entries[file_idx].path.clone();
let outcome = explorer.handle_key(key(KeyCode::Enter));
assert_eq!(outcome, ExplorerOutcome::Selected(expected));
}
#[test]
fn handle_key_backspace_ascends() {
let tmp = temp_dir_with_files();
let subdir = tmp.path().join("subdir");
let mut explorer = FileExplorer::new(subdir, vec![]);
explorer.handle_key(key(KeyCode::Backspace));
assert_eq!(explorer.current_dir, tmp.path());
}
#[test]
fn toggle_hidden_changes_visibility() {
let tmp = temp_dir_with_files();
fs::write(tmp.path().join(".hidden_file"), b"").unwrap();
let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
assert!(!explorer.entries.iter().any(|e| e.name == ".hidden_file"));
explorer.set_show_hidden(true);
assert!(explorer.entries.iter().any(|e| e.name == ".hidden_file"));
}
#[test]
fn fmt_size_formats_bytes() {
assert_eq!(fmt_size(512), "512 B");
assert_eq!(fmt_size(1_536), "1.5 KB");
assert_eq!(fmt_size(2_097_152), "2.0 MB");
assert_eq!(fmt_size(1_073_741_824), "1.0 GB");
}
#[test]
fn extension_filter_only_shows_matching_files() {
let tmp = temp_dir_with_files();
let explorer = FileExplorer::new(tmp.path().to_path_buf(), vec!["iso".into()]);
assert!(
explorer.entries.iter().any(|e| e.name == "ubuntu.iso"),
"iso file should appear in entries"
);
assert!(
!explorer.entries.iter().any(|e| e.name == "debian.img"),
"img file should be excluded by filter"
);
assert!(
explorer.entries.iter().any(|e| e.is_dir),
"directories should always be visible"
);
assert!(
explorer
.entries
.iter()
.filter(|e| !e.is_dir)
.all(|e| e.extension == "iso"),
"all visible files must match the active filter"
);
}
#[test]
fn navigate_to_resets_cursor_and_scroll() {
let tmp = temp_dir_with_files();
let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
explorer.cursor = 2;
explorer.scroll_offset = 1;
explorer.navigate_to(tmp.path().to_path_buf());
assert_eq!(explorer.cursor, 0);
assert_eq!(explorer.scroll_offset, 0);
}
#[test]
fn current_entry_returns_highlighted() {
let tmp = temp_dir_with_files();
let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
explorer.cursor = 0;
let entry = explorer.current_entry().expect("should have entry");
assert_eq!(entry, explorer.entries.first().unwrap());
}
#[test]
fn unrecognised_key_returns_unhandled() {
let tmp = temp_dir_with_files();
let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
assert_eq!(
explorer.handle_key(key(KeyCode::F(5))),
ExplorerOutcome::Unhandled
);
}
#[test]
fn slash_activates_search_mode() {
let tmp = temp_dir_with_files();
let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
assert!(!explorer.search_active);
explorer.handle_key(key(KeyCode::Char('/')));
assert!(explorer.search_active);
assert_eq!(explorer.search_query(), "");
}
#[test]
fn search_active_chars_append_to_query() {
let tmp = temp_dir_with_files();
let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
explorer.handle_key(key(KeyCode::Char('/')));
explorer.handle_key(key(KeyCode::Char('u')));
explorer.handle_key(key(KeyCode::Char('b')));
explorer.handle_key(key(KeyCode::Char('u')));
assert_eq!(explorer.search_query(), "ubu");
assert!(explorer.search_active);
}
#[test]
fn search_filters_entries_by_name() {
let tmp = temp_dir_with_files();
let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
explorer.handle_key(key(KeyCode::Char('/')));
for c in "ubu".chars() {
explorer.handle_key(key(KeyCode::Char(c)));
}
assert_eq!(explorer.entries.len(), 1);
assert_eq!(explorer.entries[0].name, "ubuntu.iso");
}
#[test]
fn search_backspace_pops_last_char() {
let tmp = temp_dir_with_files();
let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
explorer.handle_key(key(KeyCode::Char('/')));
explorer.handle_key(key(KeyCode::Char('u')));
explorer.handle_key(key(KeyCode::Char('b')));
explorer.handle_key(key(KeyCode::Backspace));
assert_eq!(explorer.search_query(), "u");
assert!(explorer.search_active);
}
#[test]
fn search_backspace_on_empty_deactivates() {
let tmp = temp_dir_with_files();
let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
explorer.handle_key(key(KeyCode::Char('/')));
assert!(explorer.search_active);
explorer.handle_key(key(KeyCode::Backspace));
assert!(!explorer.search_active);
assert_eq!(explorer.search_query(), "");
}
#[test]
fn search_esc_clears_and_deactivates_returns_pending() {
let tmp = temp_dir_with_files();
let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
explorer.handle_key(key(KeyCode::Char('/')));
explorer.handle_key(key(KeyCode::Char('u')));
let outcome = explorer.handle_key(key(KeyCode::Esc));
assert_eq!(
outcome,
ExplorerOutcome::Pending,
"Esc should clear search, not dismiss"
);
assert!(!explorer.search_active);
assert_eq!(explorer.search_query(), "");
}
#[test]
fn esc_when_not_searching_dismisses() {
let tmp = temp_dir_with_files();
let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
assert!(!explorer.search_active);
assert_eq!(
explorer.handle_key(key(KeyCode::Esc)),
ExplorerOutcome::Dismissed
);
}
#[test]
fn search_clears_on_directory_descend() {
let tmp = temp_dir_with_files();
let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
explorer.search_active = true;
explorer.search_query = "sub".into();
explorer.cursor = explorer.entries.iter().position(|e| e.is_dir).unwrap();
explorer.handle_key(key(KeyCode::Enter));
assert!(!explorer.search_active);
assert_eq!(explorer.search_query(), "");
}
#[test]
fn search_clears_on_ascend() {
let tmp = temp_dir_with_files();
let subdir = tmp.path().join("subdir");
let mut explorer = FileExplorer::new(subdir, vec![]);
explorer.search_active = true;
explorer.search_query = "foo".into();
explorer.ascend();
assert!(
!explorer.search_active,
"search must be deactivated after ascend"
);
assert_eq!(
explorer.search_query(),
"",
"query must be cleared after ascend"
);
assert_eq!(
explorer.current_dir,
tmp.path(),
"must have ascended to parent"
);
}
#[test]
fn backspace_in_search_pops_char_not_ascend() {
let tmp = temp_dir_with_files();
let subdir = tmp.path().join("subdir");
let mut explorer = FileExplorer::new(subdir.clone(), vec![]);
explorer.search_active = true;
explorer.search_query = "foo".into();
explorer.handle_key(key(KeyCode::Backspace));
assert_eq!(explorer.current_dir, subdir, "must NOT have ascended");
assert_eq!(
explorer.search_query(),
"fo",
"Backspace should pop last char"
);
assert!(explorer.search_active, "search must still be active");
}
#[test]
fn default_sort_mode_is_name() {
let tmp = temp_dir_with_files();
let explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
assert_eq!(explorer.sort_mode(), SortMode::Name);
}
#[test]
fn sort_mode_cycles_on_s_key() {
let tmp = temp_dir_with_files();
let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
assert_eq!(explorer.sort_mode(), SortMode::Name);
explorer.handle_key(key(KeyCode::Char('s')));
assert_eq!(explorer.sort_mode(), SortMode::SizeDesc);
explorer.handle_key(key(KeyCode::Char('s')));
assert_eq!(explorer.sort_mode(), SortMode::Extension);
explorer.handle_key(key(KeyCode::Char('s')));
assert_eq!(explorer.sort_mode(), SortMode::Name);
}
#[test]
fn sort_size_desc_orders_largest_first() {
let tmp = tempfile::tempdir().expect("temp dir");
fs::write(tmp.path().join("small.txt"), vec![0u8; 10]).unwrap();
fs::write(tmp.path().join("large.txt"), vec![0u8; 10_000]).unwrap();
fs::write(tmp.path().join("medium.txt"), vec![0u8; 1_000]).unwrap();
let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
explorer.set_sort_mode(SortMode::SizeDesc);
let sizes: Vec<u64> = explorer.entries.iter().filter_map(|e| e.size).collect();
let mut sorted_desc = sizes.clone();
sorted_desc.sort_by(|a, b| b.cmp(a));
assert_eq!(sizes, sorted_desc, "files should be sorted largest-first");
}
#[test]
fn sort_extension_groups_by_ext() {
let tmp = tempfile::tempdir().expect("temp dir");
fs::write(tmp.path().join("b.toml"), b"").unwrap();
fs::write(tmp.path().join("a.rs"), b"").unwrap();
fs::write(tmp.path().join("c.toml"), b"").unwrap();
fs::write(tmp.path().join("z.rs"), b"").unwrap();
let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
explorer.set_sort_mode(SortMode::Extension);
let exts: Vec<&str> = explorer
.entries
.iter()
.filter(|e| !e.is_dir)
.map(|e| e.extension.as_str())
.collect();
let rs_last = exts.iter().rposition(|&e| e == "rs").unwrap_or(0);
let toml_first = exts.iter().position(|&e| e == "toml").unwrap_or(usize::MAX);
assert!(rs_last < toml_first, "rs group must precede toml group");
}
#[test]
fn builder_sort_mode_applied() {
let tmp = temp_dir_with_files();
let explorer = FileExplorer::builder(tmp.path().to_path_buf())
.sort_mode(SortMode::SizeDesc)
.build();
assert_eq!(explorer.sort_mode(), SortMode::SizeDesc);
}
#[test]
fn set_sort_mode_reloads() {
let tmp = temp_dir_with_files();
let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
explorer.set_sort_mode(SortMode::Extension);
assert_eq!(explorer.sort_mode(), SortMode::Extension);
assert!(!explorer.entries.is_empty());
}
#[test]
fn j_key_moves_cursor_down() {
let tmp = temp_dir_with_files();
let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
let before = explorer.cursor;
explorer.handle_key(key(KeyCode::Char('j')));
assert_eq!(explorer.cursor, before + 1);
}
#[test]
fn k_key_moves_cursor_up() {
let tmp = temp_dir_with_files();
let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
explorer.cursor = 2;
explorer.handle_key(key(KeyCode::Char('k')));
assert_eq!(explorer.cursor, 1);
}
#[test]
fn h_key_ascends_to_parent() {
let tmp = temp_dir_with_files();
let subdir = tmp.path().join("subdir");
let mut explorer = FileExplorer::new(subdir, vec![]);
explorer.handle_key(key(KeyCode::Char('h')));
assert_eq!(explorer.current_dir, tmp.path());
}
#[test]
fn l_key_descends_into_dir() {
let tmp = temp_dir_with_files();
let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
let dir_idx = explorer.entries.iter().position(|e| e.is_dir).unwrap();
explorer.cursor = dir_idx;
let expected = explorer.entries[dir_idx].path.clone();
let outcome = explorer.handle_key(key(KeyCode::Char('l')));
assert_eq!(outcome, ExplorerOutcome::Pending);
assert_eq!(explorer.current_dir, expected);
}
#[test]
fn right_arrow_descends_into_dir() {
let tmp = temp_dir_with_files();
let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
let dir_idx = explorer.entries.iter().position(|e| e.is_dir).unwrap();
explorer.cursor = dir_idx;
let expected = explorer.entries[dir_idx].path.clone();
let outcome = explorer.handle_key(key(KeyCode::Right));
assert_eq!(
outcome,
ExplorerOutcome::Pending,
"Right arrow should descend into directory"
);
assert_eq!(
explorer.current_dir, expected,
"Right arrow should change into the selected directory"
);
}
#[test]
fn right_arrow_on_file_moves_down_not_exits() {
let tmp = temp_dir_with_files();
let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
let file_idx = explorer.entries.iter().position(|e| !e.is_dir).unwrap();
assert!(
file_idx + 1 < explorer.entries.len(),
"fixture must have an entry after the first file"
);
explorer.cursor = file_idx;
let original_dir = explorer.current_dir.clone();
let outcome = explorer.handle_key(key(KeyCode::Right));
assert_eq!(
outcome,
ExplorerOutcome::Pending,
"Right arrow on a file must never exit (always Pending)"
);
assert_eq!(
explorer.current_dir, original_dir,
"Right arrow on a file must not change directory"
);
assert_eq!(
explorer.cursor,
file_idx + 1,
"Right arrow on a file must advance the cursor by one"
);
}
#[test]
fn right_arrow_on_file_at_last_entry_does_not_overflow() {
let tmp = temp_dir_with_files();
let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
let last = explorer.entries.len() - 1;
explorer.cursor = last;
explorer.handle_key(key(KeyCode::Right));
assert_eq!(
explorer.cursor, last,
"Right arrow at the last entry must not overflow past it"
);
}
#[test]
fn enter_on_file_still_confirms_and_exits() {
let tmp = temp_dir_with_files();
let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
let file_idx = explorer.entries.iter().position(|e| !e.is_dir).unwrap();
explorer.cursor = file_idx;
let expected = explorer.entries[file_idx].path.clone();
let outcome = explorer.handle_key(key(KeyCode::Enter));
assert_eq!(
outcome,
ExplorerOutcome::Selected(expected),
"Enter on a file should confirm (select) it and exit"
);
}
#[test]
fn left_arrow_ascends_to_parent() {
let tmp = temp_dir_with_files();
let subdir = tmp.path().join("subdir");
let mut explorer = FileExplorer::new(subdir, vec![]);
let outcome = explorer.handle_key(key(KeyCode::Left));
assert_eq!(
outcome,
ExplorerOutcome::Pending,
"Left arrow should return Pending after ascending"
);
assert_eq!(
explorer.current_dir,
tmp.path(),
"Left arrow should ascend to the parent directory"
);
}
#[test]
fn right_arrow_clears_search_on_dir_descend() {
let tmp = temp_dir_with_files();
let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
explorer.search_active = true;
explorer.search_query = "sub".to_string();
explorer.reload();
let dir_idx = explorer
.entries
.iter()
.position(|e| e.is_dir)
.expect("fixture subdir must match 'sub'");
explorer.cursor = dir_idx;
explorer.handle_key(key(KeyCode::Right));
assert!(
!explorer.search_active,
"navigate() must deactivate search on directory descend"
);
assert!(
explorer.search_query.is_empty(),
"navigate() must clear search query on directory descend"
);
}
#[test]
fn right_arrow_clears_marks_on_dir_descend() {
let tmp = temp_dir_with_files();
let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
let dir_idx = explorer
.entries
.iter()
.position(|e| e.is_dir)
.expect("fixture has a subdir");
explorer.toggle_mark();
assert!(
!explorer.marked.is_empty(),
"should have a mark before descend"
);
explorer.cursor = explorer
.entries
.iter()
.position(|e| e.is_dir)
.expect("fixture has a subdir");
explorer.handle_key(key(KeyCode::Right));
assert!(
explorer.marked.is_empty(),
"navigate() must clear marks on directory descend"
);
let _ = dir_idx;
}
#[test]
fn backspace_still_ascends() {
let tmp = temp_dir_with_files();
let subdir = tmp.path().join("subdir");
let mut explorer = FileExplorer::new(subdir, vec![]);
explorer.handle_key(key(KeyCode::Backspace));
assert_eq!(explorer.current_dir, tmp.path());
}
#[test]
fn q_key_dismisses() {
let tmp = temp_dir_with_files();
let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
assert_eq!(
explorer.handle_key(key(KeyCode::Char('q'))),
ExplorerOutcome::Dismissed
);
}
#[test]
fn page_down_advances_cursor_by_ten() {
let tmp = tempfile::tempdir().unwrap();
for i in 0..15 {
fs::write(tmp.path().join(format!("file{i:02}.txt")), b"").unwrap();
}
let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
explorer.cursor = 0;
explorer.handle_key(key(KeyCode::PageDown));
assert_eq!(explorer.cursor, 10);
}
#[test]
fn page_up_retreats_cursor_by_ten() {
let tmp = tempfile::tempdir().unwrap();
for i in 0..15 {
fs::write(tmp.path().join(format!("file{i:02}.txt")), b"").unwrap();
}
let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
explorer.cursor = 12;
explorer.handle_key(key(KeyCode::PageUp));
assert_eq!(explorer.cursor, 2);
}
#[test]
fn home_key_jumps_to_top() {
let tmp = temp_dir_with_files();
let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
explorer.cursor = explorer.entries.len() - 1;
explorer.handle_key(key(KeyCode::Home));
assert_eq!(explorer.cursor, 0);
assert_eq!(explorer.scroll_offset, 0);
}
#[test]
fn g_key_jumps_to_top() {
let tmp = temp_dir_with_files();
let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
explorer.cursor = explorer.entries.len() - 1;
explorer.handle_key(key(KeyCode::Char('g')));
assert_eq!(explorer.cursor, 0);
assert_eq!(explorer.scroll_offset, 0);
}
#[test]
fn end_key_jumps_to_bottom() {
let tmp = temp_dir_with_files();
let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
explorer.cursor = 0;
explorer.handle_key(key(KeyCode::End));
assert_eq!(explorer.cursor, explorer.entries.len() - 1);
}
#[test]
fn capital_g_key_jumps_to_bottom() {
let tmp = temp_dir_with_files();
let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
explorer.cursor = 0;
let key_g = KeyEvent::new(KeyCode::Char('G'), KeyModifiers::NONE);
explorer.handle_key(key_g);
assert_eq!(explorer.cursor, explorer.entries.len() - 1);
}
#[test]
fn ascend_at_root_sets_status() {
let root = std::path::PathBuf::from("/");
let mut explorer = FileExplorer::new(root.clone(), vec![]);
assert!(explorer.is_at_root());
explorer.handle_key(key(KeyCode::Backspace));
assert_eq!(explorer.current_dir, root);
assert!(
!explorer.status().is_empty(),
"status should report already at root"
);
}
#[test]
fn is_at_root_false_for_subdir() {
let tmp = temp_dir_with_files();
let explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
assert!(!explorer.is_at_root());
}
#[test]
fn is_empty_reflects_visible_entries() {
let empty_dir = tempfile::tempdir().unwrap();
let explorer = FileExplorer::new(empty_dir.path().to_path_buf(), vec![]);
assert!(explorer.is_empty());
let tmp = temp_dir_with_files();
let explorer2 = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
assert!(!explorer2.is_empty());
}
#[test]
fn entry_count_matches_entries_len() {
let tmp = temp_dir_with_files();
let explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
assert_eq!(explorer.entry_count(), explorer.entries.len());
assert!(explorer.entry_count() > 0);
}
#[test]
fn search_query_empty_when_not_searching() {
let tmp = temp_dir_with_files();
let explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
assert!(!explorer.is_searching());
assert_eq!(explorer.search_query(), "");
}
#[test]
fn search_is_case_insensitive() {
let tmp = temp_dir_with_files();
let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
explorer.handle_key(key(KeyCode::Char('/')));
for c in "UBU".chars() {
explorer.handle_key(key(KeyCode::Char(c)));
}
assert_eq!(explorer.entries.len(), 1);
assert_eq!(explorer.entries[0].name, "ubuntu.iso");
}
#[test]
fn extension_filter_is_case_insensitive() {
let tmp = tempfile::tempdir().unwrap();
fs::write(tmp.path().join("disk.ISO"), b"data").unwrap();
fs::write(tmp.path().join("other.txt"), b"text").unwrap();
let explorer = FileExplorer::new(tmp.path().to_path_buf(), vec!["iso".into()]);
assert!(
explorer.entries.iter().any(|e| e.name == "disk.ISO"),
"upper-case extension should be matched by lower-case filter"
);
assert!(
!explorer.entries.iter().any(|e| e.name == "other.txt"),
"non-matching extension should be excluded"
);
}
#[test]
fn builder_allow_extension_filters_entries() {
let tmp = temp_dir_with_files();
let explorer = FileExplorer::builder(tmp.path().to_path_buf())
.allow_extension("iso")
.build();
assert!(explorer.entries.iter().any(|e| e.name == "ubuntu.iso"));
assert!(!explorer.entries.iter().any(|e| e.name == "debian.img"));
assert!(!explorer.entries.iter().any(|e| e.name == "readme.txt"));
}
#[test]
fn builder_show_hidden_shows_dotfiles() {
let tmp = temp_dir_with_files();
fs::write(tmp.path().join(".dotfile"), b"").unwrap();
let hidden_explorer = FileExplorer::builder(tmp.path().to_path_buf())
.show_hidden(true)
.build();
assert!(hidden_explorer.entries.iter().any(|e| e.name == ".dotfile"));
let normal_explorer = FileExplorer::builder(tmp.path().to_path_buf())
.show_hidden(false)
.build();
assert!(!normal_explorer.entries.iter().any(|e| e.name == ".dotfile"));
}
#[test]
fn set_extension_filter_updates_entries() {
let tmp = temp_dir_with_files();
let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
assert!(explorer.entries.iter().any(|e| e.name == "readme.txt"));
explorer.set_extension_filter(["iso"]);
assert!(explorer.entries.iter().any(|e| e.name == "ubuntu.iso"));
assert!(!explorer.entries.iter().any(|e| e.name == "readme.txt"));
}
#[test]
fn entry_icon_directory() {
let entry = FsEntry {
name: "mydir".into(),
path: std::path::PathBuf::from("/mydir"),
is_dir: true,
size: None,
extension: String::new(),
};
assert_eq!(entry_icon(&entry), "📁");
}
#[test]
fn entry_icon_recognises_known_extensions() {
let make = |name: &str, ext: &str| FsEntry {
name: name.into(),
path: std::path::PathBuf::from(name),
is_dir: false,
size: Some(0),
extension: ext.into(),
};
assert_eq!(entry_icon(&make("archive.zip", "zip")), "📦");
assert_eq!(entry_icon(&make("doc.pdf", "pdf")), "📕");
assert_eq!(entry_icon(&make("notes.md", "md")), "📝");
assert_eq!(entry_icon(&make("config.toml", "toml")), "⚙ ");
assert_eq!(entry_icon(&make("main.rs", "rs")), "🦀");
assert_eq!(entry_icon(&make("script.py", "py")), "🐍");
assert_eq!(entry_icon(&make("page.html", "html")), "🌐");
assert_eq!(entry_icon(&make("image.png", "png")), "🖼 ");
assert_eq!(entry_icon(&make("video.mp4", "mp4")), "🎬");
assert_eq!(entry_icon(&make("song.mp3", "mp3")), "🎵");
assert_eq!(entry_icon(&make("unknown.xyz", "xyz")), "📄");
}
#[test]
fn fmt_size_exact_boundaries() {
assert_eq!(fmt_size(1_024), "1.0 KB");
assert_eq!(fmt_size(1_048_576), "1.0 MB");
assert_eq!(fmt_size(1_073_741_824), "1.0 GB");
assert_eq!(fmt_size(1_023), "1023 B");
assert_eq!(fmt_size(1_047_552), "1023.0 KB"); }
#[test]
fn toggle_mark_adds_entry_to_marked_set() {
let dir = temp_dir_with_files();
let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
assert!(!explorer.entries.is_empty(), "need at least one entry");
explorer.toggle_mark();
assert_eq!(explorer.marked.len(), 1, "one entry should be marked");
}
#[test]
fn toggle_mark_removes_already_marked_entry() {
let dir = temp_dir_with_files();
let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
explorer.toggle_mark(); let cursor_after_first = explorer.cursor;
explorer.cursor = 0; explorer.toggle_mark();
assert!(
explorer.marked.is_empty(),
"second toggle on same entry should unmark it"
);
let _ = cursor_after_first; }
#[test]
fn toggle_mark_advances_cursor_down() {
let dir = temp_dir_with_files();
let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
assert!(
explorer.entries.len() >= 2,
"fixture must have at least 2 entries"
);
let before = explorer.cursor;
explorer.toggle_mark();
assert_eq!(
explorer.cursor,
before + 1,
"cursor should advance by one after toggle_mark"
);
}
#[test]
fn toggle_mark_at_last_entry_does_not_overflow() {
let dir = temp_dir_with_files();
let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
explorer.cursor = explorer.entries.len() - 1;
explorer.toggle_mark();
assert_eq!(
explorer.cursor,
explorer.entries.len() - 1,
"cursor should stay at the last entry, not overflow"
);
}
#[test]
fn clear_marks_empties_marked_set() {
let dir = temp_dir_with_files();
let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
explorer.toggle_mark();
assert!(
!explorer.marked.is_empty(),
"should have a mark before clear"
);
explorer.clear_marks();
assert!(
explorer.marked.is_empty(),
"marked set should be empty after clear_marks"
);
}
#[test]
fn space_key_marks_current_entry() {
let dir = temp_dir_with_files();
let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
assert!(!explorer.entries.is_empty(), "need at least one entry");
let outcome = explorer.handle_key(key(KeyCode::Char(' ')));
assert_eq!(
outcome,
ExplorerOutcome::Pending,
"Space should return Pending"
);
assert_eq!(
explorer.marked.len(),
1,
"Space should mark the current entry"
);
}
#[test]
fn space_key_toggles_mark_off() {
let dir = temp_dir_with_files();
let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
explorer.handle_key(key(KeyCode::Char(' '))); explorer.cursor = 0; explorer.handle_key(key(KeyCode::Char(' ')));
assert!(
explorer.marked.is_empty(),
"second Space on same entry should unmark it"
);
}
#[test]
fn marks_cleared_when_ascending_to_parent() {
let dir = temp_dir_with_files();
let sub = dir.path().join("subdir");
fs::write(sub.join("inner.txt"), b"inner").unwrap();
let mut explorer = FileExplorer::new(sub.clone(), vec![]);
explorer.toggle_mark();
assert!(
!explorer.marked.is_empty(),
"should have a mark before ascend"
);
explorer.handle_key(key(KeyCode::Backspace));
assert!(
explorer.marked.is_empty(),
"marks should be cleared after ascending to parent"
);
}
#[test]
fn marks_cleared_when_descending_into_directory() {
let dir = temp_dir_with_files();
let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
let sub_idx = explorer
.entries
.iter()
.position(|e| e.is_dir)
.expect("fixture has a subdir");
explorer.cursor = sub_idx;
explorer.toggle_mark();
assert!(
!explorer.marked.is_empty(),
"should have a mark before descend"
);
explorer.cursor = explorer
.entries
.iter()
.position(|e| e.is_dir)
.expect("fixture has a subdir");
explorer.handle_key(key(KeyCode::Enter));
assert!(
explorer.marked.is_empty(),
"marks should be cleared after descending into a directory"
);
}
#[test]
fn can_mark_multiple_entries() {
let dir = temp_dir_with_files();
let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
let total = explorer.entries.len();
assert!(total >= 2, "fixture must have at least 2 entries");
for _ in 0..total {
explorer.toggle_mark();
}
assert_eq!(explorer.marked.len(), total, "all entries should be marked");
}
#[test]
fn move_up_at_top_does_not_underflow() {
let dir = tempdir().expect("tempdir");
fs::write(dir.path().join("a.txt"), b"a").unwrap();
let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
explorer.cursor = 0;
explorer.handle_key(key(KeyCode::Up));
assert_eq!(explorer.cursor, 0);
}
#[test]
fn move_down_at_bottom_does_not_overflow() {
let dir = tempdir().expect("tempdir");
fs::write(dir.path().join("a.txt"), b"a").unwrap();
let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
let last = explorer.entries.len().saturating_sub(1);
explorer.cursor = last;
explorer.handle_key(key(KeyCode::Down));
assert_eq!(explorer.cursor, last);
}
#[test]
fn move_down_on_empty_dir_does_not_panic() {
let dir = tempdir().expect("tempdir");
let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
assert!(explorer.entries.is_empty());
explorer.handle_key(key(KeyCode::Down));
assert_eq!(explorer.cursor, 0);
}
#[test]
fn move_up_on_empty_dir_does_not_panic() {
let dir = tempdir().expect("tempdir");
let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
assert!(explorer.entries.is_empty());
explorer.handle_key(key(KeyCode::Up));
assert_eq!(explorer.cursor, 0);
}
#[test]
fn page_down_at_bottom_does_not_overflow() {
let dir = tempdir().expect("tempdir");
for i in 0..5 {
fs::write(dir.path().join(format!("{i}.txt")), b"x").unwrap();
}
let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
let last = explorer.entries.len().saturating_sub(1);
explorer.cursor = last;
explorer.handle_key(key(KeyCode::PageDown));
assert_eq!(explorer.cursor, last);
}
#[test]
fn page_up_at_top_does_not_underflow() {
let dir = tempdir().expect("tempdir");
fs::write(dir.path().join("a.txt"), b"a").unwrap();
let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
explorer.cursor = 0;
explorer.handle_key(key(KeyCode::PageUp));
assert_eq!(explorer.cursor, 0);
}
#[test]
fn ascend_at_root_does_not_panic() {
let mut explorer = FileExplorer::new(std::path::PathBuf::from("/"), vec![]);
explorer.handle_key(key(KeyCode::Backspace));
assert_eq!(explorer.current_dir, std::path::PathBuf::from("/"));
}
#[test]
fn cursor_clamped_after_reload_with_fewer_entries() {
let dir = tempdir().expect("tempdir");
fs::write(dir.path().join("a.txt"), b"a").unwrap();
fs::write(dir.path().join("b.txt"), b"b").unwrap();
fs::write(dir.path().join("c.txt"), b"c").unwrap();
let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
explorer.cursor = explorer.entries.len() - 1;
explorer.set_extension_filter(["a"]);
assert!(
explorer.cursor < explorer.entries.len().max(1),
"cursor {} out of range for {} entries",
explorer.cursor,
explorer.entries.len()
);
}
#[test]
fn scroll_offset_clamped_after_reload_with_empty_entries() {
let dir = tempdir().expect("tempdir");
fs::write(dir.path().join("test.rs"), b"fn main(){}").unwrap();
let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
explorer.scroll_offset = 5; explorer.cursor = 0;
explorer.set_extension_filter(["xyz"]);
assert_eq!(explorer.cursor, 0);
assert_eq!(explorer.scroll_offset, 0);
}
#[test]
fn marked_paths_returns_reference_to_marked_set() {
let dir = temp_dir_with_files();
let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
explorer.toggle_mark();
assert_eq!(
explorer.marked_paths().len(),
explorer.marked.len(),
"marked_paths() should reflect the same set as the field"
);
}
fn make_file_entry(name: &str) -> crate::types::FsEntry {
let ext = std::path::Path::new(name)
.extension()
.map(|e| e.to_string_lossy().to_lowercase())
.unwrap_or_default();
crate::types::FsEntry {
name: name.to_string(),
path: std::path::PathBuf::from(name),
is_dir: false,
size: None,
extension: ext,
}
}
#[test]
fn entry_icon_iso_returns_disc() {
let e = make_file_entry("release.iso");
assert_eq!(entry_icon(&e), "💿");
}
#[test]
fn entry_icon_dmg_returns_disc() {
let e = make_file_entry("app.dmg");
assert_eq!(entry_icon(&e), "💿");
}
#[test]
fn entry_icon_zip_returns_package() {
let e = make_file_entry("archive.zip");
assert_eq!(entry_icon(&e), "📦");
}
#[test]
fn entry_icon_tar_returns_package() {
let e = make_file_entry("src.tar");
assert_eq!(entry_icon(&e), "📦");
}
#[test]
fn entry_icon_gz_returns_package() {
let e = make_file_entry("data.gz");
assert_eq!(entry_icon(&e), "📦");
}
#[test]
fn entry_icon_pdf_returns_book() {
let e = make_file_entry("manual.pdf");
assert_eq!(entry_icon(&e), "📕");
}
#[test]
fn entry_icon_md_returns_memo() {
let e = make_file_entry("README.md");
assert_eq!(entry_icon(&e), "📝");
}
#[test]
fn entry_icon_toml_returns_gear() {
let e = make_file_entry("Cargo.toml");
assert_eq!(entry_icon(&e), "⚙ ");
}
#[test]
fn entry_icon_json_returns_gear() {
let e = make_file_entry("config.json");
assert_eq!(entry_icon(&e), "⚙ ");
}
#[test]
fn entry_icon_lock_returns_lock() {
let e = make_file_entry("Cargo.lock");
assert_eq!(entry_icon(&e), "🔒");
}
#[test]
fn entry_icon_py_returns_snake() {
let e = make_file_entry("script.py");
assert_eq!(entry_icon(&e), "🐍");
}
#[test]
fn entry_icon_html_returns_globe() {
let e = make_file_entry("index.html");
assert_eq!(entry_icon(&e), "🌐");
}
#[test]
fn entry_icon_css_returns_palette() {
let e = make_file_entry("style.css");
assert_eq!(entry_icon(&e), "🎨");
}
#[test]
fn entry_icon_svg_returns_palette() {
let e = make_file_entry("logo.svg");
assert_eq!(entry_icon(&e), "🎨");
}
#[test]
fn entry_icon_png_returns_image() {
let e = make_file_entry("photo.png");
assert_eq!(entry_icon(&e), "🖼 ");
}
#[test]
fn entry_icon_jpg_returns_image() {
let e = make_file_entry("photo.jpg");
assert_eq!(entry_icon(&e), "🖼 ");
}
#[test]
fn entry_icon_mp4_returns_film() {
let e = make_file_entry("video.mp4");
assert_eq!(entry_icon(&e), "🎬");
}
#[test]
fn entry_icon_mp3_returns_music() {
let e = make_file_entry("song.mp3");
assert_eq!(entry_icon(&e), "🎵");
}
#[test]
fn entry_icon_ttf_returns_font() {
let e = make_file_entry("font.ttf");
assert_eq!(entry_icon(&e), "🔤");
}
#[test]
fn entry_icon_exe_returns_gear() {
let e = make_file_entry("setup.exe");
assert_eq!(entry_icon(&e), "⚙ ");
}
#[test]
fn entry_icon_unknown_extension_returns_document() {
let e = make_file_entry("mystery.xyz");
assert_eq!(entry_icon(&e), "📄");
}
#[test]
fn entry_icon_no_extension_returns_document() {
let e = crate::types::FsEntry {
name: "Makefile".into(),
path: std::path::PathBuf::from("Makefile"),
is_dir: false,
size: None,
extension: String::new(),
};
assert_eq!(entry_icon(&e), "📄");
}
#[test]
fn fmt_size_zero_bytes() {
assert_eq!(fmt_size(0), "0 B");
}
#[test]
fn fmt_size_one_byte() {
assert_eq!(fmt_size(1), "1 B");
}
#[test]
fn fmt_size_1023_bytes_stays_bytes() {
assert_eq!(fmt_size(1_023), "1023 B");
}
#[test]
fn fmt_size_exactly_1_kb() {
assert_eq!(fmt_size(1_024), "1.0 KB");
}
#[test]
fn fmt_size_1_5_kb() {
assert_eq!(fmt_size(1_536), "1.5 KB");
}
#[test]
fn fmt_size_1_mb_boundary() {
assert_eq!(fmt_size(1_048_576), "1.0 MB");
}
#[test]
fn fmt_size_2_mb() {
assert_eq!(fmt_size(2_097_152), "2.0 MB");
}
#[test]
fn fmt_size_1_gb_boundary() {
assert_eq!(fmt_size(1_073_741_824), "1.0 GB");
}
#[test]
fn fmt_size_large_value() {
assert_eq!(fmt_size(10 * 1_073_741_824), "10.0 GB");
}
#[test]
fn navigate_to_accepts_str_slice() {
let dir = tempdir().expect("tempdir");
let sub = dir.path().join("sub");
fs::create_dir(&sub).unwrap();
let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
explorer.navigate_to(sub.to_str().unwrap());
assert_eq!(explorer.current_dir, sub);
}
#[test]
fn navigate_to_accepts_path_ref() {
let dir = tempdir().expect("tempdir");
let sub = dir.path().join("sub2");
fs::create_dir(&sub).unwrap();
let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
explorer.navigate_to(sub.as_path());
assert_eq!(explorer.current_dir, sub);
}
#[test]
fn navigate_to_resets_cursor_to_zero() {
let dir = tempdir().expect("tempdir");
let sub = dir.path().join("sub3");
fs::create_dir(&sub).unwrap();
let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
explorer.cursor = 99;
explorer.scroll_offset = 5;
explorer.navigate_to(sub.as_path());
assert_eq!(explorer.cursor, 0);
assert_eq!(explorer.scroll_offset, 0);
}
#[test]
fn is_searching_false_by_default() {
let dir = tempdir().expect("tempdir");
let explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
assert!(!explorer.is_searching());
}
#[test]
fn is_searching_true_after_slash_key() {
let dir = tempdir().expect("tempdir");
let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
explorer.handle_key(key(KeyCode::Char('/')));
assert!(explorer.is_searching());
}
#[test]
fn is_searching_false_after_esc_cancels_search() {
let dir = tempdir().expect("tempdir");
let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
explorer.handle_key(key(KeyCode::Char('/')));
explorer.handle_key(key(KeyCode::Esc));
assert!(!explorer.is_searching());
}
#[test]
fn status_is_empty_on_fresh_explorer() {
let dir = tempdir().expect("tempdir");
let explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
assert!(explorer.status().is_empty());
}
#[test]
fn status_cleared_after_reload() {
let dir = tempdir().expect("tempdir");
let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
explorer.status = "stale message".into();
explorer.reload();
assert!(
explorer.status().is_empty(),
"reload should clear the status message"
);
}
#[test]
fn load_entries_empty_dir_returns_empty_vec() {
let dir = tempdir().expect("tempdir");
let entries = load_entries(dir.path(), false, &[], crate::types::SortMode::Name, "");
assert!(
entries.is_empty(),
"empty directory should produce no entries"
);
}
#[test]
fn load_entries_hidden_excluded_by_default() {
let dir = tempdir().expect("tempdir");
fs::write(dir.path().join(".hidden"), b"h").unwrap();
fs::write(dir.path().join("visible.txt"), b"v").unwrap();
let entries = load_entries(dir.path(), false, &[], crate::types::SortMode::Name, "");
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].name, "visible.txt");
}
#[test]
fn load_entries_hidden_included_when_show_hidden_true() {
let dir = tempdir().expect("tempdir");
fs::write(dir.path().join(".hidden"), b"h").unwrap();
fs::write(dir.path().join("visible.txt"), b"v").unwrap();
let entries = load_entries(dir.path(), true, &[], crate::types::SortMode::Name, "");
assert_eq!(entries.len(), 2);
}
#[test]
fn load_entries_nonexistent_dir_returns_empty_vec() {
let entries = load_entries(
std::path::Path::new("/nonexistent/path/that/does/not/exist"),
false,
&[],
crate::types::SortMode::Name,
"",
);
assert!(entries.is_empty());
}
#[test]
fn load_entries_search_query_is_case_insensitive() {
let dir = tempdir().expect("tempdir");
fs::write(dir.path().join("README.md"), b"r").unwrap();
fs::write(dir.path().join("main.rs"), b"m").unwrap();
let entries = load_entries(
dir.path(),
false,
&[],
crate::types::SortMode::Name,
"readme",
);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].name, "README.md");
}
#[test]
fn load_entries_dirs_always_precede_files() {
let dir = tempdir().expect("tempdir");
fs::write(dir.path().join("z_file.txt"), b"z").unwrap();
fs::create_dir(dir.path().join("a_dir")).unwrap();
let entries = load_entries(dir.path(), false, &[], crate::types::SortMode::Name, "");
assert!(entries[0].is_dir, "directory must come before file");
assert!(!entries[1].is_dir);
}
#[test]
fn load_entries_ext_filter_excludes_non_matching_files() {
let dir = tempdir().expect("tempdir");
fs::write(dir.path().join("main.rs"), b"r").unwrap();
fs::write(dir.path().join("Cargo.toml"), b"t").unwrap();
let filter = vec!["rs".to_string()];
let entries = load_entries(dir.path(), false, &filter, crate::types::SortMode::Name, "");
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].extension, "rs");
}
#[test]
fn load_entries_ext_filter_always_includes_dirs() {
let dir = tempdir().expect("tempdir");
fs::create_dir(dir.path().join("subdir")).unwrap();
fs::write(dir.path().join("file.txt"), b"t").unwrap();
let filter = vec!["rs".to_string()];
let entries = load_entries(dir.path(), false, &filter, crate::types::SortMode::Name, "");
assert_eq!(entries.len(), 1);
assert!(entries[0].is_dir);
}
}