Skip to main content

tui_file_explorer/
explorer.rs

1//! [`FileExplorer`] state machine, [`FileExplorerBuilder`], and filesystem helpers.
2//!
3//! ## Convenience methods
4//!
5//! Beyond [`FileExplorer::handle_key`] and [`FileExplorer::reload`], several
6//! small helpers make common patterns more ergonomic:
7//!
8//! ```no_run
9//! use tui_file_explorer::{FileExplorer, SortMode};
10//!
11//! let mut explorer = FileExplorer::builder(std::env::current_dir().unwrap())
12//!     .allow_extension("rs")
13//!     .sort_mode(SortMode::SizeDesc)
14//!     .build();
15//!
16//! // Inspect state without touching the raw fields
17//! println!("entries : {}", explorer.entry_count());
18//! println!("at root : {}", explorer.is_at_root());
19//! println!("status  : {}", explorer.status());
20//! println!("sort    : {}", explorer.sort_mode().label());
21//! println!("search  : {}", explorer.search_query());
22//!
23//! // Mutate configuration — both calls automatically reload the listing
24//! explorer.set_show_hidden(true);
25//! explorer.set_extension_filter(["rs", "toml"]);
26//! explorer.set_sort_mode(SortMode::Extension);
27//!
28//! // Navigate accepts anything path-like
29//! explorer.navigate_to("/tmp");
30//! ```
31
32use 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// ── FileExplorer ──────────────────────────────────────────────────────────────
43
44/// State for the file-explorer widget.
45///
46/// Keep one instance in your application state and pass a mutable reference
47/// to [`crate::render`] and [`FileExplorer::handle_key`] on every frame /
48/// key event.
49///
50/// # Example
51///
52/// ```no_run
53/// use tui_file_explorer::{FileExplorer, ExplorerOutcome};
54/// use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
55///
56/// let mut explorer = FileExplorer::new(
57///     std::env::current_dir().unwrap(),
58///     vec!["iso".into(), "img".into()],
59/// );
60///
61/// # let key = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
62/// match explorer.handle_key(key) {
63///     ExplorerOutcome::Selected(path) => println!("chosen: {}", path.display()),
64///     ExplorerOutcome::Dismissed      => println!("closed"),
65///     _                               => {}
66/// }
67/// ```
68#[derive(Debug)]
69pub struct FileExplorer {
70    /// The directory currently being browsed.
71    pub current_dir: PathBuf,
72    /// Sorted, search-filtered list of visible entries (dirs first, then files).
73    pub entries: Vec<FsEntry>,
74    /// Index of the highlighted entry.
75    pub cursor: usize,
76    /// Index of the first visible entry (for scrolling).
77    pub(crate) scroll_offset: usize,
78    /// Only files whose extension is in this list are selectable.
79    /// Directories are always shown and always navigable.
80    /// An empty `Vec` means *all* files are selectable.
81    pub extension_filter: Vec<String>,
82    /// Whether to show dotfiles / hidden entries.
83    pub show_hidden: bool,
84    /// Human-readable status message (shown in the footer).
85    pub(crate) status: String,
86    /// Current sort order for directory entries.
87    pub sort_mode: SortMode,
88    /// Current incremental-search query (empty = no search active).
89    pub search_query: String,
90    /// Whether the explorer is currently capturing keystrokes for search input.
91    pub search_active: bool,
92    /// Paths that have been space-marked for a multi-item operation.
93    pub marked: HashSet<PathBuf>,
94}
95
96impl FileExplorer {
97    // ── Construction ─────────────────────────────────────────────────────────
98
99    /// Create a new explorer starting at `initial_dir`.
100    ///
101    /// `extension_filter` is a list of lower-case extensions *without* the
102    /// leading dot (e.g. `vec!["iso".into(), "img".into()]`).
103    /// Pass an empty `Vec` to allow all files.
104    ///
105    /// For more configuration options use [`FileExplorer::builder`] instead.
106    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    /// Return a [`FileExplorerBuilder`] for constructing an explorer with
125    /// fine-grained configuration.
126    ///
127    /// # Example
128    ///
129    /// ```no_run
130    /// use tui_file_explorer::{FileExplorer, SortMode};
131    ///
132    /// let explorer = FileExplorer::builder(std::env::current_dir().unwrap())
133    ///     .extension_filter(vec!["rs".into(), "toml".into()])
134    ///     .show_hidden(true)
135    ///     .sort_mode(SortMode::SizeDesc)
136    ///     .build();
137    /// ```
138    pub fn builder(initial_dir: PathBuf) -> FileExplorerBuilder {
139        FileExplorerBuilder::new(initial_dir)
140    }
141
142    /// Navigate to `path`, resetting cursor, scroll, and any active search.
143    ///
144    /// Accepts anything that converts into a [`PathBuf`] — a [`PathBuf`],
145    /// `&Path`, `&str`, or `String` all work.
146    ///
147    /// ```no_run
148    /// use tui_file_explorer::FileExplorer;
149    ///
150    /// let mut explorer = FileExplorer::new(std::env::current_dir().unwrap(), vec![]);
151    /// explorer.navigate_to("/tmp");
152    /// explorer.navigate_to(std::path::Path::new("/home"));
153    /// ```
154    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    // ── Key handling ─────────────────────────────────────────────────────────
162
163    /// Process a single keyboard event and return the [`ExplorerOutcome`].
164    ///
165    /// Call this from your application's key-handling function and act on
166    /// [`ExplorerOutcome::Selected`] / [`ExplorerOutcome::Dismissed`].
167    /// Return the set of currently marked paths (for multi-item operations).
168    pub fn marked_paths(&self) -> &HashSet<PathBuf> {
169        &self.marked
170    }
171
172    /// Toggle the space-mark on the currently highlighted entry and move
173    /// the cursor down by one.
174    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    /// Clear all space-marks (called after a multi-delete or on navigation).
187    pub fn clear_marks(&mut self) {
188        self.marked.clear();
189    }
190
191    pub fn handle_key(&mut self, key: KeyEvent) -> ExplorerOutcome {
192        // ── Search-mode interception ──────────────────────────────────────────
193        // When search is active, printable characters feed the query rather than
194        // triggering navigation shortcuts.  Navigation keys (arrows, Enter, etc.)
195        // fall through to the normal handler below so the list remains usable
196        // while filtering.
197        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                        // Nothing left to erase — deactivate search.
209                        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                    // First Esc cancels search; second Esc (when already
220                    // inactive) dismisses the explorer entirely.
221                    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                _ => {} // navigation keys fall through
229            }
230        }
231
232        match key.code {
233            // ── Dismiss ──────────────────────────────────────────────────────
234            KeyCode::Esc => ExplorerOutcome::Dismissed,
235
236            // ── Vim-style quit ───────────────────────────────────────────────
237            KeyCode::Char('q') if key.modifiers.is_empty() => ExplorerOutcome::Dismissed,
238
239            // ── Move up ──────────────────────────────────────────────────────
240            KeyCode::Up | KeyCode::Char('k') => {
241                self.move_up();
242                ExplorerOutcome::Pending
243            }
244
245            // ── Move down ────────────────────────────────────────────────────
246            KeyCode::Down | KeyCode::Char('j') => {
247                self.move_down();
248                ExplorerOutcome::Pending
249            }
250
251            // ── Page up ──────────────────────────────────────────────────────
252            KeyCode::PageUp => {
253                for _ in 0..10 {
254                    self.move_up();
255                }
256                ExplorerOutcome::Pending
257            }
258
259            // ── Page down ────────────────────────────────────────────────────
260            KeyCode::PageDown => {
261                for _ in 0..10 {
262                    self.move_down();
263                }
264                ExplorerOutcome::Pending
265            }
266
267            // ── Jump to top ──────────────────────────────────────────────────
268            KeyCode::Home | KeyCode::Char('g') => {
269                self.cursor = 0;
270                self.scroll_offset = 0;
271                ExplorerOutcome::Pending
272            }
273
274            // ── Jump to bottom ───────────────────────────────────────────────
275            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            // ── Ascend (go to parent) ─────────────────────────────────────────
283            KeyCode::Backspace | KeyCode::Left | KeyCode::Char('h') => {
284                self.ascend();
285                ExplorerOutcome::Pending
286            }
287
288            // ── Confirm / descend ─────────────────────────────────────────────
289            KeyCode::Enter | KeyCode::Right | KeyCode::Char('l') => self.confirm(),
290
291            // ── Toggle hidden files ───────────────────────────────────────────
292            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            // ── Activate incremental search ───────────────────────────────────
301            KeyCode::Char('/') if key.modifiers.is_empty() => {
302                self.search_active = true;
303                ExplorerOutcome::Pending
304            }
305
306            // ── Cycle sort mode ───────────────────────────────────────────────
307            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            // ── Toggle space-mark on current entry ────────────────────────────
316            KeyCode::Char(' ') => {
317                self.toggle_mark();
318                ExplorerOutcome::Pending
319            }
320
321            _ => ExplorerOutcome::Unhandled,
322        }
323    }
324
325    // ── Queries ───────────────────────────────────────────────────────────────
326
327    /// The currently highlighted [`FsEntry`], or `None` if the list is empty.
328    pub fn current_entry(&self) -> Option<&FsEntry> {
329        self.entries.get(self.cursor)
330    }
331
332    // ── Inspectors ────────────────────────────────────────────────────────────
333
334    /// Returns `true` when the explorer is at the filesystem root and cannot
335    /// ascend any further.
336    ///
337    /// ```no_run
338    /// use tui_file_explorer::FileExplorer;
339    ///
340    /// let mut explorer = FileExplorer::new(std::path::PathBuf::from("/"), vec![]);
341    /// assert!(explorer.is_at_root());
342    /// ```
343    pub fn is_at_root(&self) -> bool {
344        self.current_dir.parent().is_none()
345    }
346
347    /// Returns `true` when the current directory contains no visible entries.
348    ///
349    /// This reflects the *filtered, visible* set — hidden files are excluded
350    /// unless `show_hidden` is `true`, and an active search query narrows
351    /// the set further.
352    pub fn is_empty(&self) -> bool {
353        self.entries.is_empty()
354    }
355
356    /// The number of visible entries in the current directory.
357    ///
358    /// Equivalent to `explorer.entries.len()` but reads more naturally in
359    /// condition checks.
360    pub fn entry_count(&self) -> usize {
361        self.entries.len()
362    }
363
364    /// The current human-readable status message.
365    ///
366    /// The status is set by the widget when an error occurs (e.g. attempting
367    /// to select a file that does not match the extension filter) and is
368    /// cleared on the next successful navigation.  Returns an empty string
369    /// when there is nothing to report.
370    pub fn status(&self) -> &str {
371        &self.status
372    }
373
374    /// The current sort mode.
375    ///
376    /// ```
377    /// use tui_file_explorer::{FileExplorer, SortMode};
378    ///
379    /// let explorer = FileExplorer::new(std::path::PathBuf::from("/tmp"), vec![]);
380    /// assert_eq!(explorer.sort_mode(), SortMode::Name);
381    /// ```
382    pub fn sort_mode(&self) -> SortMode {
383        self.sort_mode
384    }
385
386    /// The current incremental-search query string.
387    ///
388    /// Returns an empty string when no search is active.
389    pub fn search_query(&self) -> &str {
390        &self.search_query
391    }
392
393    /// Returns `true` when the explorer is actively capturing keystrokes for
394    /// incremental search input.
395    pub fn is_searching(&self) -> bool {
396        self.search_active
397    }
398
399    // ── Mutating setters ──────────────────────────────────────────────────────
400
401    /// Set whether hidden (dot-file) entries are visible and reload the
402    /// directory listing immediately.
403    ///
404    /// The user can also toggle this at runtime with the `.` key.
405    ///
406    /// ```no_run
407    /// use tui_file_explorer::FileExplorer;
408    ///
409    /// let mut explorer = FileExplorer::new(std::env::current_dir().unwrap(), vec![]);
410    /// explorer.set_show_hidden(true);
411    /// assert!(explorer.show_hidden);
412    /// ```
413    pub fn set_show_hidden(&mut self, show: bool) {
414        self.show_hidden = show;
415        self.reload();
416    }
417
418    /// Replace the extension filter and reload the directory listing
419    /// immediately.
420    ///
421    /// Accepts any iterable of values that convert to [`String`] — plain
422    /// `&str` slices, `String` values, and arrays all work:
423    ///
424    /// ```no_run
425    /// use tui_file_explorer::FileExplorer;
426    ///
427    /// let mut explorer = FileExplorer::new(std::env::current_dir().unwrap(), vec![]);
428    ///
429    /// // Array of &str — no .into() needed
430    /// explorer.set_extension_filter(["rs", "toml"]);
431    ///
432    /// // Vec<String>
433    /// explorer.set_extension_filter(vec!["iso".to_string(), "img".to_string()]);
434    ///
435    /// // Empty — allow all files
436    /// explorer.set_extension_filter([] as [&str; 0]);
437    /// ```
438    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    /// Change the sort mode and reload the directory listing immediately.
448    ///
449    /// The user can also cycle through modes at runtime with the `s` key.
450    ///
451    /// ```no_run
452    /// use tui_file_explorer::{FileExplorer, SortMode};
453    ///
454    /// let mut explorer = FileExplorer::new(std::env::current_dir().unwrap(), vec![]);
455    /// explorer.set_sort_mode(SortMode::SizeDesc);
456    /// assert_eq!(explorer.sort_mode(), SortMode::SizeDesc);
457    /// ```
458    pub fn set_sort_mode(&mut self, mode: SortMode) {
459        self.sort_mode = mode;
460        self.reload();
461    }
462
463    // ── Internal navigation helpers ───────────────────────────────────────────
464
465    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            // Clear search and marks when navigating to a different directory.
484            self.search_active = false;
485            self.search_query.clear();
486            self.marked.clear();
487            self.reload();
488            // Try to land the cursor on the directory we just came from.
489            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            // Clear search and marks when descending into a subdirectory.
505            self.search_active = false;
506            self.search_query.clear();
507            self.marked.clear();
508            self.navigate_to(path);
509            ExplorerOutcome::Pending
510        } else {
511            // All visible files already passed the extension filter in load_entries,
512            // so every non-directory entry is unconditionally selectable here.
513            ExplorerOutcome::Selected(entry.path.clone())
514        }
515    }
516
517    // ── Directory loading ─────────────────────────────────────────────────────
518
519    /// Re-read the current directory from the filesystem.
520    ///
521    /// Called automatically after every navigation action or configuration
522    /// change.  Callers can invoke it manually after external filesystem
523    /// mutations (e.g. a file was created or deleted in the watched directory).
524    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
536// ── FileExplorerBuilder ───────────────────────────────────────────────────────
537
538/// Builder for [`FileExplorer`].
539///
540/// Obtain one via [`FileExplorer::builder`].
541///
542/// # Example
543///
544/// ```no_run
545/// use tui_file_explorer::{FileExplorer, SortMode};
546///
547/// let explorer = FileExplorer::builder(std::env::current_dir().unwrap())
548///     .allow_extension("iso")
549///     .allow_extension("img")
550///     .show_hidden(false)
551///     .sort_mode(SortMode::SizeDesc)
552///     .build();
553/// ```
554pub struct FileExplorerBuilder {
555    initial_dir: PathBuf,
556    extension_filter: Vec<String>,
557    show_hidden: bool,
558    sort_mode: SortMode,
559}
560
561impl FileExplorerBuilder {
562    /// Create a builder rooted at `initial_dir`.
563    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    /// Set the full extension filter list at once.
573    ///
574    /// Replaces any extensions added with [`allow_extension`](Self::allow_extension).
575    ///
576    /// ```no_run
577    /// use tui_file_explorer::FileExplorer;
578    ///
579    /// let explorer = FileExplorer::builder(std::env::current_dir().unwrap())
580    ///     .extension_filter(vec!["iso".into(), "img".into()])
581    ///     .build();
582    /// ```
583    pub fn extension_filter(mut self, filter: Vec<String>) -> Self {
584        self.extension_filter = filter;
585        self
586    }
587
588    /// Append a single allowed extension.
589    ///
590    /// Call multiple times to build up the filter:
591    ///
592    /// ```no_run
593    /// use tui_file_explorer::FileExplorer;
594    ///
595    /// let explorer = FileExplorer::builder(std::env::current_dir().unwrap())
596    ///     .allow_extension("iso")
597    ///     .allow_extension("img")
598    ///     .build();
599    /// ```
600    pub fn allow_extension(mut self, ext: impl Into<String>) -> Self {
601        self.extension_filter.push(ext.into());
602        self
603    }
604
605    /// Set whether hidden (dot-file) entries are shown on startup.
606    ///
607    /// ```no_run
608    /// use tui_file_explorer::FileExplorer;
609    ///
610    /// let explorer = FileExplorer::builder(std::env::current_dir().unwrap())
611    ///     .show_hidden(true)
612    ///     .build();
613    /// ```
614    pub fn show_hidden(mut self, show: bool) -> Self {
615        self.show_hidden = show;
616        self
617    }
618
619    /// Set the initial sort mode.
620    ///
621    /// ```no_run
622    /// use tui_file_explorer::{FileExplorer, SortMode};
623    ///
624    /// let explorer = FileExplorer::builder(std::env::current_dir().unwrap())
625    ///     .sort_mode(SortMode::SizeDesc)
626    ///     .build();
627    /// ```
628    pub fn sort_mode(mut self, mode: SortMode) -> Self {
629        self.sort_mode = mode;
630        self
631    }
632
633    /// Consume the builder and return a fully initialised [`FileExplorer`].
634    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
653// ── Directory loader ──────────────────────────────────────────────────────────
654
655/// Read `dir`, apply all active filters, sort entries, and return the result.
656///
657/// * Hidden entries are excluded unless `show_hidden` is `true`.
658/// * When `ext_filter` is non-empty only files whose extension is in the list
659///   are included (directories are always included).
660/// * When `search_query` is non-empty only entries whose name contains the
661///   query (case-insensitive) are included.
662/// * Entries are sorted according to `sort_mode`; directories are always
663///   placed before files regardless of the sort mode.
664pub(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        // Extension filter — applied to files only; directories always pass.
697        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        // Search query filter — applied to both files and directories.
707        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    // Sort each group according to the active mode.
736    // Directories always sort alphabetically among themselves.
737    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            // Largest first; treat missing size as 0.
745            files.sort_by(|a, b| b.size.unwrap_or(0).cmp(&a.size.unwrap_or(0)));
746        }
747        SortMode::Extension => {
748            // By extension first, then by name within each extension group.
749            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 first, then sorted files.
758    dirs.extend(files);
759    dirs
760}
761
762// ── Utilities ─────────────────────────────────────────────────────────────────
763
764/// Choose a Unicode icon for a directory entry.
765///
766/// Exposed as a public helper so that custom renderers can reuse the same
767/// icon mapping without duplicating the match table.
768pub fn entry_icon(entry: &FsEntry) -> &'static str {
769    if entry.is_dir {
770        return "📁";
771    }
772    match entry.extension.as_str() {
773        // Disk images
774        "iso" | "dmg" => "💿",
775        "img" => "🖼 ",
776        // Archives
777        "zip" | "gz" | "xz" | "zst" | "bz2" | "tar" | "7z" | "rar" | "tgz" | "tbz2" => "📦",
778        // Documents
779        "pdf" => "📕",
780        "txt" | "log" | "rst" => "📄",
781        "md" | "mdx" | "markdown" => "📝",
782        // Config / data
783        "toml" | "yaml" | "yml" | "json" | "xml" | "ini" | "cfg" | "conf" | "env" => "⚙ ",
784        "lock" => "🔒",
785        // Source — languages
786        "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        // Shell scripts
805        "sh" | "bash" | "zsh" | "fish" | "nu" => "📜",
806        "bat" | "cmd" | "ps1" => "📜",
807        // Web
808        "html" | "htm" | "xhtml" => "🌐",
809        "css" | "scss" | "sass" | "less" => "🎨",
810        "svg" => "🎨",
811        // Images (raster)
812        "png" | "jpg" | "jpeg" | "gif" | "bmp" | "webp" | "ico" | "tiff" | "tif" | "avif"
813        | "heic" | "heif" => "🖼 ",
814        // Video
815        "mp4" | "mkv" | "avi" | "mov" | "webm" | "flv" | "wmv" | "m4v" => "🎬",
816        // Audio
817        "mp3" | "wav" | "flac" | "ogg" | "aac" | "m4a" | "opus" | "wma" => "🎵",
818        // Fonts
819        "ttf" | "otf" | "woff" | "woff2" | "eot" => "🔤",
820        // Executables / binaries
821        "exe" | "msi" | "deb" | "rpm" | "appimage" | "apk" => "⚙ ",
822        _ => "📄",
823    }
824}
825
826/// Format a byte count as a human-readable size string.
827///
828/// Exposed as a public helper so that custom renderers can reuse the same
829/// formatting logic without reimplementing it.
830///
831/// ```
832/// use tui_file_explorer::fmt_size;
833///
834/// assert_eq!(fmt_size(512),           "512 B");
835/// assert_eq!(fmt_size(1_536),         "1.5 KB");
836/// assert_eq!(fmt_size(2_097_152),     "2.0 MB");
837/// assert_eq!(fmt_size(1_073_741_824), "1.0 GB");
838/// ```
839pub 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// ── Tests ─────────────────────────────────────────────────────────────────────
855
856#[cfg(test)]
857mod tests {
858    use super::*;
859    use crossterm::event::{KeyEvent, KeyModifiers};
860    use std::fs;
861    use tempfile::TempDir;
862
863    // ── Fixtures ──────────────────────────────────────────────────────────────
864
865    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    // ── Existing tests ────────────────────────────────────────────────────────
879
880    #[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        // .txt excluded by filter
892        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        // Place cursor on the directory (dirs sort first).
968        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        // The real selectability contract lives in load_entries: only files
1026        // whose extension matches the filter appear in entries at all.
1027        let tmp = temp_dir_with_files();
1028        let explorer = FileExplorer::new(tmp.path().to_path_buf(), vec!["iso".into()]);
1029
1030        // Matching file is present.
1031        assert!(
1032            explorer.entries.iter().any(|e| e.name == "ubuntu.iso"),
1033            "iso file should appear in entries"
1034        );
1035        // Non-matching file is absent.
1036        assert!(
1037            !explorer.entries.iter().any(|e| e.name == "debian.img"),
1038            "img file should be excluded by filter"
1039        );
1040        // Directories are always present regardless of the filter.
1041        assert!(
1042            explorer.entries.iter().any(|e| e.is_dir),
1043            "directories should always be visible"
1044        );
1045        // Every visible non-directory entry has the expected extension.
1046        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    // ── Search tests ──────────────────────────────────────────────────────────
1087
1088    #[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        // Activate search and type a query that matches only ubuntu.iso
1115        explorer.handle_key(key(KeyCode::Char('/')));
1116        for c in "ubu".chars() {
1117            explorer.handle_key(key(KeyCode::Char(c)));
1118        }
1119        // Only ubuntu.iso (and nothing else) should be visible.
1120        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        // Backspace on an empty query deactivates search.
1143        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        // Navigate into subdir
1182        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        // Manually inject search state (simulates user having typed a query
1195        // while already inside subdir, then pressing the ascend key).
1196        // We bypass handle_key so the search interception doesn't consume
1197        // the Backspace — instead we call ascend() via the Left arrow which
1198        // is only handled by the non-search branch.
1199        explorer.search_active = true;
1200        explorer.search_query = "foo".into();
1201
1202        // Left arrow is not intercepted by the search block, so it reaches
1203        // the ascend() arm in the main match.
1204        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    // ── Sort tests ────────────────────────────────────────────────────────────
1223
1224    #[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        // Create files with clearly different sizes.
1248        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        // All rs entries should appear before toml entries (r < t).
1280        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        // Entries should still be present after the reload triggered by set_sort_mode.
1301        assert!(!explorer.entries.is_empty());
1302    }
1303
1304    // ── Vim key tests ─────────────────────────────────────────────────────────
1305
1306    #[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    // ── Page / jump key tests ─────────────────────────────────────────────────
1367
1368    #[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    // ── Root / status tests ───────────────────────────────────────────────────
1432
1433    #[test]
1434    fn ascend_at_root_sets_status() {
1435        // Use "/" as a reliable filesystem root on macOS/Linux.
1436        let root = std::path::PathBuf::from("/");
1437        let mut explorer = FileExplorer::new(root.clone(), vec![]);
1438        assert!(explorer.is_at_root());
1439        // Still at root after attempted ascend.
1440        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    // ── Accessor tests ────────────────────────────────────────────────────────
1456
1457    #[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    // ── Case-insensitivity tests ──────────────────────────────────────────────
1485
1486    #[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        // Type "UBU" in uppercase — should still match "ubuntu.iso".
1491        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        // File whose on-disk extension is upper-case.
1503        fs::write(tmp.path().join("disk.ISO"), b"data").unwrap();
1504        fs::write(tmp.path().join("other.txt"), b"text").unwrap();
1505
1506        // Filter expressed in lower-case should still match the upper-case ext.
1507        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    // ── Builder tests ─────────────────────────────────────────────────────────
1519
1520    #[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        // All files visible with no filter.
1552        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    // ── entry_icon tests ──────────────────────────────────────────────────────
1560
1561    #[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    // ── fmt_size boundary tests ───────────────────────────────────────────────
1597
1598    #[test]
1599    fn fmt_size_exact_boundaries() {
1600        // Exact powers of 1024.
1601        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        // Just below each boundary stays in the lower unit.
1605        assert_eq!(fmt_size(1_023), "1023 B");
1606        assert_eq!(fmt_size(1_047_552), "1023.0 KB"); // 1023 * 1024
1607    }
1608
1609    // ── toggle_mark / clear_marks / Space key ─────────────────────────────────
1610
1611    #[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(); // mark
1628        let cursor_after_first = explorer.cursor;
1629        explorer.cursor = 0; // reset to the same entry
1630        explorer.toggle_mark(); // unmark
1631
1632        assert!(
1633            explorer.marked.is_empty(),
1634            "second toggle on same entry should unmark it"
1635        );
1636        let _ = cursor_after_first; // suppress unused warning
1637    }
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        // Ensure there are at least two entries so the cursor can advance.
1644        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(' '))); // mark → cursor moves down
1719        explorer.cursor = 0; // reset to entry 0
1720        explorer.handle_key(key(KeyCode::Char(' '))); // unmark
1721
1722        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        // Start inside the subdir so we can ascend.
1732        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        // Ascend via Backspace.
1743        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        // Mark the subdirectory entry.
1757        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        // Reset cursor back to the directory entry (toggle_mark advanced it).
1770        explorer.cursor = explorer
1771            .entries
1772            .iter()
1773            .position(|e| e.is_dir)
1774            .expect("fixture has a subdir");
1775
1776        // Descend into the subdirectory — confirm() clears marks.
1777        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        // Mark every entry.
1793        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}