1use std::{
33 collections::HashSet,
34 fs,
35 path::{Path, PathBuf},
36};
37
38use crossterm::event::{KeyCode, KeyEvent};
39
40use crate::types::{ExplorerOutcome, FsEntry, SortMode};
41
42#[derive(Debug)]
69pub struct FileExplorer {
70 pub current_dir: PathBuf,
72 pub theme_name: String,
74 pub editor_name: String,
76 pub entries: Vec<FsEntry>,
78 pub cursor: usize,
80 pub(crate) scroll_offset: usize,
82 pub extension_filter: Vec<String>,
86 pub show_hidden: bool,
88 pub(crate) status: String,
90 pub sort_mode: SortMode,
92 pub search_query: String,
94 pub search_active: bool,
96 pub marked: HashSet<PathBuf>,
98 pub mkdir_active: bool,
100 pub mkdir_input: String,
102 pub touch_active: bool,
104 pub touch_input: String,
106 pub rename_active: bool,
108 pub rename_input: String,
110}
111
112macro_rules! handle_input_mode {
131 ($self:ident, $key:ident, $active:ident, $input:ident, $on_enter:expr) => {
132 if $self.$active {
133 match $key.code {
134 KeyCode::Char(c)
136 if $key.modifiers.is_empty()
137 || $key.modifiers == crossterm::event::KeyModifiers::SHIFT =>
138 {
139 $self.$input.push(c);
140 return ExplorerOutcome::Pending;
141 }
142 KeyCode::Backspace => {
144 $self.$input.pop();
145 return ExplorerOutcome::Pending;
146 }
147 KeyCode::Enter => $on_enter,
149 KeyCode::Esc => {
151 $self.$active = false;
152 $self.$input.clear();
153 return ExplorerOutcome::Pending;
154 }
155 _ => return ExplorerOutcome::Pending,
157 }
158 }
159 };
160}
161
162impl FileExplorer {
163 pub fn new(initial_dir: PathBuf, extension_filter: Vec<String>) -> Self {
173 let mut explorer = Self {
174 current_dir: initial_dir,
175 entries: Vec::new(),
176 cursor: 0,
177 scroll_offset: 0,
178 extension_filter,
179 show_hidden: false,
180 status: String::new(),
181 sort_mode: SortMode::default(),
182 search_query: String::new(),
183 search_active: false,
184 marked: HashSet::new(),
185 mkdir_active: false,
186 mkdir_input: String::new(),
187 touch_active: false,
188 touch_input: String::new(),
189 rename_active: false,
190 rename_input: String::new(),
191 theme_name: String::new(),
192 editor_name: String::new(),
193 };
194 explorer.reload();
195 explorer
196 }
197
198 pub fn builder(initial_dir: PathBuf) -> FileExplorerBuilder {
213 FileExplorerBuilder::new(initial_dir)
214 }
215
216 pub fn navigate_to(&mut self, path: impl Into<PathBuf>) {
229 self.current_dir = path.into();
230 self.cursor = 0;
231 self.scroll_offset = 0;
232 self.reload();
233 }
234
235 pub fn marked_paths(&self) -> &HashSet<PathBuf> {
243 &self.marked
244 }
245
246 pub fn toggle_mark(&mut self) {
249 if let Some(entry) = self.entries.get(self.cursor) {
250 let path = entry.path.clone();
251 if self.marked.contains(&path) {
252 self.marked.remove(&path);
253 } else {
254 self.marked.insert(path);
255 }
256 }
257 self.move_down();
258 }
259
260 pub fn clear_marks(&mut self) {
262 self.marked.clear();
263 }
264
265 pub fn handle_key(&mut self, key: KeyEvent) -> ExplorerOutcome {
266 if key.kind != crossterm::event::KeyEventKind::Press {
272 return ExplorerOutcome::Pending;
273 }
274
275 handle_input_mode!(self, key, rename_active, rename_input, {
279 let new_name = self.rename_input.trim().to_string();
280 self.rename_active = false;
281 self.rename_input.clear();
282 if new_name.is_empty() {
283 return ExplorerOutcome::Pending;
284 }
285 let src = match self.entries.get(self.cursor) {
287 Some(e) => e.path.clone(),
288 None => return ExplorerOutcome::Pending,
289 };
290 let dst = self.current_dir.join(&new_name);
291 match std::fs::rename(&src, &dst) {
292 Ok(()) => {
293 self.reload();
294 if let Some(idx) = self.entries.iter().position(|e| e.path == dst) {
296 self.cursor = idx;
297 }
298 return ExplorerOutcome::RenameCompleted(dst);
299 }
300 Err(e) => {
301 self.status = format!("rename failed: {e}");
302 return ExplorerOutcome::Pending;
303 }
304 }
305 });
306
307 handle_input_mode!(self, key, touch_active, touch_input, {
311 let name = self.touch_input.trim().to_string();
312 self.touch_active = false;
313 self.touch_input.clear();
314 if name.is_empty() {
315 return ExplorerOutcome::Pending;
316 }
317 let new_file = self.current_dir.join(&name);
318 let create_result = (|| -> std::io::Result<()> {
321 if let Some(parent) = new_file.parent() {
322 std::fs::create_dir_all(parent)?;
323 }
324 std::fs::OpenOptions::new()
327 .write(true)
328 .create(true)
329 .truncate(false)
330 .open(&new_file)?;
331 Ok(())
332 })();
333 match create_result {
334 Ok(()) => {
335 self.reload();
336 if let Some(idx) = self.entries.iter().position(|e| e.path == new_file) {
338 self.cursor = idx;
339 }
340 return ExplorerOutcome::TouchCreated(new_file);
341 }
342 Err(e) => {
343 self.status = format!("touch failed: {e}");
344 return ExplorerOutcome::Pending;
345 }
346 }
347 });
348
349 handle_input_mode!(self, key, mkdir_active, mkdir_input, {
353 let name = self.mkdir_input.trim().to_string();
354 self.mkdir_active = false;
355 self.mkdir_input.clear();
356 if name.is_empty() {
357 return ExplorerOutcome::Pending;
358 }
359 let new_dir = self.current_dir.join(&name);
360 match std::fs::create_dir_all(&new_dir) {
361 Ok(()) => {
362 self.reload();
363 if let Some(idx) = self.entries.iter().position(|e| e.path == new_dir) {
365 self.cursor = idx;
366 }
367 return ExplorerOutcome::MkdirCreated(new_dir);
368 }
369 Err(e) => {
370 self.status = format!("mkdir failed: {e}");
371 return ExplorerOutcome::Pending;
372 }
373 }
374 });
375
376 if self.search_active {
382 match key.code {
383 KeyCode::Char(c) if key.modifiers.is_empty() => {
384 self.search_query.push(c);
385 self.cursor = 0;
386 self.scroll_offset = 0;
387 self.reload();
388 return ExplorerOutcome::Pending;
389 }
390 KeyCode::Backspace => {
391 if self.search_query.is_empty() {
392 self.search_active = false;
394 } else {
395 self.search_query.pop();
396 self.cursor = 0;
397 self.scroll_offset = 0;
398 self.reload();
399 }
400 return ExplorerOutcome::Pending;
401 }
402 KeyCode::Esc => {
403 self.search_active = false;
406 self.search_query.clear();
407 self.cursor = 0;
408 self.scroll_offset = 0;
409 self.reload();
410 return ExplorerOutcome::Pending;
411 }
412 _ => {} }
414 }
415
416 match key.code {
417 KeyCode::Esc => ExplorerOutcome::Dismissed,
419
420 KeyCode::Char('q') if key.modifiers.is_empty() => ExplorerOutcome::Dismissed,
422
423 KeyCode::Up | KeyCode::Char('k') => {
425 self.move_up();
426 ExplorerOutcome::Pending
427 }
428
429 KeyCode::Down | KeyCode::Char('j') => {
431 self.move_down();
432 ExplorerOutcome::Pending
433 }
434
435 KeyCode::PageUp => {
437 for _ in 0..10 {
438 self.move_up();
439 }
440 ExplorerOutcome::Pending
441 }
442
443 KeyCode::PageDown => {
445 for _ in 0..10 {
446 self.move_down();
447 }
448 ExplorerOutcome::Pending
449 }
450
451 KeyCode::Home | KeyCode::Char('g') => {
453 self.cursor = 0;
454 self.scroll_offset = 0;
455 ExplorerOutcome::Pending
456 }
457
458 KeyCode::End | KeyCode::Char('G') => {
460 if !self.entries.is_empty() {
461 self.cursor = self.entries.len() - 1;
462 }
463 ExplorerOutcome::Pending
464 }
465
466 KeyCode::Left | KeyCode::Backspace | KeyCode::Char('h') => {
469 self.ascend();
470 ExplorerOutcome::Pending
471 }
472
473 KeyCode::Right => self.navigate(),
477
478 KeyCode::Enter | KeyCode::Char('l') => self.confirm(),
482
483 KeyCode::Char('.') => {
485 self.show_hidden = !self.show_hidden;
486 let was = self.cursor;
487 self.reload();
488 self.cursor = was.min(self.entries.len().saturating_sub(1));
489 ExplorerOutcome::Pending
490 }
491
492 KeyCode::Char('/') if key.modifiers.is_empty() => {
494 self.search_active = true;
495 ExplorerOutcome::Pending
496 }
497
498 KeyCode::Char('s') if key.modifiers.is_empty() => {
500 self.sort_mode = self.sort_mode.next();
501 let was = self.cursor;
502 self.reload();
503 self.cursor = was.min(self.entries.len().saturating_sub(1));
504 ExplorerOutcome::Pending
505 }
506
507 KeyCode::Char(' ') => {
509 self.toggle_mark();
510 ExplorerOutcome::Pending
511 }
512
513 KeyCode::Char('n') if key.modifiers.is_empty() => {
515 self.mkdir_active = true;
516 self.mkdir_input.clear();
517 ExplorerOutcome::Pending
518 }
519
520 KeyCode::Char('N') if key.modifiers.is_empty() => {
523 self.touch_active = true;
524 self.touch_input.clear();
525 ExplorerOutcome::Pending
526 }
527
528 KeyCode::Char('r') if key.modifiers.is_empty() => {
532 if let Some(entry) = self.entries.get(self.cursor) {
533 self.rename_input = entry.name.clone();
534 self.rename_active = true;
535 }
536 ExplorerOutcome::Pending
537 }
538
539 _ => ExplorerOutcome::Unhandled,
540 }
541 }
542
543 pub fn current_entry(&self) -> Option<&FsEntry> {
547 self.entries.get(self.cursor)
548 }
549
550 pub fn is_mkdir_active(&self) -> bool {
552 self.mkdir_active
553 }
554
555 pub fn mkdir_input(&self) -> &str {
557 &self.mkdir_input
558 }
559
560 pub fn is_touch_active(&self) -> bool {
562 self.touch_active
563 }
564
565 pub fn touch_input(&self) -> &str {
567 &self.touch_input
568 }
569
570 pub fn is_rename_active(&self) -> bool {
572 self.rename_active
573 }
574
575 pub fn rename_input(&self) -> &str {
577 &self.rename_input
578 }
579
580 pub fn is_at_root(&self) -> bool {
592 self.current_dir.parent().is_none()
593 }
594
595 pub fn is_empty(&self) -> bool {
601 self.entries.is_empty()
602 }
603
604 pub fn entry_count(&self) -> usize {
609 self.entries.len()
610 }
611
612 pub fn status(&self) -> &str {
619 &self.status
620 }
621
622 pub fn sort_mode(&self) -> SortMode {
631 self.sort_mode
632 }
633
634 pub fn search_query(&self) -> &str {
638 &self.search_query
639 }
640
641 pub fn is_searching(&self) -> bool {
644 self.search_active
645 }
646
647 pub fn set_show_hidden(&mut self, show: bool) {
662 self.show_hidden = show;
663 self.reload();
664 }
665
666 pub fn set_extension_filter<I, S>(&mut self, filter: I)
687 where
688 I: IntoIterator<Item = S>,
689 S: Into<String>,
690 {
691 self.extension_filter = filter.into_iter().map(Into::into).collect();
692 self.reload();
693 }
694
695 pub fn set_sort_mode(&mut self, mode: SortMode) {
707 self.sort_mode = mode;
708 self.reload();
709 }
710
711 fn move_up(&mut self) {
714 if self.cursor > 0 {
715 self.cursor -= 1;
716 }
717 self.clamp_cursor();
719 }
720
721 fn move_down(&mut self) {
722 let last = self.entries.len().saturating_sub(1);
723 if !self.entries.is_empty() && self.cursor < last {
724 self.cursor += 1;
725 }
726 self.clamp_cursor();
727 }
728
729 fn clamp_cursor(&mut self) {
733 let max = self.entries.len().saturating_sub(1);
734 if self.cursor > max {
735 self.cursor = max;
736 }
737 if self.scroll_offset > self.cursor {
738 self.scroll_offset = self.cursor;
739 }
740 }
741
742 fn ascend(&mut self) {
743 if let Some(parent) = self.current_dir.parent().map(|p| p.to_path_buf()) {
744 let prev = self.current_dir.clone();
745 self.current_dir = parent;
746 self.cursor = 0;
747 self.scroll_offset = 0;
748 self.search_active = false;
750 self.search_query.clear();
751 self.marked.clear();
752 self.reload();
753 if let Some(idx) = self.entries.iter().position(|e| e.path == prev) {
755 self.cursor = idx;
756 }
757 self.clamp_cursor();
759 } else {
760 self.status = "Already at the filesystem root.".to_string();
762 }
763 }
764
765 fn navigate(&mut self) -> ExplorerOutcome {
771 let Some(entry) = self.entries.get(self.cursor) else {
772 return ExplorerOutcome::Pending;
773 };
774
775 if entry.is_dir {
776 let path = entry.path.clone();
777 self.search_active = false;
778 self.search_query.clear();
779 self.marked.clear();
780 self.navigate_to(path);
781 } else {
782 self.move_down();
783 }
784 ExplorerOutcome::Pending
785 }
786
787 fn confirm(&mut self) -> ExplorerOutcome {
788 let Some(entry) = self.entries.get(self.cursor) else {
789 return ExplorerOutcome::Pending;
790 };
791
792 if entry.is_dir {
793 let path = entry.path.clone();
794 self.search_active = false;
796 self.search_query.clear();
797 self.marked.clear();
798 self.navigate_to(path);
799 ExplorerOutcome::Pending
800 } else {
801 ExplorerOutcome::Selected(entry.path.clone())
804 }
805 }
806
807 pub fn reload(&mut self) {
815 self.status.clear();
816 self.entries = load_entries(
817 &self.current_dir,
818 self.show_hidden,
819 &self.extension_filter,
820 self.sort_mode,
821 &self.search_query,
822 );
823 self.clamp_cursor();
827 }
828}
829
830pub struct FileExplorerBuilder {
849 initial_dir: PathBuf,
850 extension_filter: Vec<String>,
851 show_hidden: bool,
852 sort_mode: SortMode,
853}
854
855impl FileExplorerBuilder {
856 pub fn new(initial_dir: PathBuf) -> Self {
858 Self {
859 initial_dir,
860 extension_filter: Vec::new(),
861 show_hidden: false,
862 sort_mode: SortMode::default(),
863 }
864 }
865
866 pub fn extension_filter(mut self, filter: Vec<String>) -> Self {
878 self.extension_filter = filter;
879 self
880 }
881
882 pub fn allow_extension(mut self, ext: impl Into<String>) -> Self {
895 self.extension_filter.push(ext.into());
896 self
897 }
898
899 pub fn show_hidden(mut self, show: bool) -> Self {
909 self.show_hidden = show;
910 self
911 }
912
913 pub fn sort_mode(mut self, mode: SortMode) -> Self {
923 self.sort_mode = mode;
924 self
925 }
926
927 pub fn build(self) -> FileExplorer {
929 let mut explorer = FileExplorer {
930 current_dir: self.initial_dir,
931 entries: Vec::new(),
932 cursor: 0,
933 scroll_offset: 0,
934 extension_filter: self.extension_filter,
935 show_hidden: self.show_hidden,
936 status: String::new(),
937 sort_mode: self.sort_mode,
938 search_query: String::new(),
939 search_active: false,
940 marked: HashSet::new(),
941 mkdir_active: false,
942 mkdir_input: String::new(),
943 touch_active: false,
944 touch_input: String::new(),
945 rename_active: false,
946 rename_input: String::new(),
947 theme_name: String::new(),
948 editor_name: String::new(),
949 };
950 explorer.reload();
951 explorer
952 }
953}
954
955pub(crate) fn load_entries(
967 dir: &Path,
968 show_hidden: bool,
969 ext_filter: &[String],
970 sort_mode: SortMode,
971 search_query: &str,
972) -> Vec<FsEntry> {
973 let read = match fs::read_dir(dir) {
974 Ok(r) => r,
975 Err(_) => return Vec::new(),
976 };
977
978 let mut dirs: Vec<FsEntry> = Vec::new();
979 let mut files: Vec<FsEntry> = Vec::new();
980
981 for entry in read.flatten() {
982 let path = entry.path();
983 let name = entry.file_name().to_string_lossy().to_string();
984
985 if !show_hidden && name.starts_with('.') {
986 continue;
987 }
988
989 let is_dir = path.is_dir();
990 let extension = if is_dir {
991 String::new()
992 } else {
993 path.extension()
994 .map(|e| e.to_string_lossy().to_lowercase())
995 .unwrap_or_default()
996 };
997
998 if !is_dir && !ext_filter.is_empty() {
1000 let matches = ext_filter
1001 .iter()
1002 .any(|f| f.eq_ignore_ascii_case(&extension));
1003 if !matches {
1004 continue;
1005 }
1006 }
1007
1008 if !search_query.is_empty() {
1010 let q = search_query.to_lowercase();
1011 if !name.to_lowercase().contains(&q) {
1012 continue;
1013 }
1014 }
1015
1016 let size = if is_dir {
1017 None
1018 } else {
1019 entry.metadata().ok().map(|m| m.len())
1020 };
1021
1022 let fs_entry = FsEntry {
1023 name,
1024 path,
1025 is_dir,
1026 size,
1027 extension,
1028 };
1029
1030 if is_dir {
1031 dirs.push(fs_entry);
1032 } else {
1033 files.push(fs_entry);
1034 }
1035 }
1036
1037 dirs.sort_by_key(|a| a.name.to_lowercase());
1040
1041 match sort_mode {
1042 SortMode::Name => {
1043 files.sort_by_key(|a| a.name.to_lowercase());
1044 }
1045 SortMode::SizeDesc => {
1046 files.sort_by_key(|b| std::cmp::Reverse(b.size.unwrap_or(0)));
1048 }
1049 SortMode::Extension => {
1050 files.sort_by(|a, b| {
1052 a.extension
1053 .cmp(&b.extension)
1054 .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
1055 });
1056 }
1057 }
1058
1059 dirs.extend(files);
1061 dirs
1062}
1063
1064pub fn entry_icon(entry: &FsEntry) -> &'static str {
1071 if entry.is_dir {
1072 return "📁";
1073 }
1074 match entry.extension.as_str() {
1075 "iso" | "dmg" => "💿",
1077 "img" => "🖼 ",
1078 "zip" | "gz" | "xz" | "zst" | "bz2" | "tar" | "7z" | "rar" | "tgz" | "tbz2" => "📦",
1080 "pdf" => "📕",
1082 "txt" | "log" | "rst" => "📄",
1083 "md" | "mdx" | "markdown" => "📝",
1084 "toml" | "yaml" | "yml" | "json" | "xml" | "ini" | "cfg" | "conf" | "env" => "⚙ ",
1086 "lock" => "🔒",
1087 "rs" => "🦀",
1089 "py" | "pyw" => "🐍",
1090 "js" | "mjs" | "cjs" => "📜",
1091 "ts" | "mts" | "cts" => "📜",
1092 "jsx" | "tsx" => "📜",
1093 "go" => "📜",
1094 "c" | "h" => "📜",
1095 "cpp" | "cc" | "cxx" | "hpp" | "hxx" => "📜",
1096 "java" | "kt" | "kts" => "📜",
1097 "rb" | "erb" => "📜",
1098 "php" => "📜",
1099 "swift" => "📜",
1100 "cs" => "📜",
1101 "lua" => "📜",
1102 "zig" => "📜",
1103 "ex" | "exs" => "📜",
1104 "hs" | "lhs" => "📜",
1105 "ml" | "mli" => "📜",
1106 "sh" | "bash" | "zsh" | "fish" | "nu" => "📜",
1108 "bat" | "cmd" | "ps1" => "📜",
1109 "html" | "htm" | "xhtml" => "🌐",
1111 "css" | "scss" | "sass" | "less" => "🎨",
1112 "svg" => "🎨",
1113 "png" | "jpg" | "jpeg" | "gif" | "bmp" | "webp" | "ico" | "tiff" | "tif" | "avif"
1115 | "heic" | "heif" => "🖼 ",
1116 "mp4" | "mkv" | "avi" | "mov" | "webm" | "flv" | "wmv" | "m4v" => "🎬",
1118 "mp3" | "wav" | "flac" | "ogg" | "aac" | "m4a" | "opus" | "wma" => "🎵",
1120 "ttf" | "otf" | "woff" | "woff2" | "eot" => "🔤",
1122 "exe" | "msi" | "deb" | "rpm" | "appimage" | "apk" => "⚙ ",
1124 _ => "📄",
1125 }
1126}
1127
1128pub fn fmt_size(bytes: u64) -> String {
1142 const KB: u64 = 1_024;
1143 const MB: u64 = 1_024 * KB;
1144 const GB: u64 = 1_024 * MB;
1145 if bytes >= GB {
1146 format!("{:.1} GB", bytes as f64 / GB as f64)
1147 } else if bytes >= MB {
1148 format!("{:.1} MB", bytes as f64 / MB as f64)
1149 } else if bytes >= KB {
1150 format!("{:.1} KB", bytes as f64 / KB as f64)
1151 } else {
1152 format!("{bytes} B")
1153 }
1154}
1155
1156#[cfg(test)]
1159mod tests {
1160 use super::*;
1161 use crossterm::event::{KeyEvent, KeyModifiers};
1162 use std::fs;
1163 use tempfile::{tempdir, TempDir};
1164
1165 fn temp_dir_with_files() -> TempDir {
1168 let dir = tempfile::tempdir().expect("temp dir");
1169 fs::write(dir.path().join("ubuntu.iso"), b"fake iso content").unwrap();
1170 fs::write(dir.path().join("debian.img"), b"fake img content").unwrap();
1171 fs::write(dir.path().join("readme.txt"), b"some text").unwrap();
1172 fs::create_dir(dir.path().join("subdir")).unwrap();
1173 dir
1174 }
1175
1176 fn key(code: KeyCode) -> KeyEvent {
1177 KeyEvent::new(code, KeyModifiers::NONE)
1178 }
1179
1180 #[test]
1183 fn new_loads_entries() {
1184 let tmp = temp_dir_with_files();
1185 let explorer =
1186 FileExplorer::new(tmp.path().to_path_buf(), vec!["iso".into(), "img".into()]);
1187 assert!(explorer
1188 .entries
1189 .iter()
1190 .any(|e| e.name == "subdir" && e.is_dir));
1191 assert!(explorer.entries.iter().any(|e| e.name == "ubuntu.iso"));
1192 assert!(explorer.entries.iter().any(|e| e.name == "debian.img"));
1193 assert!(!explorer.entries.iter().any(|e| e.name == "readme.txt"));
1195 }
1196
1197 #[test]
1198 fn no_filter_shows_all_files() {
1199 let tmp = temp_dir_with_files();
1200 let explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1201 assert!(explorer.entries.iter().any(|e| e.name == "readme.txt"));
1202 }
1203
1204 #[test]
1205 fn dirs_listed_before_files() {
1206 let tmp = temp_dir_with_files();
1207 let explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1208 let first_file_idx = explorer
1209 .entries
1210 .iter()
1211 .position(|e| !e.is_dir)
1212 .unwrap_or(usize::MAX);
1213 let last_dir_idx = explorer.entries.iter().rposition(|e| e.is_dir).unwrap_or(0);
1214 assert!(
1215 last_dir_idx < first_file_idx,
1216 "all dirs must appear before any file"
1217 );
1218 }
1219
1220 #[test]
1221 fn move_down_increments_cursor() {
1222 let tmp = temp_dir_with_files();
1223 let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1224 explorer.move_down();
1225 assert_eq!(explorer.cursor, 1);
1226 }
1227
1228 #[test]
1229 fn move_up_clamps_at_zero() {
1230 let tmp = temp_dir_with_files();
1231 let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1232 explorer.move_up();
1233 assert_eq!(explorer.cursor, 0);
1234 }
1235
1236 #[test]
1237 fn move_down_clamps_at_last() {
1238 let tmp = temp_dir_with_files();
1239 let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1240 let last = explorer.entries.len() - 1;
1241 explorer.cursor = last;
1242 explorer.move_down();
1243 assert_eq!(explorer.cursor, last);
1244 }
1245
1246 #[test]
1247 fn handle_key_down_moves_cursor() {
1248 let tmp = temp_dir_with_files();
1249 let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1250 let before = explorer.cursor;
1251 explorer.handle_key(key(KeyCode::Down));
1252 assert_eq!(explorer.cursor, before + 1);
1253 }
1254
1255 #[test]
1256 fn handle_key_esc_dismisses() {
1257 let tmp = temp_dir_with_files();
1258 let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1259 assert_eq!(
1260 explorer.handle_key(key(KeyCode::Esc)),
1261 ExplorerOutcome::Dismissed
1262 );
1263 }
1264
1265 #[test]
1266 fn handle_key_enter_on_dir_descends() {
1267 let tmp = temp_dir_with_files();
1268 let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1269 let dir_idx = explorer
1271 .entries
1272 .iter()
1273 .position(|e| e.is_dir)
1274 .expect("no dir in fixture");
1275 explorer.cursor = dir_idx;
1276 let expected_path = explorer.entries[dir_idx].path.clone();
1277 let outcome = explorer.handle_key(key(KeyCode::Enter));
1278 assert_eq!(outcome, ExplorerOutcome::Pending);
1279 assert_eq!(explorer.current_dir, expected_path);
1280 }
1281
1282 #[test]
1283 fn handle_key_enter_on_valid_file_selects() {
1284 let tmp = temp_dir_with_files();
1285 let mut explorer =
1286 FileExplorer::new(tmp.path().to_path_buf(), vec!["iso".into(), "img".into()]);
1287 let file_idx = explorer
1288 .entries
1289 .iter()
1290 .position(|e| !e.is_dir)
1291 .expect("no file in fixture");
1292 explorer.cursor = file_idx;
1293 let expected = explorer.entries[file_idx].path.clone();
1294 let outcome = explorer.handle_key(key(KeyCode::Enter));
1295 assert_eq!(outcome, ExplorerOutcome::Selected(expected));
1296 }
1297
1298 #[test]
1299 fn handle_key_backspace_ascends() {
1300 let tmp = temp_dir_with_files();
1301 let subdir = tmp.path().join("subdir");
1302 let mut explorer = FileExplorer::new(subdir, vec![]);
1303 explorer.handle_key(key(KeyCode::Backspace));
1304 assert_eq!(explorer.current_dir, tmp.path());
1305 }
1306
1307 #[test]
1308 fn toggle_hidden_changes_visibility() {
1309 let tmp = temp_dir_with_files();
1310 fs::write(tmp.path().join(".hidden_file"), b"").unwrap();
1311 let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1312 assert!(!explorer.entries.iter().any(|e| e.name == ".hidden_file"));
1313 explorer.set_show_hidden(true);
1314 assert!(explorer.entries.iter().any(|e| e.name == ".hidden_file"));
1315 }
1316
1317 #[test]
1318 fn fmt_size_formats_bytes() {
1319 assert_eq!(fmt_size(512), "512 B");
1320 assert_eq!(fmt_size(1_536), "1.5 KB");
1321 assert_eq!(fmt_size(2_097_152), "2.0 MB");
1322 assert_eq!(fmt_size(1_073_741_824), "1.0 GB");
1323 }
1324
1325 #[test]
1326 fn extension_filter_only_shows_matching_files() {
1327 let tmp = temp_dir_with_files();
1330 let explorer = FileExplorer::new(tmp.path().to_path_buf(), vec!["iso".into()]);
1331
1332 assert!(
1334 explorer.entries.iter().any(|e| e.name == "ubuntu.iso"),
1335 "iso file should appear in entries"
1336 );
1337 assert!(
1339 !explorer.entries.iter().any(|e| e.name == "debian.img"),
1340 "img file should be excluded by filter"
1341 );
1342 assert!(
1344 explorer.entries.iter().any(|e| e.is_dir),
1345 "directories should always be visible"
1346 );
1347 assert!(
1349 explorer
1350 .entries
1351 .iter()
1352 .filter(|e| !e.is_dir)
1353 .all(|e| e.extension == "iso"),
1354 "all visible files must match the active filter"
1355 );
1356 }
1357
1358 #[test]
1359 fn navigate_to_resets_cursor_and_scroll() {
1360 let tmp = temp_dir_with_files();
1361 let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1362 explorer.cursor = 2;
1363 explorer.scroll_offset = 1;
1364 explorer.navigate_to(tmp.path().to_path_buf());
1365 assert_eq!(explorer.cursor, 0);
1366 assert_eq!(explorer.scroll_offset, 0);
1367 }
1368
1369 #[test]
1370 fn current_entry_returns_highlighted() {
1371 let tmp = temp_dir_with_files();
1372 let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1373 explorer.cursor = 0;
1374 let entry = explorer.current_entry().expect("should have entry");
1375 assert_eq!(entry, explorer.entries.first().unwrap());
1376 }
1377
1378 #[test]
1379 fn unrecognised_key_returns_unhandled() {
1380 let tmp = temp_dir_with_files();
1381 let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1382 assert_eq!(
1383 explorer.handle_key(key(KeyCode::F(5))),
1384 ExplorerOutcome::Unhandled
1385 );
1386 }
1387
1388 #[test]
1391 fn slash_activates_search_mode() {
1392 let tmp = temp_dir_with_files();
1393 let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1394 assert!(!explorer.search_active);
1395 explorer.handle_key(key(KeyCode::Char('/')));
1396 assert!(explorer.search_active);
1397 assert_eq!(explorer.search_query(), "");
1398 }
1399
1400 #[test]
1401 fn search_active_chars_append_to_query() {
1402 let tmp = temp_dir_with_files();
1403 let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1404 explorer.handle_key(key(KeyCode::Char('/')));
1405 explorer.handle_key(key(KeyCode::Char('u')));
1406 explorer.handle_key(key(KeyCode::Char('b')));
1407 explorer.handle_key(key(KeyCode::Char('u')));
1408 assert_eq!(explorer.search_query(), "ubu");
1409 assert!(explorer.search_active);
1410 }
1411
1412 #[test]
1413 fn search_filters_entries_by_name() {
1414 let tmp = temp_dir_with_files();
1415 let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1416 explorer.handle_key(key(KeyCode::Char('/')));
1418 for c in "ubu".chars() {
1419 explorer.handle_key(key(KeyCode::Char(c)));
1420 }
1421 assert_eq!(explorer.entries.len(), 1);
1423 assert_eq!(explorer.entries[0].name, "ubuntu.iso");
1424 }
1425
1426 #[test]
1427 fn search_backspace_pops_last_char() {
1428 let tmp = temp_dir_with_files();
1429 let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1430 explorer.handle_key(key(KeyCode::Char('/')));
1431 explorer.handle_key(key(KeyCode::Char('u')));
1432 explorer.handle_key(key(KeyCode::Char('b')));
1433 explorer.handle_key(key(KeyCode::Backspace));
1434 assert_eq!(explorer.search_query(), "u");
1435 assert!(explorer.search_active);
1436 }
1437
1438 #[test]
1439 fn search_backspace_on_empty_deactivates() {
1440 let tmp = temp_dir_with_files();
1441 let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1442 explorer.handle_key(key(KeyCode::Char('/')));
1443 assert!(explorer.search_active);
1444 explorer.handle_key(key(KeyCode::Backspace));
1446 assert!(!explorer.search_active);
1447 assert_eq!(explorer.search_query(), "");
1448 }
1449
1450 #[test]
1451 fn search_esc_clears_and_deactivates_returns_pending() {
1452 let tmp = temp_dir_with_files();
1453 let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1454 explorer.handle_key(key(KeyCode::Char('/')));
1455 explorer.handle_key(key(KeyCode::Char('u')));
1456 let outcome = explorer.handle_key(key(KeyCode::Esc));
1457 assert_eq!(
1458 outcome,
1459 ExplorerOutcome::Pending,
1460 "Esc should clear search, not dismiss"
1461 );
1462 assert!(!explorer.search_active);
1463 assert_eq!(explorer.search_query(), "");
1464 }
1465
1466 #[test]
1467 fn esc_when_not_searching_dismisses() {
1468 let tmp = temp_dir_with_files();
1469 let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1470 assert!(!explorer.search_active);
1471 assert_eq!(
1472 explorer.handle_key(key(KeyCode::Esc)),
1473 ExplorerOutcome::Dismissed
1474 );
1475 }
1476
1477 #[test]
1478 fn search_clears_on_directory_descend() {
1479 let tmp = temp_dir_with_files();
1480 let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1481 explorer.search_active = true;
1482 explorer.search_query = "sub".into();
1483 explorer.cursor = explorer.entries.iter().position(|e| e.is_dir).unwrap();
1485 explorer.handle_key(key(KeyCode::Enter));
1486 assert!(!explorer.search_active);
1487 assert_eq!(explorer.search_query(), "");
1488 }
1489
1490 #[test]
1491 fn search_clears_on_ascend() {
1492 let tmp = temp_dir_with_files();
1493 let subdir = tmp.path().join("subdir");
1494 let mut explorer = FileExplorer::new(subdir, vec![]);
1495
1496 explorer.search_active = true;
1507 explorer.search_query = "foo".into();
1508
1509 explorer.ascend();
1511
1512 assert!(
1513 !explorer.search_active,
1514 "search must be deactivated after ascend"
1515 );
1516 assert_eq!(
1517 explorer.search_query(),
1518 "",
1519 "query must be cleared after ascend"
1520 );
1521 assert_eq!(
1522 explorer.current_dir,
1523 tmp.path(),
1524 "must have ascended to parent"
1525 );
1526 }
1527
1528 #[test]
1529 fn backspace_in_search_pops_char_not_ascend() {
1530 let tmp = temp_dir_with_files();
1533 let subdir = tmp.path().join("subdir");
1534 let mut explorer = FileExplorer::new(subdir.clone(), vec![]);
1535 explorer.search_active = true;
1536 explorer.search_query = "foo".into();
1537
1538 explorer.handle_key(key(KeyCode::Backspace)); assert_eq!(explorer.current_dir, subdir, "must NOT have ascended");
1541 assert_eq!(
1542 explorer.search_query(),
1543 "fo",
1544 "Backspace should pop last char"
1545 );
1546 assert!(explorer.search_active, "search must still be active");
1547 }
1548
1549 #[test]
1552 fn default_sort_mode_is_name() {
1553 let tmp = temp_dir_with_files();
1554 let explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1555 assert_eq!(explorer.sort_mode(), SortMode::Name);
1556 }
1557
1558 #[test]
1559 fn sort_mode_cycles_on_s_key() {
1560 let tmp = temp_dir_with_files();
1561 let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1562 assert_eq!(explorer.sort_mode(), SortMode::Name);
1563 explorer.handle_key(key(KeyCode::Char('s')));
1564 assert_eq!(explorer.sort_mode(), SortMode::SizeDesc);
1565 explorer.handle_key(key(KeyCode::Char('s')));
1566 assert_eq!(explorer.sort_mode(), SortMode::Extension);
1567 explorer.handle_key(key(KeyCode::Char('s')));
1568 assert_eq!(explorer.sort_mode(), SortMode::Name);
1569 }
1570
1571 #[test]
1572 fn sort_size_desc_orders_largest_first() {
1573 let tmp = tempfile::tempdir().expect("temp dir");
1574 fs::write(tmp.path().join("small.txt"), vec![0u8; 10]).unwrap();
1576 fs::write(tmp.path().join("large.txt"), vec![0u8; 10_000]).unwrap();
1577 fs::write(tmp.path().join("medium.txt"), vec![0u8; 1_000]).unwrap();
1578
1579 let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1580 explorer.set_sort_mode(SortMode::SizeDesc);
1581
1582 let sizes: Vec<u64> = explorer.entries.iter().filter_map(|e| e.size).collect();
1583 let mut sorted_desc = sizes.clone();
1584 sorted_desc.sort_by(|a, b| b.cmp(a));
1585 assert_eq!(sizes, sorted_desc, "files should be sorted largest-first");
1586 }
1587
1588 #[test]
1589 fn sort_extension_groups_by_ext() {
1590 let tmp = tempfile::tempdir().expect("temp dir");
1591 fs::write(tmp.path().join("b.toml"), b"").unwrap();
1592 fs::write(tmp.path().join("a.rs"), b"").unwrap();
1593 fs::write(tmp.path().join("c.toml"), b"").unwrap();
1594 fs::write(tmp.path().join("z.rs"), b"").unwrap();
1595
1596 let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1597 explorer.set_sort_mode(SortMode::Extension);
1598
1599 let exts: Vec<&str> = explorer
1600 .entries
1601 .iter()
1602 .filter(|e| !e.is_dir)
1603 .map(|e| e.extension.as_str())
1604 .collect();
1605
1606 let rs_last = exts.iter().rposition(|&e| e == "rs").unwrap_or(0);
1608 let toml_first = exts.iter().position(|&e| e == "toml").unwrap_or(usize::MAX);
1609 assert!(rs_last < toml_first, "rs group must precede toml group");
1610 }
1611
1612 #[test]
1613 fn builder_sort_mode_applied() {
1614 let tmp = temp_dir_with_files();
1615 let explorer = FileExplorer::builder(tmp.path().to_path_buf())
1616 .sort_mode(SortMode::SizeDesc)
1617 .build();
1618 assert_eq!(explorer.sort_mode(), SortMode::SizeDesc);
1619 }
1620
1621 #[test]
1622 fn set_sort_mode_reloads() {
1623 let tmp = temp_dir_with_files();
1624 let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1625 explorer.set_sort_mode(SortMode::Extension);
1626 assert_eq!(explorer.sort_mode(), SortMode::Extension);
1627 assert!(!explorer.entries.is_empty());
1629 }
1630
1631 #[test]
1634 fn j_key_moves_cursor_down() {
1635 let tmp = temp_dir_with_files();
1636 let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1637 let before = explorer.cursor;
1638 explorer.handle_key(key(KeyCode::Char('j')));
1639 assert_eq!(explorer.cursor, before + 1);
1640 }
1641
1642 #[test]
1643 fn k_key_moves_cursor_up() {
1644 let tmp = temp_dir_with_files();
1645 let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1646 explorer.cursor = 2;
1647 explorer.handle_key(key(KeyCode::Char('k')));
1648 assert_eq!(explorer.cursor, 1);
1649 }
1650
1651 #[test]
1652 fn h_key_ascends_to_parent() {
1653 let tmp = temp_dir_with_files();
1654 let subdir = tmp.path().join("subdir");
1655 let mut explorer = FileExplorer::new(subdir, vec![]);
1656 explorer.handle_key(key(KeyCode::Char('h')));
1657 assert_eq!(explorer.current_dir, tmp.path());
1658 }
1659
1660 #[test]
1661 fn l_key_descends_into_dir() {
1662 let tmp = temp_dir_with_files();
1663 let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1664 let dir_idx = explorer.entries.iter().position(|e| e.is_dir).unwrap();
1665 explorer.cursor = dir_idx;
1666 let expected = explorer.entries[dir_idx].path.clone();
1667 let outcome = explorer.handle_key(key(KeyCode::Char('l')));
1668 assert_eq!(outcome, ExplorerOutcome::Pending);
1669 assert_eq!(explorer.current_dir, expected);
1670 }
1671
1672 #[test]
1673 fn right_arrow_descends_into_dir() {
1674 let tmp = temp_dir_with_files();
1675 let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1676 let dir_idx = explorer.entries.iter().position(|e| e.is_dir).unwrap();
1677 explorer.cursor = dir_idx;
1678 let expected = explorer.entries[dir_idx].path.clone();
1679 let outcome = explorer.handle_key(key(KeyCode::Right));
1680 assert_eq!(
1681 outcome,
1682 ExplorerOutcome::Pending,
1683 "Right arrow should descend into directory"
1684 );
1685 assert_eq!(
1686 explorer.current_dir, expected,
1687 "Right arrow should change into the selected directory"
1688 );
1689 }
1690
1691 #[test]
1692 fn right_arrow_on_file_moves_down_not_exits() {
1693 let tmp = temp_dir_with_files();
1694 let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1695 let file_idx = explorer.entries.iter().position(|e| !e.is_dir).unwrap();
1697 assert!(
1699 file_idx + 1 < explorer.entries.len(),
1700 "fixture must have an entry after the first file"
1701 );
1702 explorer.cursor = file_idx;
1703 let original_dir = explorer.current_dir.clone();
1704 let outcome = explorer.handle_key(key(KeyCode::Right));
1705 assert_eq!(
1706 outcome,
1707 ExplorerOutcome::Pending,
1708 "Right arrow on a file must never exit (always Pending)"
1709 );
1710 assert_eq!(
1711 explorer.current_dir, original_dir,
1712 "Right arrow on a file must not change directory"
1713 );
1714 assert_eq!(
1715 explorer.cursor,
1716 file_idx + 1,
1717 "Right arrow on a file must advance the cursor by one"
1718 );
1719 }
1720
1721 #[test]
1722 fn right_arrow_on_file_at_last_entry_does_not_overflow() {
1723 let tmp = temp_dir_with_files();
1724 let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1725 let last = explorer.entries.len() - 1;
1726 explorer.cursor = last;
1728 explorer.handle_key(key(KeyCode::Right));
1729 assert_eq!(
1730 explorer.cursor, last,
1731 "Right arrow at the last entry must not overflow past it"
1732 );
1733 }
1734
1735 #[test]
1736 fn enter_on_file_still_confirms_and_exits() {
1737 let tmp = temp_dir_with_files();
1738 let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1739 let file_idx = explorer.entries.iter().position(|e| !e.is_dir).unwrap();
1740 explorer.cursor = file_idx;
1741 let expected = explorer.entries[file_idx].path.clone();
1742 let outcome = explorer.handle_key(key(KeyCode::Enter));
1743 assert_eq!(
1744 outcome,
1745 ExplorerOutcome::Selected(expected),
1746 "Enter on a file should confirm (select) it and exit"
1747 );
1748 }
1749
1750 #[test]
1751 fn left_arrow_ascends_to_parent() {
1752 let tmp = temp_dir_with_files();
1753 let subdir = tmp.path().join("subdir");
1754 let mut explorer = FileExplorer::new(subdir, vec![]);
1755 let outcome = explorer.handle_key(key(KeyCode::Left));
1756 assert_eq!(
1757 outcome,
1758 ExplorerOutcome::Pending,
1759 "Left arrow should return Pending after ascending"
1760 );
1761 assert_eq!(
1762 explorer.current_dir,
1763 tmp.path(),
1764 "Left arrow should ascend to the parent directory"
1765 );
1766 }
1767
1768 #[test]
1769 fn right_arrow_clears_search_on_dir_descend() {
1770 let tmp = temp_dir_with_files();
1771 let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1772 explorer.search_active = true;
1774 explorer.search_query = "sub".to_string();
1775 explorer.reload();
1776 let dir_idx = explorer
1778 .entries
1779 .iter()
1780 .position(|e| e.is_dir)
1781 .expect("fixture subdir must match 'sub'");
1782 explorer.cursor = dir_idx;
1783 explorer.handle_key(key(KeyCode::Right));
1784 assert!(
1785 !explorer.search_active,
1786 "navigate() must deactivate search on directory descend"
1787 );
1788 assert!(
1789 explorer.search_query.is_empty(),
1790 "navigate() must clear search query on directory descend"
1791 );
1792 }
1793
1794 #[test]
1795 fn right_arrow_clears_marks_on_dir_descend() {
1796 let tmp = temp_dir_with_files();
1797 let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1798 let dir_idx = explorer
1799 .entries
1800 .iter()
1801 .position(|e| e.is_dir)
1802 .expect("fixture has a subdir");
1803 explorer.toggle_mark();
1805 assert!(
1806 !explorer.marked.is_empty(),
1807 "should have a mark before descend"
1808 );
1809 explorer.cursor = explorer
1811 .entries
1812 .iter()
1813 .position(|e| e.is_dir)
1814 .expect("fixture has a subdir");
1815 explorer.handle_key(key(KeyCode::Right));
1816 assert!(
1817 explorer.marked.is_empty(),
1818 "navigate() must clear marks on directory descend"
1819 );
1820 let _ = dir_idx;
1821 }
1822
1823 #[test]
1824 fn backspace_still_ascends() {
1825 let tmp = temp_dir_with_files();
1826 let subdir = tmp.path().join("subdir");
1827 let mut explorer = FileExplorer::new(subdir, vec![]);
1828 explorer.handle_key(key(KeyCode::Backspace));
1829 assert_eq!(explorer.current_dir, tmp.path());
1830 }
1831
1832 #[test]
1833 fn q_key_dismisses() {
1834 let tmp = temp_dir_with_files();
1835 let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1836 assert_eq!(
1837 explorer.handle_key(key(KeyCode::Char('q'))),
1838 ExplorerOutcome::Dismissed
1839 );
1840 }
1841
1842 #[test]
1845 fn page_down_advances_cursor_by_ten() {
1846 let tmp = tempfile::tempdir().unwrap();
1847 for i in 0..15 {
1848 fs::write(tmp.path().join(format!("file{i:02}.txt")), b"").unwrap();
1849 }
1850 let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1851 explorer.cursor = 0;
1852 explorer.handle_key(key(KeyCode::PageDown));
1853 assert_eq!(explorer.cursor, 10);
1854 }
1855
1856 #[test]
1857 fn page_up_retreats_cursor_by_ten() {
1858 let tmp = tempfile::tempdir().unwrap();
1859 for i in 0..15 {
1860 fs::write(tmp.path().join(format!("file{i:02}.txt")), b"").unwrap();
1861 }
1862 let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1863 explorer.cursor = 12;
1864 explorer.handle_key(key(KeyCode::PageUp));
1865 assert_eq!(explorer.cursor, 2);
1866 }
1867
1868 #[test]
1869 fn home_key_jumps_to_top() {
1870 let tmp = temp_dir_with_files();
1871 let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1872 explorer.cursor = explorer.entries.len() - 1;
1873 explorer.handle_key(key(KeyCode::Home));
1874 assert_eq!(explorer.cursor, 0);
1875 assert_eq!(explorer.scroll_offset, 0);
1876 }
1877
1878 #[test]
1879 fn g_key_jumps_to_top() {
1880 let tmp = temp_dir_with_files();
1881 let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1882 explorer.cursor = explorer.entries.len() - 1;
1883 explorer.handle_key(key(KeyCode::Char('g')));
1884 assert_eq!(explorer.cursor, 0);
1885 assert_eq!(explorer.scroll_offset, 0);
1886 }
1887
1888 #[test]
1889 fn end_key_jumps_to_bottom() {
1890 let tmp = temp_dir_with_files();
1891 let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1892 explorer.cursor = 0;
1893 explorer.handle_key(key(KeyCode::End));
1894 assert_eq!(explorer.cursor, explorer.entries.len() - 1);
1895 }
1896
1897 #[test]
1898 fn capital_g_key_jumps_to_bottom() {
1899 let tmp = temp_dir_with_files();
1900 let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1901 explorer.cursor = 0;
1902 let key_g = KeyEvent::new(KeyCode::Char('G'), KeyModifiers::NONE);
1903 explorer.handle_key(key_g);
1904 assert_eq!(explorer.cursor, explorer.entries.len() - 1);
1905 }
1906
1907 #[test]
1910 fn ascend_at_root_sets_status() {
1911 let root = std::path::PathBuf::from("/");
1913 let mut explorer = FileExplorer::new(root.clone(), vec![]);
1914 assert!(explorer.is_at_root());
1915 explorer.handle_key(key(KeyCode::Backspace));
1917 assert_eq!(explorer.current_dir, root);
1918 assert!(
1919 !explorer.status().is_empty(),
1920 "status should report already at root"
1921 );
1922 }
1923
1924 #[test]
1925 fn is_at_root_false_for_subdir() {
1926 let tmp = temp_dir_with_files();
1927 let explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1928 assert!(!explorer.is_at_root());
1929 }
1930
1931 #[test]
1934 fn is_empty_reflects_visible_entries() {
1935 let empty_dir = tempfile::tempdir().unwrap();
1936 let explorer = FileExplorer::new(empty_dir.path().to_path_buf(), vec![]);
1937 assert!(explorer.is_empty());
1938
1939 let tmp = temp_dir_with_files();
1940 let explorer2 = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1941 assert!(!explorer2.is_empty());
1942 }
1943
1944 #[test]
1945 fn entry_count_matches_entries_len() {
1946 let tmp = temp_dir_with_files();
1947 let explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1948 assert_eq!(explorer.entry_count(), explorer.entries.len());
1949 assert!(explorer.entry_count() > 0);
1950 }
1951
1952 #[test]
1953 fn search_query_empty_when_not_searching() {
1954 let tmp = temp_dir_with_files();
1955 let explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1956 assert!(!explorer.is_searching());
1957 assert_eq!(explorer.search_query(), "");
1958 }
1959
1960 #[test]
1963 fn search_is_case_insensitive() {
1964 let tmp = temp_dir_with_files();
1965 let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1966 explorer.handle_key(key(KeyCode::Char('/')));
1968 for c in "UBU".chars() {
1969 explorer.handle_key(key(KeyCode::Char(c)));
1970 }
1971 assert_eq!(explorer.entries.len(), 1);
1972 assert_eq!(explorer.entries[0].name, "ubuntu.iso");
1973 }
1974
1975 #[test]
1976 fn extension_filter_is_case_insensitive() {
1977 let tmp = tempfile::tempdir().unwrap();
1978 fs::write(tmp.path().join("disk.ISO"), b"data").unwrap();
1980 fs::write(tmp.path().join("other.txt"), b"text").unwrap();
1981
1982 let explorer = FileExplorer::new(tmp.path().to_path_buf(), vec!["iso".into()]);
1984 assert!(
1985 explorer.entries.iter().any(|e| e.name == "disk.ISO"),
1986 "upper-case extension should be matched by lower-case filter"
1987 );
1988 assert!(
1989 !explorer.entries.iter().any(|e| e.name == "other.txt"),
1990 "non-matching extension should be excluded"
1991 );
1992 }
1993
1994 #[test]
1997 fn builder_allow_extension_filters_entries() {
1998 let tmp = temp_dir_with_files();
1999 let explorer = FileExplorer::builder(tmp.path().to_path_buf())
2000 .allow_extension("iso")
2001 .build();
2002 assert!(explorer.entries.iter().any(|e| e.name == "ubuntu.iso"));
2003 assert!(!explorer.entries.iter().any(|e| e.name == "debian.img"));
2004 assert!(!explorer.entries.iter().any(|e| e.name == "readme.txt"));
2005 }
2006
2007 #[test]
2008 fn builder_show_hidden_shows_dotfiles() {
2009 let tmp = temp_dir_with_files();
2010 fs::write(tmp.path().join(".dotfile"), b"").unwrap();
2011
2012 let hidden_explorer = FileExplorer::builder(tmp.path().to_path_buf())
2013 .show_hidden(true)
2014 .build();
2015 assert!(hidden_explorer.entries.iter().any(|e| e.name == ".dotfile"));
2016
2017 let normal_explorer = FileExplorer::builder(tmp.path().to_path_buf())
2018 .show_hidden(false)
2019 .build();
2020 assert!(!normal_explorer.entries.iter().any(|e| e.name == ".dotfile"));
2021 }
2022
2023 #[test]
2024 fn set_extension_filter_updates_entries() {
2025 let tmp = temp_dir_with_files();
2026 let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
2027 assert!(explorer.entries.iter().any(|e| e.name == "readme.txt"));
2029
2030 explorer.set_extension_filter(["iso"]);
2031 assert!(explorer.entries.iter().any(|e| e.name == "ubuntu.iso"));
2032 assert!(!explorer.entries.iter().any(|e| e.name == "readme.txt"));
2033 }
2034
2035 #[test]
2038 fn entry_icon_directory() {
2039 let entry = FsEntry {
2040 name: "mydir".into(),
2041 path: std::path::PathBuf::from("/mydir"),
2042 is_dir: true,
2043 size: None,
2044 extension: String::new(),
2045 };
2046 assert_eq!(entry_icon(&entry), "📁");
2047 }
2048
2049 #[test]
2050 fn entry_icon_recognises_known_extensions() {
2051 let make = |name: &str, ext: &str| FsEntry {
2052 name: name.into(),
2053 path: std::path::PathBuf::from(name),
2054 is_dir: false,
2055 size: Some(0),
2056 extension: ext.into(),
2057 };
2058
2059 assert_eq!(entry_icon(&make("archive.zip", "zip")), "📦");
2060 assert_eq!(entry_icon(&make("doc.pdf", "pdf")), "📕");
2061 assert_eq!(entry_icon(&make("notes.md", "md")), "📝");
2062 assert_eq!(entry_icon(&make("config.toml", "toml")), "⚙ ");
2063 assert_eq!(entry_icon(&make("main.rs", "rs")), "🦀");
2064 assert_eq!(entry_icon(&make("script.py", "py")), "🐍");
2065 assert_eq!(entry_icon(&make("page.html", "html")), "🌐");
2066 assert_eq!(entry_icon(&make("image.png", "png")), "🖼 ");
2067 assert_eq!(entry_icon(&make("video.mp4", "mp4")), "🎬");
2068 assert_eq!(entry_icon(&make("song.mp3", "mp3")), "🎵");
2069 assert_eq!(entry_icon(&make("unknown.xyz", "xyz")), "📄");
2070 }
2071
2072 #[test]
2075 fn fmt_size_exact_boundaries() {
2076 assert_eq!(fmt_size(1_024), "1.0 KB");
2078 assert_eq!(fmt_size(1_048_576), "1.0 MB");
2079 assert_eq!(fmt_size(1_073_741_824), "1.0 GB");
2080 assert_eq!(fmt_size(1_023), "1023 B");
2082 assert_eq!(fmt_size(1_047_552), "1023.0 KB"); }
2084
2085 #[test]
2088 fn toggle_mark_adds_entry_to_marked_set() {
2089 let dir = temp_dir_with_files();
2090 let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
2091 assert!(!explorer.entries.is_empty(), "need at least one entry");
2092
2093 explorer.toggle_mark();
2094
2095 assert_eq!(explorer.marked.len(), 1, "one entry should be marked");
2096 }
2097
2098 #[test]
2099 fn toggle_mark_removes_already_marked_entry() {
2100 let dir = temp_dir_with_files();
2101 let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
2102
2103 explorer.toggle_mark(); let cursor_after_first = explorer.cursor;
2105 explorer.cursor = 0; explorer.toggle_mark(); assert!(
2109 explorer.marked.is_empty(),
2110 "second toggle on same entry should unmark it"
2111 );
2112 let _ = cursor_after_first; }
2114
2115 #[test]
2116 fn toggle_mark_advances_cursor_down() {
2117 let dir = temp_dir_with_files();
2118 let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
2119 assert!(
2121 explorer.entries.len() >= 2,
2122 "fixture must have at least 2 entries"
2123 );
2124
2125 let before = explorer.cursor;
2126 explorer.toggle_mark();
2127
2128 assert_eq!(
2129 explorer.cursor,
2130 before + 1,
2131 "cursor should advance by one after toggle_mark"
2132 );
2133 }
2134
2135 #[test]
2136 fn toggle_mark_at_last_entry_does_not_overflow() {
2137 let dir = temp_dir_with_files();
2138 let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
2139 explorer.cursor = explorer.entries.len() - 1;
2140
2141 explorer.toggle_mark();
2142
2143 assert_eq!(
2144 explorer.cursor,
2145 explorer.entries.len() - 1,
2146 "cursor should stay at the last entry, not overflow"
2147 );
2148 }
2149
2150 #[test]
2151 fn clear_marks_empties_marked_set() {
2152 let dir = temp_dir_with_files();
2153 let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
2154
2155 explorer.toggle_mark();
2156 assert!(
2157 !explorer.marked.is_empty(),
2158 "should have a mark before clear"
2159 );
2160
2161 explorer.clear_marks();
2162
2163 assert!(
2164 explorer.marked.is_empty(),
2165 "marked set should be empty after clear_marks"
2166 );
2167 }
2168
2169 #[test]
2170 fn space_key_marks_current_entry() {
2171 let dir = temp_dir_with_files();
2172 let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
2173 assert!(!explorer.entries.is_empty(), "need at least one entry");
2174
2175 let outcome = explorer.handle_key(key(KeyCode::Char(' ')));
2176
2177 assert_eq!(
2178 outcome,
2179 ExplorerOutcome::Pending,
2180 "Space should return Pending"
2181 );
2182 assert_eq!(
2183 explorer.marked.len(),
2184 1,
2185 "Space should mark the current entry"
2186 );
2187 }
2188
2189 #[test]
2190 fn space_key_toggles_mark_off() {
2191 let dir = temp_dir_with_files();
2192 let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
2193
2194 explorer.handle_key(key(KeyCode::Char(' '))); explorer.cursor = 0; explorer.handle_key(key(KeyCode::Char(' '))); assert!(
2199 explorer.marked.is_empty(),
2200 "second Space on same entry should unmark it"
2201 );
2202 }
2203
2204 #[test]
2205 fn marks_cleared_when_ascending_to_parent() {
2206 let dir = temp_dir_with_files();
2207 let sub = dir.path().join("subdir");
2209 fs::write(sub.join("inner.txt"), b"inner").unwrap();
2210 let mut explorer = FileExplorer::new(sub.clone(), vec![]);
2211
2212 explorer.toggle_mark();
2213 assert!(
2214 !explorer.marked.is_empty(),
2215 "should have a mark before ascend"
2216 );
2217
2218 explorer.handle_key(key(KeyCode::Backspace));
2220
2221 assert!(
2222 explorer.marked.is_empty(),
2223 "marks should be cleared after ascending to parent"
2224 );
2225 }
2226
2227 #[test]
2228 fn marks_cleared_when_descending_into_directory() {
2229 let dir = temp_dir_with_files();
2230 let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
2231
2232 let sub_idx = explorer
2234 .entries
2235 .iter()
2236 .position(|e| e.is_dir)
2237 .expect("fixture has a subdir");
2238 explorer.cursor = sub_idx;
2239 explorer.toggle_mark();
2240 assert!(
2241 !explorer.marked.is_empty(),
2242 "should have a mark before descend"
2243 );
2244
2245 explorer.cursor = explorer
2247 .entries
2248 .iter()
2249 .position(|e| e.is_dir)
2250 .expect("fixture has a subdir");
2251
2252 explorer.handle_key(key(KeyCode::Enter));
2254
2255 assert!(
2256 explorer.marked.is_empty(),
2257 "marks should be cleared after descending into a directory"
2258 );
2259 }
2260
2261 #[test]
2262 fn can_mark_multiple_entries() {
2263 let dir = temp_dir_with_files();
2264 let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
2265 let total = explorer.entries.len();
2266 assert!(total >= 2, "fixture must have at least 2 entries");
2267
2268 for _ in 0..total {
2270 explorer.toggle_mark();
2271 }
2272
2273 assert_eq!(explorer.marked.len(), total, "all entries should be marked");
2274 }
2275
2276 #[test]
2279 fn move_up_at_top_does_not_underflow() {
2280 let dir = tempdir().expect("tempdir");
2281 fs::write(dir.path().join("a.txt"), b"a").unwrap();
2282 let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
2283 explorer.cursor = 0;
2284 explorer.handle_key(key(KeyCode::Up));
2286 assert_eq!(explorer.cursor, 0);
2287 }
2288
2289 #[test]
2290 fn move_down_at_bottom_does_not_overflow() {
2291 let dir = tempdir().expect("tempdir");
2292 fs::write(dir.path().join("a.txt"), b"a").unwrap();
2293 let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
2294 let last = explorer.entries.len().saturating_sub(1);
2295 explorer.cursor = last;
2296 explorer.handle_key(key(KeyCode::Down));
2297 assert_eq!(explorer.cursor, last);
2298 }
2299
2300 #[test]
2301 fn move_down_on_empty_dir_does_not_panic() {
2302 let dir = tempdir().expect("tempdir");
2303 let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
2304 assert!(explorer.entries.is_empty());
2305 explorer.handle_key(key(KeyCode::Down));
2307 assert_eq!(explorer.cursor, 0);
2308 }
2309
2310 #[test]
2311 fn move_up_on_empty_dir_does_not_panic() {
2312 let dir = tempdir().expect("tempdir");
2313 let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
2314 assert!(explorer.entries.is_empty());
2315 explorer.handle_key(key(KeyCode::Up));
2316 assert_eq!(explorer.cursor, 0);
2317 }
2318
2319 #[test]
2320 fn page_down_at_bottom_does_not_overflow() {
2321 let dir = tempdir().expect("tempdir");
2322 for i in 0..5 {
2323 fs::write(dir.path().join(format!("{i}.txt")), b"x").unwrap();
2324 }
2325 let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
2326 let last = explorer.entries.len().saturating_sub(1);
2327 explorer.cursor = last;
2328 explorer.handle_key(key(KeyCode::PageDown));
2329 assert_eq!(explorer.cursor, last);
2330 }
2331
2332 #[test]
2333 fn page_up_at_top_does_not_underflow() {
2334 let dir = tempdir().expect("tempdir");
2335 fs::write(dir.path().join("a.txt"), b"a").unwrap();
2336 let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
2337 explorer.cursor = 0;
2338 explorer.handle_key(key(KeyCode::PageUp));
2339 assert_eq!(explorer.cursor, 0);
2340 }
2341
2342 #[test]
2343 fn ascend_at_root_does_not_panic() {
2344 let mut explorer = FileExplorer::new(std::path::PathBuf::from("/"), vec![]);
2345 explorer.handle_key(key(KeyCode::Backspace));
2347 assert_eq!(explorer.current_dir, std::path::PathBuf::from("/"));
2348 }
2349
2350 #[test]
2351 fn cursor_clamped_after_reload_with_fewer_entries() {
2352 let dir = tempdir().expect("tempdir");
2353 fs::write(dir.path().join("a.txt"), b"a").unwrap();
2354 fs::write(dir.path().join("b.txt"), b"b").unwrap();
2355 fs::write(dir.path().join("c.txt"), b"c").unwrap();
2356 let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
2357 explorer.cursor = explorer.entries.len() - 1;
2359 explorer.set_extension_filter(["a"]);
2361 assert!(
2363 explorer.cursor < explorer.entries.len().max(1),
2364 "cursor {} out of range for {} entries",
2365 explorer.cursor,
2366 explorer.entries.len()
2367 );
2368 }
2369
2370 #[test]
2371 fn scroll_offset_clamped_after_reload_with_empty_entries() {
2372 let dir = tempdir().expect("tempdir");
2373 fs::write(dir.path().join("test.rs"), b"fn main(){}").unwrap();
2374 let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
2375 explorer.scroll_offset = 5; explorer.cursor = 0;
2377 explorer.set_extension_filter(["xyz"]);
2379 assert_eq!(explorer.cursor, 0);
2380 assert_eq!(explorer.scroll_offset, 0);
2381 }
2382
2383 #[test]
2384 fn marked_paths_returns_reference_to_marked_set() {
2385 let dir = temp_dir_with_files();
2386 let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
2387
2388 explorer.toggle_mark();
2389
2390 assert_eq!(
2391 explorer.marked_paths().len(),
2392 explorer.marked.len(),
2393 "marked_paths() should reflect the same set as the field"
2394 );
2395 }
2396
2397 fn make_file_entry(name: &str) -> crate::types::FsEntry {
2400 let ext = std::path::Path::new(name)
2401 .extension()
2402 .map(|e| e.to_string_lossy().to_lowercase())
2403 .unwrap_or_default();
2404 crate::types::FsEntry {
2405 name: name.to_string(),
2406 path: std::path::PathBuf::from(name),
2407 is_dir: false,
2408 size: None,
2409 extension: ext,
2410 }
2411 }
2412
2413 macro_rules! assert_entry_icon {
2414 ($( $test_name:ident : $filename:expr => $icon:expr ),+ $(,)?) => {
2415 $(
2416 #[test]
2417 fn $test_name() {
2418 let e = make_file_entry($filename);
2419 assert_eq!(entry_icon(&e), $icon);
2420 }
2421 )+
2422 };
2423 }
2424
2425 assert_entry_icon! {
2426 entry_icon_iso_returns_disc: "release.iso" => "💿",
2427 entry_icon_dmg_returns_disc: "app.dmg" => "💿",
2428 entry_icon_zip_returns_package: "archive.zip" => "📦",
2429 entry_icon_tar_returns_package: "src.tar" => "📦",
2430 entry_icon_gz_returns_package: "data.gz" => "📦",
2431 entry_icon_pdf_returns_book: "manual.pdf" => "📕",
2432 entry_icon_md_returns_memo: "README.md" => "📝",
2433 entry_icon_toml_returns_gear: "Cargo.toml" => "⚙ ",
2434 entry_icon_json_returns_gear: "config.json" => "⚙ ",
2435 entry_icon_lock_returns_lock: "Cargo.lock" => "🔒",
2436 entry_icon_py_returns_snake: "script.py" => "🐍",
2437 entry_icon_html_returns_globe: "index.html" => "🌐",
2438 entry_icon_css_returns_palette: "style.css" => "🎨",
2439 entry_icon_svg_returns_palette: "logo.svg" => "🎨",
2440 entry_icon_png_returns_image: "photo.png" => "🖼 ",
2441 entry_icon_jpg_returns_image: "photo.jpg" => "🖼 ",
2442 entry_icon_mp4_returns_film: "video.mp4" => "🎬",
2443 entry_icon_mp3_returns_music: "song.mp3" => "🎵",
2444 entry_icon_ttf_returns_font: "font.ttf" => "🔤",
2445 entry_icon_exe_returns_gear: "setup.exe" => "⚙ ",
2446 entry_icon_unknown_extension_returns_document: "mystery.xyz" => "📄",
2447 }
2448
2449 #[test]
2450 fn entry_icon_no_extension_returns_document() {
2451 let e = crate::types::FsEntry {
2452 name: "Makefile".into(),
2453 path: std::path::PathBuf::from("Makefile"),
2454 is_dir: false,
2455 size: None,
2456 extension: String::new(),
2457 };
2458 assert_eq!(entry_icon(&e), "📄");
2459 }
2460
2461 #[test]
2464 fn fmt_size_zero_bytes() {
2465 assert_eq!(fmt_size(0), "0 B");
2466 }
2467
2468 #[test]
2469 fn fmt_size_one_byte() {
2470 assert_eq!(fmt_size(1), "1 B");
2471 }
2472
2473 #[test]
2474 fn fmt_size_1023_bytes_stays_bytes() {
2475 assert_eq!(fmt_size(1_023), "1023 B");
2476 }
2477
2478 #[test]
2479 fn fmt_size_exactly_1_kb() {
2480 assert_eq!(fmt_size(1_024), "1.0 KB");
2481 }
2482
2483 #[test]
2484 fn fmt_size_1_5_kb() {
2485 assert_eq!(fmt_size(1_536), "1.5 KB");
2486 }
2487
2488 #[test]
2489 fn fmt_size_1_mb_boundary() {
2490 assert_eq!(fmt_size(1_048_576), "1.0 MB");
2491 }
2492
2493 #[test]
2494 fn fmt_size_2_mb() {
2495 assert_eq!(fmt_size(2_097_152), "2.0 MB");
2496 }
2497
2498 #[test]
2499 fn fmt_size_1_gb_boundary() {
2500 assert_eq!(fmt_size(1_073_741_824), "1.0 GB");
2501 }
2502
2503 #[test]
2504 fn fmt_size_large_value() {
2505 assert_eq!(fmt_size(10 * 1_073_741_824), "10.0 GB");
2507 }
2508
2509 #[test]
2512 fn navigate_to_accepts_str_slice() {
2513 let dir = tempdir().expect("tempdir");
2514 let sub = dir.path().join("sub");
2515 fs::create_dir(&sub).unwrap();
2516
2517 let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
2518 explorer.navigate_to(sub.to_str().unwrap());
2519 assert_eq!(explorer.current_dir, sub);
2520 }
2521
2522 #[test]
2523 fn navigate_to_accepts_path_ref() {
2524 let dir = tempdir().expect("tempdir");
2525 let sub = dir.path().join("sub2");
2526 fs::create_dir(&sub).unwrap();
2527
2528 let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
2529 explorer.navigate_to(sub.as_path());
2530 assert_eq!(explorer.current_dir, sub);
2531 }
2532
2533 #[test]
2534 fn navigate_to_resets_cursor_to_zero() {
2535 let dir = tempdir().expect("tempdir");
2536 let sub = dir.path().join("sub3");
2537 fs::create_dir(&sub).unwrap();
2538
2539 let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
2540 explorer.cursor = 99;
2541 explorer.scroll_offset = 5;
2542 explorer.navigate_to(sub.as_path());
2543 assert_eq!(explorer.cursor, 0);
2544 assert_eq!(explorer.scroll_offset, 0);
2545 }
2546
2547 #[test]
2550 fn is_searching_false_by_default() {
2551 let dir = tempdir().expect("tempdir");
2552 let explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
2553 assert!(!explorer.is_searching());
2554 }
2555
2556 #[test]
2557 fn is_searching_true_after_slash_key() {
2558 let dir = tempdir().expect("tempdir");
2559 let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
2560 explorer.handle_key(key(KeyCode::Char('/')));
2561 assert!(explorer.is_searching());
2562 }
2563
2564 #[test]
2565 fn is_searching_false_after_esc_cancels_search() {
2566 let dir = tempdir().expect("tempdir");
2567 let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
2568 explorer.handle_key(key(KeyCode::Char('/')));
2569 explorer.handle_key(key(KeyCode::Esc));
2570 assert!(!explorer.is_searching());
2571 }
2572
2573 #[test]
2576 fn status_is_empty_on_fresh_explorer() {
2577 let dir = tempdir().expect("tempdir");
2578 let explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
2579 assert!(explorer.status().is_empty());
2580 }
2581
2582 #[test]
2583 fn status_cleared_after_reload() {
2584 let dir = tempdir().expect("tempdir");
2585 let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
2586 explorer.status = "stale message".into();
2588 explorer.reload();
2589 assert!(
2590 explorer.status().is_empty(),
2591 "reload should clear the status message"
2592 );
2593 }
2594
2595 #[test]
2598 fn load_entries_empty_dir_returns_empty_vec() {
2599 let dir = tempdir().expect("tempdir");
2600 let entries = load_entries(dir.path(), false, &[], crate::types::SortMode::Name, "");
2601 assert!(
2602 entries.is_empty(),
2603 "empty directory should produce no entries"
2604 );
2605 }
2606
2607 #[test]
2608 fn load_entries_hidden_excluded_by_default() {
2609 let dir = tempdir().expect("tempdir");
2610 fs::write(dir.path().join(".hidden"), b"h").unwrap();
2611 fs::write(dir.path().join("visible.txt"), b"v").unwrap();
2612
2613 let entries = load_entries(dir.path(), false, &[], crate::types::SortMode::Name, "");
2614 assert_eq!(entries.len(), 1);
2615 assert_eq!(entries[0].name, "visible.txt");
2616 }
2617
2618 #[test]
2619 fn load_entries_hidden_included_when_show_hidden_true() {
2620 let dir = tempdir().expect("tempdir");
2621 fs::write(dir.path().join(".hidden"), b"h").unwrap();
2622 fs::write(dir.path().join("visible.txt"), b"v").unwrap();
2623
2624 let entries = load_entries(dir.path(), true, &[], crate::types::SortMode::Name, "");
2625 assert_eq!(entries.len(), 2);
2626 }
2627
2628 #[test]
2629 fn load_entries_nonexistent_dir_returns_empty_vec() {
2630 let entries = load_entries(
2631 std::path::Path::new("/nonexistent/path/that/does/not/exist"),
2632 false,
2633 &[],
2634 crate::types::SortMode::Name,
2635 "",
2636 );
2637 assert!(entries.is_empty());
2638 }
2639
2640 #[test]
2641 fn load_entries_search_query_is_case_insensitive() {
2642 let dir = tempdir().expect("tempdir");
2643 fs::write(dir.path().join("README.md"), b"r").unwrap();
2644 fs::write(dir.path().join("main.rs"), b"m").unwrap();
2645
2646 let entries = load_entries(
2647 dir.path(),
2648 false,
2649 &[],
2650 crate::types::SortMode::Name,
2651 "readme",
2652 );
2653 assert_eq!(entries.len(), 1);
2654 assert_eq!(entries[0].name, "README.md");
2655 }
2656
2657 #[test]
2658 fn load_entries_dirs_always_precede_files() {
2659 let dir = tempdir().expect("tempdir");
2660 fs::write(dir.path().join("z_file.txt"), b"z").unwrap();
2661 fs::create_dir(dir.path().join("a_dir")).unwrap();
2662
2663 let entries = load_entries(dir.path(), false, &[], crate::types::SortMode::Name, "");
2664 assert!(entries[0].is_dir, "directory must come before file");
2665 assert!(!entries[1].is_dir);
2666 }
2667
2668 #[test]
2669 fn load_entries_ext_filter_excludes_non_matching_files() {
2670 let dir = tempdir().expect("tempdir");
2671 fs::write(dir.path().join("main.rs"), b"r").unwrap();
2672 fs::write(dir.path().join("Cargo.toml"), b"t").unwrap();
2673
2674 let filter = vec!["rs".to_string()];
2675 let entries = load_entries(dir.path(), false, &filter, crate::types::SortMode::Name, "");
2676 assert_eq!(entries.len(), 1);
2677 assert_eq!(entries[0].extension, "rs");
2678 }
2679
2680 #[test]
2681 fn load_entries_ext_filter_always_includes_dirs() {
2682 let dir = tempdir().expect("tempdir");
2683 fs::create_dir(dir.path().join("subdir")).unwrap();
2684 fs::write(dir.path().join("file.txt"), b"t").unwrap();
2685
2686 let filter = vec!["rs".to_string()];
2688 let entries = load_entries(dir.path(), false, &filter, crate::types::SortMode::Name, "");
2689 assert_eq!(entries.len(), 1);
2690 assert!(entries[0].is_dir);
2691 }
2692
2693 #[test]
2696 fn r_key_activates_rename_mode_with_prefilled_name() {
2697 let tmp = temp_dir_with_files();
2698 let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
2699 let idx = explorer
2701 .entries
2702 .iter()
2703 .position(|e| e.name == "readme.txt")
2704 .expect("readme.txt present");
2705 explorer.cursor = idx;
2706
2707 let outcome = explorer.handle_key(key(KeyCode::Char('r')));
2708 assert_eq!(outcome, ExplorerOutcome::Pending);
2709 assert!(explorer.is_rename_active());
2710 assert_eq!(explorer.rename_input(), "readme.txt");
2711 }
2712
2713 #[test]
2714 fn r_key_on_empty_dir_does_not_activate_rename() {
2715 let dir = tempdir().expect("tempdir");
2716 let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
2717 assert!(explorer.entries.is_empty());
2718
2719 let outcome = explorer.handle_key(key(KeyCode::Char('r')));
2720 assert_eq!(outcome, ExplorerOutcome::Pending);
2721 assert!(!explorer.is_rename_active());
2722 }
2723
2724 #[test]
2725 fn rename_mode_chars_append_to_input() {
2726 let tmp = temp_dir_with_files();
2727 let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
2728 explorer.handle_key(key(KeyCode::Char('r')));
2729 assert!(explorer.is_rename_active());
2730
2731 let original_len = explorer.rename_input().len();
2733 for _ in 0..original_len {
2734 explorer.handle_key(key(KeyCode::Backspace));
2735 }
2736 explorer.handle_key(key(KeyCode::Char('n')));
2737 explorer.handle_key(key(KeyCode::Char('e')));
2738 explorer.handle_key(key(KeyCode::Char('w')));
2739
2740 assert_eq!(explorer.rename_input(), "new");
2741 assert!(explorer.is_rename_active());
2742 }
2743
2744 #[test]
2745 fn rename_mode_backspace_pops_last_char() {
2746 let tmp = temp_dir_with_files();
2747 let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
2748 explorer.handle_key(key(KeyCode::Char('r')));
2749
2750 let original_len = explorer.rename_input().len();
2752 for _ in 0..original_len {
2753 explorer.handle_key(key(KeyCode::Backspace));
2754 }
2755 explorer.handle_key(key(KeyCode::Char('a')));
2756 explorer.handle_key(key(KeyCode::Char('b')));
2757 assert_eq!(explorer.rename_input(), "ab");
2758
2759 explorer.handle_key(key(KeyCode::Backspace));
2760 assert_eq!(explorer.rename_input(), "a");
2761 }
2762
2763 #[test]
2764 fn rename_mode_esc_cancels_without_renaming() {
2765 let tmp = temp_dir_with_files();
2766 let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
2767 let idx = explorer
2768 .entries
2769 .iter()
2770 .position(|e| e.name == "readme.txt")
2771 .expect("readme.txt present");
2772 explorer.cursor = idx;
2773
2774 explorer.handle_key(key(KeyCode::Char('r')));
2775 assert!(explorer.is_rename_active());
2776
2777 let outcome = explorer.handle_key(key(KeyCode::Esc));
2778 assert_eq!(outcome, ExplorerOutcome::Pending);
2779 assert!(!explorer.is_rename_active());
2780 assert_eq!(explorer.rename_input(), "");
2781 assert!(tmp.path().join("readme.txt").exists());
2783 }
2784
2785 #[test]
2786 fn rename_mode_enter_renames_file_and_returns_rename_completed() {
2787 let tmp = temp_dir_with_files();
2788 let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
2789 let idx = explorer
2790 .entries
2791 .iter()
2792 .position(|e| e.name == "readme.txt")
2793 .expect("readme.txt present");
2794 explorer.cursor = idx;
2795
2796 explorer.handle_key(key(KeyCode::Char('r')));
2798 let prefill_len = explorer.rename_input().len();
2799 for _ in 0..prefill_len {
2800 explorer.handle_key(key(KeyCode::Backspace));
2801 }
2802 for c in "notes.txt".chars() {
2803 explorer.handle_key(key(KeyCode::Char(c)));
2804 }
2805
2806 let outcome = explorer.handle_key(key(KeyCode::Enter));
2807
2808 assert!(!explorer.is_rename_active());
2809 assert_eq!(explorer.rename_input(), "");
2810 assert!(tmp.path().join("notes.txt").exists(), "new name must exist");
2811 assert!(
2812 !tmp.path().join("readme.txt").exists(),
2813 "old name must be gone"
2814 );
2815 assert!(
2816 matches!(outcome, ExplorerOutcome::RenameCompleted(p) if p.file_name().unwrap() == "notes.txt")
2817 );
2818 }
2819
2820 #[test]
2821 fn rename_mode_cursor_moves_to_renamed_entry() {
2822 let tmp = temp_dir_with_files();
2823 let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
2824 let idx = explorer
2825 .entries
2826 .iter()
2827 .position(|e| e.name == "readme.txt")
2828 .expect("readme.txt present");
2829 explorer.cursor = idx;
2830
2831 explorer.handle_key(key(KeyCode::Char('r')));
2832 let prefill_len = explorer.rename_input().len();
2833 for _ in 0..prefill_len {
2834 explorer.handle_key(key(KeyCode::Backspace));
2835 }
2836 for c in "zzz_last.txt".chars() {
2837 explorer.handle_key(key(KeyCode::Char(c)));
2838 }
2839 explorer.handle_key(key(KeyCode::Enter));
2840
2841 let new_idx = explorer
2842 .entries
2843 .iter()
2844 .position(|e| e.name == "zzz_last.txt")
2845 .expect("renamed entry in list");
2846 assert_eq!(explorer.cursor, new_idx);
2847 }
2848
2849 #[test]
2850 fn rename_mode_enter_with_empty_input_is_noop() {
2851 let tmp = temp_dir_with_files();
2852 let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
2853 let idx = explorer
2854 .entries
2855 .iter()
2856 .position(|e| e.name == "readme.txt")
2857 .expect("readme.txt present");
2858 explorer.cursor = idx;
2859
2860 explorer.handle_key(key(KeyCode::Char('r')));
2861 let prefill_len = explorer.rename_input().len();
2863 for _ in 0..prefill_len {
2864 explorer.handle_key(key(KeyCode::Backspace));
2865 }
2866 assert_eq!(explorer.rename_input(), "");
2867
2868 let outcome = explorer.handle_key(key(KeyCode::Enter));
2869 assert_eq!(outcome, ExplorerOutcome::Pending);
2870 assert!(!explorer.is_rename_active());
2871 assert!(tmp.path().join("readme.txt").exists());
2873 }
2874
2875 #[test]
2876 fn rename_mode_can_rename_directory() {
2877 let tmp = temp_dir_with_files();
2878 let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
2879 let idx = explorer
2880 .entries
2881 .iter()
2882 .position(|e| e.name == "subdir" && e.is_dir)
2883 .expect("subdir present");
2884 explorer.cursor = idx;
2885
2886 explorer.handle_key(key(KeyCode::Char('r')));
2887 let prefill_len = explorer.rename_input().len();
2888 for _ in 0..prefill_len {
2889 explorer.handle_key(key(KeyCode::Backspace));
2890 }
2891 for c in "renamed_dir".chars() {
2892 explorer.handle_key(key(KeyCode::Char(c)));
2893 }
2894 let outcome = explorer.handle_key(key(KeyCode::Enter));
2895
2896 assert!(tmp.path().join("renamed_dir").exists());
2897 assert!(!tmp.path().join("subdir").exists());
2898 assert!(matches!(outcome, ExplorerOutcome::RenameCompleted(_)));
2899 }
2900
2901 #[test]
2902 fn rename_mode_unrecognised_key_returns_pending_without_cancelling() {
2903 let tmp = temp_dir_with_files();
2904 let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
2905 explorer.handle_key(key(KeyCode::Char('r')));
2906 assert!(explorer.is_rename_active());
2907
2908 let outcome = explorer.handle_key(key(KeyCode::F(1)));
2910 assert_eq!(outcome, ExplorerOutcome::Pending);
2911 assert!(explorer.is_rename_active(), "rename mode must stay active");
2912 }
2913
2914 #[test]
2915 fn is_rename_active_false_by_default() {
2916 let tmp = temp_dir_with_files();
2917 let explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
2918 assert!(!explorer.is_rename_active());
2919 }
2920
2921 #[test]
2922 fn rename_input_empty_by_default() {
2923 let tmp = temp_dir_with_files();
2924 let explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
2925 assert_eq!(explorer.rename_input(), "");
2926 }
2927
2928 #[test]
2935 fn mkdir_mode_char_pushes_to_input_via_macro() {
2936 let dir = tempdir().expect("tempdir");
2937 let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
2938 explorer.mkdir_active = true;
2939 explorer.mkdir_input.clear();
2940
2941 let outcome = explorer.handle_key(key(KeyCode::Char('a')));
2942 assert_eq!(outcome, ExplorerOutcome::Pending);
2943 assert_eq!(explorer.mkdir_input, "a");
2944 assert!(explorer.mkdir_active, "mode must remain active after Char");
2945 }
2946
2947 #[test]
2948 fn mkdir_mode_backspace_pops_via_macro() {
2949 let dir = tempdir().expect("tempdir");
2950 let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
2951 explorer.mkdir_active = true;
2952 explorer.mkdir_input = "ab".to_string();
2953
2954 let outcome = explorer.handle_key(key(KeyCode::Backspace));
2955 assert_eq!(outcome, ExplorerOutcome::Pending);
2956 assert_eq!(explorer.mkdir_input, "a");
2957 assert!(explorer.mkdir_active);
2958 }
2959
2960 #[test]
2961 fn mkdir_mode_esc_cancels_via_macro() {
2962 let dir = tempdir().expect("tempdir");
2963 let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
2964 explorer.mkdir_active = true;
2965 explorer.mkdir_input = "half".to_string();
2966
2967 let outcome = explorer.handle_key(key(KeyCode::Esc));
2968 assert_eq!(outcome, ExplorerOutcome::Pending);
2969 assert!(!explorer.mkdir_active, "mode must be deactivated by Esc");
2970 assert!(
2971 explorer.mkdir_input.is_empty(),
2972 "input must be cleared by Esc"
2973 );
2974 }
2975
2976 #[test]
2977 fn mkdir_mode_unknown_key_returns_pending_via_macro() {
2978 let dir = tempdir().expect("tempdir");
2979 let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
2980 explorer.mkdir_active = true;
2981 explorer.mkdir_input = "foo".to_string();
2982
2983 let outcome = explorer.handle_key(key(KeyCode::F(2)));
2984 assert_eq!(outcome, ExplorerOutcome::Pending);
2985 assert!(explorer.mkdir_active);
2987 assert_eq!(explorer.mkdir_input, "foo");
2988 }
2989
2990 #[test]
2993 fn touch_mode_char_pushes_to_input_via_macro() {
2994 let dir = tempdir().expect("tempdir");
2995 let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
2996 explorer.touch_active = true;
2997 explorer.touch_input.clear();
2998
2999 let outcome = explorer.handle_key(key(KeyCode::Char('z')));
3000 assert_eq!(outcome, ExplorerOutcome::Pending);
3001 assert_eq!(explorer.touch_input, "z");
3002 assert!(explorer.touch_active);
3003 }
3004
3005 #[test]
3006 fn touch_mode_backspace_pops_via_macro() {
3007 let dir = tempdir().expect("tempdir");
3008 let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
3009 explorer.touch_active = true;
3010 explorer.touch_input = "xy".to_string();
3011
3012 let outcome = explorer.handle_key(key(KeyCode::Backspace));
3013 assert_eq!(outcome, ExplorerOutcome::Pending);
3014 assert_eq!(explorer.touch_input, "x");
3015 assert!(explorer.touch_active);
3016 }
3017
3018 #[test]
3019 fn touch_mode_esc_cancels_via_macro() {
3020 let dir = tempdir().expect("tempdir");
3021 let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
3022 explorer.touch_active = true;
3023 explorer.touch_input = "half".to_string();
3024
3025 let outcome = explorer.handle_key(key(KeyCode::Esc));
3026 assert_eq!(outcome, ExplorerOutcome::Pending);
3027 assert!(!explorer.touch_active);
3028 assert!(explorer.touch_input.is_empty());
3029 }
3030
3031 #[test]
3032 fn touch_mode_unknown_key_returns_pending_via_macro() {
3033 let dir = tempdir().expect("tempdir");
3034 let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
3035 explorer.touch_active = true;
3036 explorer.touch_input = "bar".to_string();
3037
3038 let outcome = explorer.handle_key(key(KeyCode::F(3)));
3039 assert_eq!(outcome, ExplorerOutcome::Pending);
3040 assert!(explorer.touch_active);
3041 assert_eq!(explorer.touch_input, "bar");
3042 }
3043
3044 #[test]
3047 fn rename_mode_char_pushes_to_input_via_macro() {
3048 let dir = tempdir().expect("tempdir");
3049 let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
3050 explorer.rename_active = true;
3051 explorer.rename_input.clear();
3052
3053 let outcome = explorer.handle_key(key(KeyCode::Char('r')));
3054 assert_eq!(outcome, ExplorerOutcome::Pending);
3058 assert_eq!(explorer.rename_input, "r");
3059 assert!(explorer.rename_active);
3060 }
3061
3062 #[test]
3063 fn rename_mode_backspace_pops_via_macro() {
3064 let dir = tempdir().expect("tempdir");
3065 let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
3066 explorer.rename_active = true;
3067 explorer.rename_input = "cd".to_string();
3068
3069 let outcome = explorer.handle_key(key(KeyCode::Backspace));
3070 assert_eq!(outcome, ExplorerOutcome::Pending);
3071 assert_eq!(explorer.rename_input, "c");
3072 assert!(explorer.rename_active);
3073 }
3074
3075 #[test]
3076 fn rename_mode_esc_cancels_via_macro() {
3077 let dir = tempdir().expect("tempdir");
3078 let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
3079 explorer.rename_active = true;
3080 explorer.rename_input = "draft".to_string();
3081
3082 let outcome = explorer.handle_key(key(KeyCode::Esc));
3083 assert_eq!(outcome, ExplorerOutcome::Pending);
3084 assert!(!explorer.rename_active);
3085 assert!(explorer.rename_input.is_empty());
3086 }
3087
3088 #[test]
3089 fn rename_mode_unknown_key_returns_pending_via_macro() {
3090 let dir = tempdir().expect("tempdir");
3091 let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
3092 explorer.rename_active = true;
3093 explorer.rename_input = "baz".to_string();
3094
3095 let outcome = explorer.handle_key(key(KeyCode::F(4)));
3096 assert_eq!(outcome, ExplorerOutcome::Pending);
3097 assert!(explorer.rename_active);
3098 assert_eq!(explorer.rename_input, "baz");
3099 }
3100}