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 entries: Vec<FsEntry>,
74 pub cursor: usize,
76 pub(crate) scroll_offset: usize,
78 pub extension_filter: Vec<String>,
82 pub show_hidden: bool,
84 pub(crate) status: String,
86 pub sort_mode: SortMode,
88 pub search_query: String,
90 pub search_active: bool,
92 pub marked: HashSet<PathBuf>,
94}
95
96impl FileExplorer {
97 pub fn new(initial_dir: PathBuf, extension_filter: Vec<String>) -> Self {
107 let mut explorer = Self {
108 current_dir: initial_dir,
109 entries: Vec::new(),
110 cursor: 0,
111 scroll_offset: 0,
112 extension_filter,
113 show_hidden: false,
114 status: String::new(),
115 sort_mode: SortMode::default(),
116 search_query: String::new(),
117 search_active: false,
118 marked: HashSet::new(),
119 };
120 explorer.reload();
121 explorer
122 }
123
124 pub fn builder(initial_dir: PathBuf) -> FileExplorerBuilder {
139 FileExplorerBuilder::new(initial_dir)
140 }
141
142 pub fn navigate_to(&mut self, path: impl Into<PathBuf>) {
155 self.current_dir = path.into();
156 self.cursor = 0;
157 self.scroll_offset = 0;
158 self.reload();
159 }
160
161 pub fn marked_paths(&self) -> &HashSet<PathBuf> {
169 &self.marked
170 }
171
172 pub fn toggle_mark(&mut self) {
175 if let Some(entry) = self.entries.get(self.cursor) {
176 let path = entry.path.clone();
177 if self.marked.contains(&path) {
178 self.marked.remove(&path);
179 } else {
180 self.marked.insert(path);
181 }
182 }
183 self.move_down();
184 }
185
186 pub fn clear_marks(&mut self) {
188 self.marked.clear();
189 }
190
191 pub fn handle_key(&mut self, key: KeyEvent) -> ExplorerOutcome {
192 if self.search_active {
198 match key.code {
199 KeyCode::Char(c) if key.modifiers.is_empty() => {
200 self.search_query.push(c);
201 self.cursor = 0;
202 self.scroll_offset = 0;
203 self.reload();
204 return ExplorerOutcome::Pending;
205 }
206 KeyCode::Backspace => {
207 if self.search_query.is_empty() {
208 self.search_active = false;
210 } else {
211 self.search_query.pop();
212 self.cursor = 0;
213 self.scroll_offset = 0;
214 self.reload();
215 }
216 return ExplorerOutcome::Pending;
217 }
218 KeyCode::Esc => {
219 self.search_active = false;
222 self.search_query.clear();
223 self.cursor = 0;
224 self.scroll_offset = 0;
225 self.reload();
226 return ExplorerOutcome::Pending;
227 }
228 _ => {} }
230 }
231
232 match key.code {
233 KeyCode::Esc => ExplorerOutcome::Dismissed,
235
236 KeyCode::Char('q') if key.modifiers.is_empty() => ExplorerOutcome::Dismissed,
238
239 KeyCode::Up | KeyCode::Char('k') => {
241 self.move_up();
242 ExplorerOutcome::Pending
243 }
244
245 KeyCode::Down | KeyCode::Char('j') => {
247 self.move_down();
248 ExplorerOutcome::Pending
249 }
250
251 KeyCode::PageUp => {
253 for _ in 0..10 {
254 self.move_up();
255 }
256 ExplorerOutcome::Pending
257 }
258
259 KeyCode::PageDown => {
261 for _ in 0..10 {
262 self.move_down();
263 }
264 ExplorerOutcome::Pending
265 }
266
267 KeyCode::Home | KeyCode::Char('g') => {
269 self.cursor = 0;
270 self.scroll_offset = 0;
271 ExplorerOutcome::Pending
272 }
273
274 KeyCode::End | KeyCode::Char('G') => {
276 if !self.entries.is_empty() {
277 self.cursor = self.entries.len() - 1;
278 }
279 ExplorerOutcome::Pending
280 }
281
282 KeyCode::Backspace | KeyCode::Left | KeyCode::Char('h') => {
284 self.ascend();
285 ExplorerOutcome::Pending
286 }
287
288 KeyCode::Enter | KeyCode::Right | KeyCode::Char('l') => self.confirm(),
290
291 KeyCode::Char('.') => {
293 self.show_hidden = !self.show_hidden;
294 let was = self.cursor;
295 self.reload();
296 self.cursor = was.min(self.entries.len().saturating_sub(1));
297 ExplorerOutcome::Pending
298 }
299
300 KeyCode::Char('/') if key.modifiers.is_empty() => {
302 self.search_active = true;
303 ExplorerOutcome::Pending
304 }
305
306 KeyCode::Char('s') if key.modifiers.is_empty() => {
308 self.sort_mode = self.sort_mode.next();
309 let was = self.cursor;
310 self.reload();
311 self.cursor = was.min(self.entries.len().saturating_sub(1));
312 ExplorerOutcome::Pending
313 }
314
315 KeyCode::Char(' ') => {
317 self.toggle_mark();
318 ExplorerOutcome::Pending
319 }
320
321 _ => ExplorerOutcome::Unhandled,
322 }
323 }
324
325 pub fn current_entry(&self) -> Option<&FsEntry> {
329 self.entries.get(self.cursor)
330 }
331
332 pub fn is_at_root(&self) -> bool {
344 self.current_dir.parent().is_none()
345 }
346
347 pub fn is_empty(&self) -> bool {
353 self.entries.is_empty()
354 }
355
356 pub fn entry_count(&self) -> usize {
361 self.entries.len()
362 }
363
364 pub fn status(&self) -> &str {
371 &self.status
372 }
373
374 pub fn sort_mode(&self) -> SortMode {
383 self.sort_mode
384 }
385
386 pub fn search_query(&self) -> &str {
390 &self.search_query
391 }
392
393 pub fn is_searching(&self) -> bool {
396 self.search_active
397 }
398
399 pub fn set_show_hidden(&mut self, show: bool) {
414 self.show_hidden = show;
415 self.reload();
416 }
417
418 pub fn set_extension_filter<I, S>(&mut self, filter: I)
439 where
440 I: IntoIterator<Item = S>,
441 S: Into<String>,
442 {
443 self.extension_filter = filter.into_iter().map(Into::into).collect();
444 self.reload();
445 }
446
447 pub fn set_sort_mode(&mut self, mode: SortMode) {
459 self.sort_mode = mode;
460 self.reload();
461 }
462
463 fn move_up(&mut self) {
466 if self.cursor > 0 {
467 self.cursor -= 1;
468 }
469 }
470
471 fn move_down(&mut self) {
472 if !self.entries.is_empty() && self.cursor < self.entries.len() - 1 {
473 self.cursor += 1;
474 }
475 }
476
477 fn ascend(&mut self) {
478 if let Some(parent) = self.current_dir.parent().map(|p| p.to_path_buf()) {
479 let prev = self.current_dir.clone();
480 self.current_dir = parent;
481 self.cursor = 0;
482 self.scroll_offset = 0;
483 self.search_active = false;
485 self.search_query.clear();
486 self.marked.clear();
487 self.reload();
488 if let Some(idx) = self.entries.iter().position(|e| e.path == prev) {
490 self.cursor = idx;
491 }
492 } else {
493 self.status = "Already at the filesystem root.".to_string();
494 }
495 }
496
497 fn confirm(&mut self) -> ExplorerOutcome {
498 let Some(entry) = self.entries.get(self.cursor) else {
499 return ExplorerOutcome::Pending;
500 };
501
502 if entry.is_dir {
503 let path = entry.path.clone();
504 self.search_active = false;
506 self.search_query.clear();
507 self.marked.clear();
508 self.navigate_to(path);
509 ExplorerOutcome::Pending
510 } else {
511 ExplorerOutcome::Selected(entry.path.clone())
514 }
515 }
516
517 pub fn reload(&mut self) {
525 self.status.clear();
526 self.entries = load_entries(
527 &self.current_dir,
528 self.show_hidden,
529 &self.extension_filter,
530 self.sort_mode,
531 &self.search_query,
532 );
533 }
534}
535
536pub struct FileExplorerBuilder {
555 initial_dir: PathBuf,
556 extension_filter: Vec<String>,
557 show_hidden: bool,
558 sort_mode: SortMode,
559}
560
561impl FileExplorerBuilder {
562 pub fn new(initial_dir: PathBuf) -> Self {
564 Self {
565 initial_dir,
566 extension_filter: Vec::new(),
567 show_hidden: false,
568 sort_mode: SortMode::default(),
569 }
570 }
571
572 pub fn extension_filter(mut self, filter: Vec<String>) -> Self {
584 self.extension_filter = filter;
585 self
586 }
587
588 pub fn allow_extension(mut self, ext: impl Into<String>) -> Self {
601 self.extension_filter.push(ext.into());
602 self
603 }
604
605 pub fn show_hidden(mut self, show: bool) -> Self {
615 self.show_hidden = show;
616 self
617 }
618
619 pub fn sort_mode(mut self, mode: SortMode) -> Self {
629 self.sort_mode = mode;
630 self
631 }
632
633 pub fn build(self) -> FileExplorer {
635 let mut explorer = FileExplorer {
636 current_dir: self.initial_dir,
637 entries: Vec::new(),
638 cursor: 0,
639 scroll_offset: 0,
640 extension_filter: self.extension_filter,
641 show_hidden: self.show_hidden,
642 status: String::new(),
643 sort_mode: self.sort_mode,
644 search_query: String::new(),
645 search_active: false,
646 marked: HashSet::new(),
647 };
648 explorer.reload();
649 explorer
650 }
651}
652
653pub(crate) fn load_entries(
665 dir: &Path,
666 show_hidden: bool,
667 ext_filter: &[String],
668 sort_mode: SortMode,
669 search_query: &str,
670) -> Vec<FsEntry> {
671 let read = match fs::read_dir(dir) {
672 Ok(r) => r,
673 Err(_) => return Vec::new(),
674 };
675
676 let mut dirs: Vec<FsEntry> = Vec::new();
677 let mut files: Vec<FsEntry> = Vec::new();
678
679 for entry in read.flatten() {
680 let path = entry.path();
681 let name = entry.file_name().to_string_lossy().to_string();
682
683 if !show_hidden && name.starts_with('.') {
684 continue;
685 }
686
687 let is_dir = path.is_dir();
688 let extension = if is_dir {
689 String::new()
690 } else {
691 path.extension()
692 .map(|e| e.to_string_lossy().to_lowercase())
693 .unwrap_or_default()
694 };
695
696 if !is_dir && !ext_filter.is_empty() {
698 let matches = ext_filter
699 .iter()
700 .any(|f| f.eq_ignore_ascii_case(&extension));
701 if !matches {
702 continue;
703 }
704 }
705
706 if !search_query.is_empty() {
708 let q = search_query.to_lowercase();
709 if !name.to_lowercase().contains(&q) {
710 continue;
711 }
712 }
713
714 let size = if is_dir {
715 None
716 } else {
717 entry.metadata().ok().map(|m| m.len())
718 };
719
720 let fs_entry = FsEntry {
721 name,
722 path,
723 is_dir,
724 size,
725 extension,
726 };
727
728 if is_dir {
729 dirs.push(fs_entry);
730 } else {
731 files.push(fs_entry);
732 }
733 }
734
735 dirs.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
738
739 match sort_mode {
740 SortMode::Name => {
741 files.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
742 }
743 SortMode::SizeDesc => {
744 files.sort_by(|a, b| b.size.unwrap_or(0).cmp(&a.size.unwrap_or(0)));
746 }
747 SortMode::Extension => {
748 files.sort_by(|a, b| {
750 a.extension
751 .cmp(&b.extension)
752 .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
753 });
754 }
755 }
756
757 dirs.extend(files);
759 dirs
760}
761
762pub fn entry_icon(entry: &FsEntry) -> &'static str {
769 if entry.is_dir {
770 return "📁";
771 }
772 match entry.extension.as_str() {
773 "iso" | "dmg" => "💿",
775 "img" => "🖼 ",
776 "zip" | "gz" | "xz" | "zst" | "bz2" | "tar" | "7z" | "rar" | "tgz" | "tbz2" => "📦",
778 "pdf" => "📕",
780 "txt" | "log" | "rst" => "📄",
781 "md" | "mdx" | "markdown" => "📝",
782 "toml" | "yaml" | "yml" | "json" | "xml" | "ini" | "cfg" | "conf" | "env" => "⚙ ",
784 "lock" => "🔒",
785 "rs" => "🦀",
787 "py" | "pyw" => "🐍",
788 "js" | "mjs" | "cjs" => "📜",
789 "ts" | "mts" | "cts" => "📜",
790 "jsx" | "tsx" => "📜",
791 "go" => "📜",
792 "c" | "h" => "📜",
793 "cpp" | "cc" | "cxx" | "hpp" | "hxx" => "📜",
794 "java" | "kt" | "kts" => "📜",
795 "rb" | "erb" => "📜",
796 "php" => "📜",
797 "swift" => "📜",
798 "cs" => "📜",
799 "lua" => "📜",
800 "zig" => "📜",
801 "ex" | "exs" => "📜",
802 "hs" | "lhs" => "📜",
803 "ml" | "mli" => "📜",
804 "sh" | "bash" | "zsh" | "fish" | "nu" => "📜",
806 "bat" | "cmd" | "ps1" => "📜",
807 "html" | "htm" | "xhtml" => "🌐",
809 "css" | "scss" | "sass" | "less" => "🎨",
810 "svg" => "🎨",
811 "png" | "jpg" | "jpeg" | "gif" | "bmp" | "webp" | "ico" | "tiff" | "tif" | "avif"
813 | "heic" | "heif" => "🖼 ",
814 "mp4" | "mkv" | "avi" | "mov" | "webm" | "flv" | "wmv" | "m4v" => "🎬",
816 "mp3" | "wav" | "flac" | "ogg" | "aac" | "m4a" | "opus" | "wma" => "🎵",
818 "ttf" | "otf" | "woff" | "woff2" | "eot" => "🔤",
820 "exe" | "msi" | "deb" | "rpm" | "appimage" | "apk" => "⚙ ",
822 _ => "📄",
823 }
824}
825
826pub fn fmt_size(bytes: u64) -> String {
840 const KB: u64 = 1_024;
841 const MB: u64 = 1_024 * KB;
842 const GB: u64 = 1_024 * MB;
843 if bytes >= GB {
844 format!("{:.1} GB", bytes as f64 / GB as f64)
845 } else if bytes >= MB {
846 format!("{:.1} MB", bytes as f64 / MB as f64)
847 } else if bytes >= KB {
848 format!("{:.1} KB", bytes as f64 / KB as f64)
849 } else {
850 format!("{bytes} B")
851 }
852}
853
854#[cfg(test)]
857mod tests {
858 use super::*;
859 use crossterm::event::{KeyEvent, KeyModifiers};
860 use std::fs;
861 use tempfile::TempDir;
862
863 fn temp_dir_with_files() -> TempDir {
866 let dir = tempfile::tempdir().expect("temp dir");
867 fs::write(dir.path().join("ubuntu.iso"), b"fake iso content").unwrap();
868 fs::write(dir.path().join("debian.img"), b"fake img content").unwrap();
869 fs::write(dir.path().join("readme.txt"), b"some text").unwrap();
870 fs::create_dir(dir.path().join("subdir")).unwrap();
871 dir
872 }
873
874 fn key(code: KeyCode) -> KeyEvent {
875 KeyEvent::new(code, KeyModifiers::NONE)
876 }
877
878 #[test]
881 fn new_loads_entries() {
882 let tmp = temp_dir_with_files();
883 let explorer =
884 FileExplorer::new(tmp.path().to_path_buf(), vec!["iso".into(), "img".into()]);
885 assert!(explorer
886 .entries
887 .iter()
888 .any(|e| e.name == "subdir" && e.is_dir));
889 assert!(explorer.entries.iter().any(|e| e.name == "ubuntu.iso"));
890 assert!(explorer.entries.iter().any(|e| e.name == "debian.img"));
891 assert!(!explorer.entries.iter().any(|e| e.name == "readme.txt"));
893 }
894
895 #[test]
896 fn no_filter_shows_all_files() {
897 let tmp = temp_dir_with_files();
898 let explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
899 assert!(explorer.entries.iter().any(|e| e.name == "readme.txt"));
900 }
901
902 #[test]
903 fn dirs_listed_before_files() {
904 let tmp = temp_dir_with_files();
905 let explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
906 let first_file_idx = explorer
907 .entries
908 .iter()
909 .position(|e| !e.is_dir)
910 .unwrap_or(usize::MAX);
911 let last_dir_idx = explorer.entries.iter().rposition(|e| e.is_dir).unwrap_or(0);
912 assert!(
913 last_dir_idx < first_file_idx,
914 "all dirs must appear before any file"
915 );
916 }
917
918 #[test]
919 fn move_down_increments_cursor() {
920 let tmp = temp_dir_with_files();
921 let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
922 explorer.move_down();
923 assert_eq!(explorer.cursor, 1);
924 }
925
926 #[test]
927 fn move_up_clamps_at_zero() {
928 let tmp = temp_dir_with_files();
929 let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
930 explorer.move_up();
931 assert_eq!(explorer.cursor, 0);
932 }
933
934 #[test]
935 fn move_down_clamps_at_last() {
936 let tmp = temp_dir_with_files();
937 let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
938 let last = explorer.entries.len() - 1;
939 explorer.cursor = last;
940 explorer.move_down();
941 assert_eq!(explorer.cursor, last);
942 }
943
944 #[test]
945 fn handle_key_down_moves_cursor() {
946 let tmp = temp_dir_with_files();
947 let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
948 let before = explorer.cursor;
949 explorer.handle_key(key(KeyCode::Down));
950 assert_eq!(explorer.cursor, before + 1);
951 }
952
953 #[test]
954 fn handle_key_esc_dismisses() {
955 let tmp = temp_dir_with_files();
956 let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
957 assert_eq!(
958 explorer.handle_key(key(KeyCode::Esc)),
959 ExplorerOutcome::Dismissed
960 );
961 }
962
963 #[test]
964 fn handle_key_enter_on_dir_descends() {
965 let tmp = temp_dir_with_files();
966 let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
967 let dir_idx = explorer
969 .entries
970 .iter()
971 .position(|e| e.is_dir)
972 .expect("no dir in fixture");
973 explorer.cursor = dir_idx;
974 let expected_path = explorer.entries[dir_idx].path.clone();
975 let outcome = explorer.handle_key(key(KeyCode::Enter));
976 assert_eq!(outcome, ExplorerOutcome::Pending);
977 assert_eq!(explorer.current_dir, expected_path);
978 }
979
980 #[test]
981 fn handle_key_enter_on_valid_file_selects() {
982 let tmp = temp_dir_with_files();
983 let mut explorer =
984 FileExplorer::new(tmp.path().to_path_buf(), vec!["iso".into(), "img".into()]);
985 let file_idx = explorer
986 .entries
987 .iter()
988 .position(|e| !e.is_dir)
989 .expect("no file in fixture");
990 explorer.cursor = file_idx;
991 let expected = explorer.entries[file_idx].path.clone();
992 let outcome = explorer.handle_key(key(KeyCode::Enter));
993 assert_eq!(outcome, ExplorerOutcome::Selected(expected));
994 }
995
996 #[test]
997 fn handle_key_backspace_ascends() {
998 let tmp = temp_dir_with_files();
999 let subdir = tmp.path().join("subdir");
1000 let mut explorer = FileExplorer::new(subdir, vec![]);
1001 explorer.handle_key(key(KeyCode::Backspace));
1002 assert_eq!(explorer.current_dir, tmp.path());
1003 }
1004
1005 #[test]
1006 fn toggle_hidden_changes_visibility() {
1007 let tmp = temp_dir_with_files();
1008 fs::write(tmp.path().join(".hidden_file"), b"").unwrap();
1009 let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1010 assert!(!explorer.entries.iter().any(|e| e.name == ".hidden_file"));
1011 explorer.set_show_hidden(true);
1012 assert!(explorer.entries.iter().any(|e| e.name == ".hidden_file"));
1013 }
1014
1015 #[test]
1016 fn fmt_size_formats_bytes() {
1017 assert_eq!(fmt_size(512), "512 B");
1018 assert_eq!(fmt_size(1_536), "1.5 KB");
1019 assert_eq!(fmt_size(2_097_152), "2.0 MB");
1020 assert_eq!(fmt_size(1_073_741_824), "1.0 GB");
1021 }
1022
1023 #[test]
1024 fn extension_filter_only_shows_matching_files() {
1025 let tmp = temp_dir_with_files();
1028 let explorer = FileExplorer::new(tmp.path().to_path_buf(), vec!["iso".into()]);
1029
1030 assert!(
1032 explorer.entries.iter().any(|e| e.name == "ubuntu.iso"),
1033 "iso file should appear in entries"
1034 );
1035 assert!(
1037 !explorer.entries.iter().any(|e| e.name == "debian.img"),
1038 "img file should be excluded by filter"
1039 );
1040 assert!(
1042 explorer.entries.iter().any(|e| e.is_dir),
1043 "directories should always be visible"
1044 );
1045 assert!(
1047 explorer
1048 .entries
1049 .iter()
1050 .filter(|e| !e.is_dir)
1051 .all(|e| e.extension == "iso"),
1052 "all visible files must match the active filter"
1053 );
1054 }
1055
1056 #[test]
1057 fn navigate_to_resets_cursor_and_scroll() {
1058 let tmp = temp_dir_with_files();
1059 let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1060 explorer.cursor = 2;
1061 explorer.scroll_offset = 1;
1062 explorer.navigate_to(tmp.path().to_path_buf());
1063 assert_eq!(explorer.cursor, 0);
1064 assert_eq!(explorer.scroll_offset, 0);
1065 }
1066
1067 #[test]
1068 fn current_entry_returns_highlighted() {
1069 let tmp = temp_dir_with_files();
1070 let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1071 explorer.cursor = 0;
1072 let entry = explorer.current_entry().expect("should have entry");
1073 assert_eq!(entry, explorer.entries.first().unwrap());
1074 }
1075
1076 #[test]
1077 fn unrecognised_key_returns_unhandled() {
1078 let tmp = temp_dir_with_files();
1079 let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1080 assert_eq!(
1081 explorer.handle_key(key(KeyCode::F(5))),
1082 ExplorerOutcome::Unhandled
1083 );
1084 }
1085
1086 #[test]
1089 fn slash_activates_search_mode() {
1090 let tmp = temp_dir_with_files();
1091 let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1092 assert!(!explorer.search_active);
1093 explorer.handle_key(key(KeyCode::Char('/')));
1094 assert!(explorer.search_active);
1095 assert_eq!(explorer.search_query(), "");
1096 }
1097
1098 #[test]
1099 fn search_active_chars_append_to_query() {
1100 let tmp = temp_dir_with_files();
1101 let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1102 explorer.handle_key(key(KeyCode::Char('/')));
1103 explorer.handle_key(key(KeyCode::Char('u')));
1104 explorer.handle_key(key(KeyCode::Char('b')));
1105 explorer.handle_key(key(KeyCode::Char('u')));
1106 assert_eq!(explorer.search_query(), "ubu");
1107 assert!(explorer.search_active);
1108 }
1109
1110 #[test]
1111 fn search_filters_entries_by_name() {
1112 let tmp = temp_dir_with_files();
1113 let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1114 explorer.handle_key(key(KeyCode::Char('/')));
1116 for c in "ubu".chars() {
1117 explorer.handle_key(key(KeyCode::Char(c)));
1118 }
1119 assert_eq!(explorer.entries.len(), 1);
1121 assert_eq!(explorer.entries[0].name, "ubuntu.iso");
1122 }
1123
1124 #[test]
1125 fn search_backspace_pops_last_char() {
1126 let tmp = temp_dir_with_files();
1127 let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1128 explorer.handle_key(key(KeyCode::Char('/')));
1129 explorer.handle_key(key(KeyCode::Char('u')));
1130 explorer.handle_key(key(KeyCode::Char('b')));
1131 explorer.handle_key(key(KeyCode::Backspace));
1132 assert_eq!(explorer.search_query(), "u");
1133 assert!(explorer.search_active);
1134 }
1135
1136 #[test]
1137 fn search_backspace_on_empty_deactivates() {
1138 let tmp = temp_dir_with_files();
1139 let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1140 explorer.handle_key(key(KeyCode::Char('/')));
1141 assert!(explorer.search_active);
1142 explorer.handle_key(key(KeyCode::Backspace));
1144 assert!(!explorer.search_active);
1145 assert_eq!(explorer.search_query(), "");
1146 }
1147
1148 #[test]
1149 fn search_esc_clears_and_deactivates_returns_pending() {
1150 let tmp = temp_dir_with_files();
1151 let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1152 explorer.handle_key(key(KeyCode::Char('/')));
1153 explorer.handle_key(key(KeyCode::Char('u')));
1154 let outcome = explorer.handle_key(key(KeyCode::Esc));
1155 assert_eq!(
1156 outcome,
1157 ExplorerOutcome::Pending,
1158 "Esc should clear search, not dismiss"
1159 );
1160 assert!(!explorer.search_active);
1161 assert_eq!(explorer.search_query(), "");
1162 }
1163
1164 #[test]
1165 fn esc_when_not_searching_dismisses() {
1166 let tmp = temp_dir_with_files();
1167 let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1168 assert!(!explorer.search_active);
1169 assert_eq!(
1170 explorer.handle_key(key(KeyCode::Esc)),
1171 ExplorerOutcome::Dismissed
1172 );
1173 }
1174
1175 #[test]
1176 fn search_clears_on_directory_descend() {
1177 let tmp = temp_dir_with_files();
1178 let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1179 explorer.search_active = true;
1180 explorer.search_query = "sub".into();
1181 explorer.cursor = explorer.entries.iter().position(|e| e.is_dir).unwrap();
1183 explorer.handle_key(key(KeyCode::Enter));
1184 assert!(!explorer.search_active);
1185 assert_eq!(explorer.search_query(), "");
1186 }
1187
1188 #[test]
1189 fn search_clears_on_ascend() {
1190 let tmp = temp_dir_with_files();
1191 let subdir = tmp.path().join("subdir");
1192 let mut explorer = FileExplorer::new(subdir, vec![]);
1193
1194 explorer.search_active = true;
1200 explorer.search_query = "foo".into();
1201
1202 explorer.handle_key(key(KeyCode::Left));
1205
1206 assert!(
1207 !explorer.search_active,
1208 "search must be deactivated after ascend"
1209 );
1210 assert_eq!(
1211 explorer.search_query(),
1212 "",
1213 "query must be cleared after ascend"
1214 );
1215 assert_eq!(
1216 explorer.current_dir,
1217 tmp.path(),
1218 "must have ascended to parent"
1219 );
1220 }
1221
1222 #[test]
1225 fn default_sort_mode_is_name() {
1226 let tmp = temp_dir_with_files();
1227 let explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1228 assert_eq!(explorer.sort_mode(), SortMode::Name);
1229 }
1230
1231 #[test]
1232 fn sort_mode_cycles_on_s_key() {
1233 let tmp = temp_dir_with_files();
1234 let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1235 assert_eq!(explorer.sort_mode(), SortMode::Name);
1236 explorer.handle_key(key(KeyCode::Char('s')));
1237 assert_eq!(explorer.sort_mode(), SortMode::SizeDesc);
1238 explorer.handle_key(key(KeyCode::Char('s')));
1239 assert_eq!(explorer.sort_mode(), SortMode::Extension);
1240 explorer.handle_key(key(KeyCode::Char('s')));
1241 assert_eq!(explorer.sort_mode(), SortMode::Name);
1242 }
1243
1244 #[test]
1245 fn sort_size_desc_orders_largest_first() {
1246 let tmp = tempfile::tempdir().expect("temp dir");
1247 fs::write(tmp.path().join("small.txt"), vec![0u8; 10]).unwrap();
1249 fs::write(tmp.path().join("large.txt"), vec![0u8; 10_000]).unwrap();
1250 fs::write(tmp.path().join("medium.txt"), vec![0u8; 1_000]).unwrap();
1251
1252 let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1253 explorer.set_sort_mode(SortMode::SizeDesc);
1254
1255 let sizes: Vec<u64> = explorer.entries.iter().filter_map(|e| e.size).collect();
1256 let mut sorted_desc = sizes.clone();
1257 sorted_desc.sort_by(|a, b| b.cmp(a));
1258 assert_eq!(sizes, sorted_desc, "files should be sorted largest-first");
1259 }
1260
1261 #[test]
1262 fn sort_extension_groups_by_ext() {
1263 let tmp = tempfile::tempdir().expect("temp dir");
1264 fs::write(tmp.path().join("b.toml"), b"").unwrap();
1265 fs::write(tmp.path().join("a.rs"), b"").unwrap();
1266 fs::write(tmp.path().join("c.toml"), b"").unwrap();
1267 fs::write(tmp.path().join("z.rs"), b"").unwrap();
1268
1269 let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1270 explorer.set_sort_mode(SortMode::Extension);
1271
1272 let exts: Vec<&str> = explorer
1273 .entries
1274 .iter()
1275 .filter(|e| !e.is_dir)
1276 .map(|e| e.extension.as_str())
1277 .collect();
1278
1279 let rs_last = exts.iter().rposition(|&e| e == "rs").unwrap_or(0);
1281 let toml_first = exts.iter().position(|&e| e == "toml").unwrap_or(usize::MAX);
1282 assert!(rs_last < toml_first, "rs group must precede toml group");
1283 }
1284
1285 #[test]
1286 fn builder_sort_mode_applied() {
1287 let tmp = temp_dir_with_files();
1288 let explorer = FileExplorer::builder(tmp.path().to_path_buf())
1289 .sort_mode(SortMode::SizeDesc)
1290 .build();
1291 assert_eq!(explorer.sort_mode(), SortMode::SizeDesc);
1292 }
1293
1294 #[test]
1295 fn set_sort_mode_reloads() {
1296 let tmp = temp_dir_with_files();
1297 let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1298 explorer.set_sort_mode(SortMode::Extension);
1299 assert_eq!(explorer.sort_mode(), SortMode::Extension);
1300 assert!(!explorer.entries.is_empty());
1302 }
1303
1304 #[test]
1307 fn j_key_moves_cursor_down() {
1308 let tmp = temp_dir_with_files();
1309 let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1310 let before = explorer.cursor;
1311 explorer.handle_key(key(KeyCode::Char('j')));
1312 assert_eq!(explorer.cursor, before + 1);
1313 }
1314
1315 #[test]
1316 fn k_key_moves_cursor_up() {
1317 let tmp = temp_dir_with_files();
1318 let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1319 explorer.cursor = 2;
1320 explorer.handle_key(key(KeyCode::Char('k')));
1321 assert_eq!(explorer.cursor, 1);
1322 }
1323
1324 #[test]
1325 fn h_key_ascends_to_parent() {
1326 let tmp = temp_dir_with_files();
1327 let subdir = tmp.path().join("subdir");
1328 let mut explorer = FileExplorer::new(subdir, vec![]);
1329 explorer.handle_key(key(KeyCode::Char('h')));
1330 assert_eq!(explorer.current_dir, tmp.path());
1331 }
1332
1333 #[test]
1334 fn l_key_descends_into_dir() {
1335 let tmp = temp_dir_with_files();
1336 let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1337 let dir_idx = explorer.entries.iter().position(|e| e.is_dir).unwrap();
1338 explorer.cursor = dir_idx;
1339 let expected = explorer.entries[dir_idx].path.clone();
1340 let outcome = explorer.handle_key(key(KeyCode::Char('l')));
1341 assert_eq!(outcome, ExplorerOutcome::Pending);
1342 assert_eq!(explorer.current_dir, expected);
1343 }
1344
1345 #[test]
1346 fn right_arrow_confirms_file() {
1347 let tmp = temp_dir_with_files();
1348 let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1349 let file_idx = explorer.entries.iter().position(|e| !e.is_dir).unwrap();
1350 explorer.cursor = file_idx;
1351 let expected = explorer.entries[file_idx].path.clone();
1352 let outcome = explorer.handle_key(key(KeyCode::Right));
1353 assert_eq!(outcome, ExplorerOutcome::Selected(expected));
1354 }
1355
1356 #[test]
1357 fn q_key_dismisses() {
1358 let tmp = temp_dir_with_files();
1359 let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1360 assert_eq!(
1361 explorer.handle_key(key(KeyCode::Char('q'))),
1362 ExplorerOutcome::Dismissed
1363 );
1364 }
1365
1366 #[test]
1369 fn page_down_advances_cursor_by_ten() {
1370 let tmp = tempfile::tempdir().unwrap();
1371 for i in 0..15 {
1372 fs::write(tmp.path().join(format!("file{i:02}.txt")), b"").unwrap();
1373 }
1374 let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1375 explorer.cursor = 0;
1376 explorer.handle_key(key(KeyCode::PageDown));
1377 assert_eq!(explorer.cursor, 10);
1378 }
1379
1380 #[test]
1381 fn page_up_retreats_cursor_by_ten() {
1382 let tmp = tempfile::tempdir().unwrap();
1383 for i in 0..15 {
1384 fs::write(tmp.path().join(format!("file{i:02}.txt")), b"").unwrap();
1385 }
1386 let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1387 explorer.cursor = 12;
1388 explorer.handle_key(key(KeyCode::PageUp));
1389 assert_eq!(explorer.cursor, 2);
1390 }
1391
1392 #[test]
1393 fn home_key_jumps_to_top() {
1394 let tmp = temp_dir_with_files();
1395 let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1396 explorer.cursor = explorer.entries.len() - 1;
1397 explorer.handle_key(key(KeyCode::Home));
1398 assert_eq!(explorer.cursor, 0);
1399 assert_eq!(explorer.scroll_offset, 0);
1400 }
1401
1402 #[test]
1403 fn g_key_jumps_to_top() {
1404 let tmp = temp_dir_with_files();
1405 let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1406 explorer.cursor = explorer.entries.len() - 1;
1407 explorer.handle_key(key(KeyCode::Char('g')));
1408 assert_eq!(explorer.cursor, 0);
1409 assert_eq!(explorer.scroll_offset, 0);
1410 }
1411
1412 #[test]
1413 fn end_key_jumps_to_bottom() {
1414 let tmp = temp_dir_with_files();
1415 let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1416 explorer.cursor = 0;
1417 explorer.handle_key(key(KeyCode::End));
1418 assert_eq!(explorer.cursor, explorer.entries.len() - 1);
1419 }
1420
1421 #[test]
1422 fn capital_g_key_jumps_to_bottom() {
1423 let tmp = temp_dir_with_files();
1424 let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1425 explorer.cursor = 0;
1426 let key_g = KeyEvent::new(KeyCode::Char('G'), KeyModifiers::NONE);
1427 explorer.handle_key(key_g);
1428 assert_eq!(explorer.cursor, explorer.entries.len() - 1);
1429 }
1430
1431 #[test]
1434 fn ascend_at_root_sets_status() {
1435 let root = std::path::PathBuf::from("/");
1437 let mut explorer = FileExplorer::new(root.clone(), vec![]);
1438 assert!(explorer.is_at_root());
1439 explorer.handle_key(key(KeyCode::Backspace));
1441 assert_eq!(explorer.current_dir, root);
1442 assert!(
1443 !explorer.status().is_empty(),
1444 "status should report already at root"
1445 );
1446 }
1447
1448 #[test]
1449 fn is_at_root_false_for_subdir() {
1450 let tmp = temp_dir_with_files();
1451 let explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1452 assert!(!explorer.is_at_root());
1453 }
1454
1455 #[test]
1458 fn is_empty_reflects_visible_entries() {
1459 let empty_dir = tempfile::tempdir().unwrap();
1460 let explorer = FileExplorer::new(empty_dir.path().to_path_buf(), vec![]);
1461 assert!(explorer.is_empty());
1462
1463 let tmp = temp_dir_with_files();
1464 let explorer2 = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1465 assert!(!explorer2.is_empty());
1466 }
1467
1468 #[test]
1469 fn entry_count_matches_entries_len() {
1470 let tmp = temp_dir_with_files();
1471 let explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1472 assert_eq!(explorer.entry_count(), explorer.entries.len());
1473 assert!(explorer.entry_count() > 0);
1474 }
1475
1476 #[test]
1477 fn search_query_empty_when_not_searching() {
1478 let tmp = temp_dir_with_files();
1479 let explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1480 assert!(!explorer.is_searching());
1481 assert_eq!(explorer.search_query(), "");
1482 }
1483
1484 #[test]
1487 fn search_is_case_insensitive() {
1488 let tmp = temp_dir_with_files();
1489 let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1490 explorer.handle_key(key(KeyCode::Char('/')));
1492 for c in "UBU".chars() {
1493 explorer.handle_key(key(KeyCode::Char(c)));
1494 }
1495 assert_eq!(explorer.entries.len(), 1);
1496 assert_eq!(explorer.entries[0].name, "ubuntu.iso");
1497 }
1498
1499 #[test]
1500 fn extension_filter_is_case_insensitive() {
1501 let tmp = tempfile::tempdir().unwrap();
1502 fs::write(tmp.path().join("disk.ISO"), b"data").unwrap();
1504 fs::write(tmp.path().join("other.txt"), b"text").unwrap();
1505
1506 let explorer = FileExplorer::new(tmp.path().to_path_buf(), vec!["iso".into()]);
1508 assert!(
1509 explorer.entries.iter().any(|e| e.name == "disk.ISO"),
1510 "upper-case extension should be matched by lower-case filter"
1511 );
1512 assert!(
1513 !explorer.entries.iter().any(|e| e.name == "other.txt"),
1514 "non-matching extension should be excluded"
1515 );
1516 }
1517
1518 #[test]
1521 fn builder_allow_extension_filters_entries() {
1522 let tmp = temp_dir_with_files();
1523 let explorer = FileExplorer::builder(tmp.path().to_path_buf())
1524 .allow_extension("iso")
1525 .build();
1526 assert!(explorer.entries.iter().any(|e| e.name == "ubuntu.iso"));
1527 assert!(!explorer.entries.iter().any(|e| e.name == "debian.img"));
1528 assert!(!explorer.entries.iter().any(|e| e.name == "readme.txt"));
1529 }
1530
1531 #[test]
1532 fn builder_show_hidden_shows_dotfiles() {
1533 let tmp = temp_dir_with_files();
1534 fs::write(tmp.path().join(".dotfile"), b"").unwrap();
1535
1536 let hidden_explorer = FileExplorer::builder(tmp.path().to_path_buf())
1537 .show_hidden(true)
1538 .build();
1539 assert!(hidden_explorer.entries.iter().any(|e| e.name == ".dotfile"));
1540
1541 let normal_explorer = FileExplorer::builder(tmp.path().to_path_buf())
1542 .show_hidden(false)
1543 .build();
1544 assert!(!normal_explorer.entries.iter().any(|e| e.name == ".dotfile"));
1545 }
1546
1547 #[test]
1548 fn set_extension_filter_updates_entries() {
1549 let tmp = temp_dir_with_files();
1550 let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1551 assert!(explorer.entries.iter().any(|e| e.name == "readme.txt"));
1553
1554 explorer.set_extension_filter(["iso"]);
1555 assert!(explorer.entries.iter().any(|e| e.name == "ubuntu.iso"));
1556 assert!(!explorer.entries.iter().any(|e| e.name == "readme.txt"));
1557 }
1558
1559 #[test]
1562 fn entry_icon_directory() {
1563 let entry = FsEntry {
1564 name: "mydir".into(),
1565 path: std::path::PathBuf::from("/mydir"),
1566 is_dir: true,
1567 size: None,
1568 extension: String::new(),
1569 };
1570 assert_eq!(entry_icon(&entry), "📁");
1571 }
1572
1573 #[test]
1574 fn entry_icon_recognises_known_extensions() {
1575 let make = |name: &str, ext: &str| FsEntry {
1576 name: name.into(),
1577 path: std::path::PathBuf::from(name),
1578 is_dir: false,
1579 size: Some(0),
1580 extension: ext.into(),
1581 };
1582
1583 assert_eq!(entry_icon(&make("archive.zip", "zip")), "📦");
1584 assert_eq!(entry_icon(&make("doc.pdf", "pdf")), "📕");
1585 assert_eq!(entry_icon(&make("notes.md", "md")), "📝");
1586 assert_eq!(entry_icon(&make("config.toml", "toml")), "⚙ ");
1587 assert_eq!(entry_icon(&make("main.rs", "rs")), "🦀");
1588 assert_eq!(entry_icon(&make("script.py", "py")), "🐍");
1589 assert_eq!(entry_icon(&make("page.html", "html")), "🌐");
1590 assert_eq!(entry_icon(&make("image.png", "png")), "🖼 ");
1591 assert_eq!(entry_icon(&make("video.mp4", "mp4")), "🎬");
1592 assert_eq!(entry_icon(&make("song.mp3", "mp3")), "🎵");
1593 assert_eq!(entry_icon(&make("unknown.xyz", "xyz")), "📄");
1594 }
1595
1596 #[test]
1599 fn fmt_size_exact_boundaries() {
1600 assert_eq!(fmt_size(1_024), "1.0 KB");
1602 assert_eq!(fmt_size(1_048_576), "1.0 MB");
1603 assert_eq!(fmt_size(1_073_741_824), "1.0 GB");
1604 assert_eq!(fmt_size(1_023), "1023 B");
1606 assert_eq!(fmt_size(1_047_552), "1023.0 KB"); }
1608
1609 #[test]
1612 fn toggle_mark_adds_entry_to_marked_set() {
1613 let dir = temp_dir_with_files();
1614 let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
1615 assert!(!explorer.entries.is_empty(), "need at least one entry");
1616
1617 explorer.toggle_mark();
1618
1619 assert_eq!(explorer.marked.len(), 1, "one entry should be marked");
1620 }
1621
1622 #[test]
1623 fn toggle_mark_removes_already_marked_entry() {
1624 let dir = temp_dir_with_files();
1625 let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
1626
1627 explorer.toggle_mark(); let cursor_after_first = explorer.cursor;
1629 explorer.cursor = 0; explorer.toggle_mark(); assert!(
1633 explorer.marked.is_empty(),
1634 "second toggle on same entry should unmark it"
1635 );
1636 let _ = cursor_after_first; }
1638
1639 #[test]
1640 fn toggle_mark_advances_cursor_down() {
1641 let dir = temp_dir_with_files();
1642 let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
1643 assert!(
1645 explorer.entries.len() >= 2,
1646 "fixture must have at least 2 entries"
1647 );
1648
1649 let before = explorer.cursor;
1650 explorer.toggle_mark();
1651
1652 assert_eq!(
1653 explorer.cursor,
1654 before + 1,
1655 "cursor should advance by one after toggle_mark"
1656 );
1657 }
1658
1659 #[test]
1660 fn toggle_mark_at_last_entry_does_not_overflow() {
1661 let dir = temp_dir_with_files();
1662 let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
1663 explorer.cursor = explorer.entries.len() - 1;
1664
1665 explorer.toggle_mark();
1666
1667 assert_eq!(
1668 explorer.cursor,
1669 explorer.entries.len() - 1,
1670 "cursor should stay at the last entry, not overflow"
1671 );
1672 }
1673
1674 #[test]
1675 fn clear_marks_empties_marked_set() {
1676 let dir = temp_dir_with_files();
1677 let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
1678
1679 explorer.toggle_mark();
1680 assert!(
1681 !explorer.marked.is_empty(),
1682 "should have a mark before clear"
1683 );
1684
1685 explorer.clear_marks();
1686
1687 assert!(
1688 explorer.marked.is_empty(),
1689 "marked set should be empty after clear_marks"
1690 );
1691 }
1692
1693 #[test]
1694 fn space_key_marks_current_entry() {
1695 let dir = temp_dir_with_files();
1696 let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
1697 assert!(!explorer.entries.is_empty(), "need at least one entry");
1698
1699 let outcome = explorer.handle_key(key(KeyCode::Char(' ')));
1700
1701 assert_eq!(
1702 outcome,
1703 ExplorerOutcome::Pending,
1704 "Space should return Pending"
1705 );
1706 assert_eq!(
1707 explorer.marked.len(),
1708 1,
1709 "Space should mark the current entry"
1710 );
1711 }
1712
1713 #[test]
1714 fn space_key_toggles_mark_off() {
1715 let dir = temp_dir_with_files();
1716 let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
1717
1718 explorer.handle_key(key(KeyCode::Char(' '))); explorer.cursor = 0; explorer.handle_key(key(KeyCode::Char(' '))); assert!(
1723 explorer.marked.is_empty(),
1724 "second Space on same entry should unmark it"
1725 );
1726 }
1727
1728 #[test]
1729 fn marks_cleared_when_ascending_to_parent() {
1730 let dir = temp_dir_with_files();
1731 let sub = dir.path().join("subdir");
1733 fs::write(sub.join("inner.txt"), b"inner").unwrap();
1734 let mut explorer = FileExplorer::new(sub.clone(), vec![]);
1735
1736 explorer.toggle_mark();
1737 assert!(
1738 !explorer.marked.is_empty(),
1739 "should have a mark before ascend"
1740 );
1741
1742 explorer.handle_key(key(KeyCode::Backspace));
1744
1745 assert!(
1746 explorer.marked.is_empty(),
1747 "marks should be cleared after ascending to parent"
1748 );
1749 }
1750
1751 #[test]
1752 fn marks_cleared_when_descending_into_directory() {
1753 let dir = temp_dir_with_files();
1754 let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
1755
1756 let sub_idx = explorer
1758 .entries
1759 .iter()
1760 .position(|e| e.is_dir)
1761 .expect("fixture has a subdir");
1762 explorer.cursor = sub_idx;
1763 explorer.toggle_mark();
1764 assert!(
1765 !explorer.marked.is_empty(),
1766 "should have a mark before descend"
1767 );
1768
1769 explorer.cursor = explorer
1771 .entries
1772 .iter()
1773 .position(|e| e.is_dir)
1774 .expect("fixture has a subdir");
1775
1776 explorer.handle_key(key(KeyCode::Enter));
1778
1779 assert!(
1780 explorer.marked.is_empty(),
1781 "marks should be cleared after descending into a directory"
1782 );
1783 }
1784
1785 #[test]
1786 fn can_mark_multiple_entries() {
1787 let dir = temp_dir_with_files();
1788 let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
1789 let total = explorer.entries.len();
1790 assert!(total >= 2, "fixture must have at least 2 entries");
1791
1792 for _ in 0..total {
1794 explorer.toggle_mark();
1795 }
1796
1797 assert_eq!(explorer.marked.len(), total, "all entries should be marked");
1798 }
1799
1800 #[test]
1801 fn marked_paths_returns_reference_to_marked_set() {
1802 let dir = temp_dir_with_files();
1803 let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
1804
1805 explorer.toggle_mark();
1806
1807 assert_eq!(
1808 explorer.marked_paths().len(),
1809 explorer.marked.len(),
1810 "marked_paths() should reflect the same set as the field"
1811 );
1812 }
1813}