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    /// The name of the currently active theme (used in the header display).
73    pub theme_name: String,
74    /// The label of the currently configured editor (used in the header display).
75    pub editor_name: String,
76    /// Sorted, search-filtered list of visible entries (dirs first, then files).
77    pub entries: Vec<FsEntry>,
78    /// Index of the highlighted entry.
79    pub cursor: usize,
80    /// Index of the first visible entry (for scrolling).
81    pub(crate) scroll_offset: usize,
82    /// Only files whose extension is in this list are selectable.
83    /// Directories are always shown and always navigable.
84    /// An empty `Vec` means *all* files are selectable.
85    pub extension_filter: Vec<String>,
86    /// Whether to show dotfiles / hidden entries.
87    pub show_hidden: bool,
88    /// Human-readable status message (shown in the footer).
89    pub(crate) status: String,
90    /// Current sort order for directory entries.
91    pub sort_mode: SortMode,
92    /// Current incremental-search query (empty = no search active).
93    pub search_query: String,
94    /// Whether the explorer is currently capturing keystrokes for search input.
95    pub search_active: bool,
96    /// Paths that have been space-marked for a multi-item operation.
97    pub marked: HashSet<PathBuf>,
98    /// Whether the explorer is currently capturing keystrokes for a new folder name.
99    pub mkdir_active: bool,
100    /// The folder name being typed when `mkdir_active` is true.
101    pub mkdir_input: String,
102    /// Whether the explorer is currently capturing keystrokes for a new file name.
103    pub touch_active: bool,
104    /// The file name being typed when `touch_active` is true.
105    pub touch_input: String,
106    /// Whether the explorer is currently capturing keystrokes for a rename operation.
107    pub rename_active: bool,
108    /// The new name being typed when `rename_active` is true.
109    pub rename_input: String,
110}
111
112// ── handle_input_mode! ────────────────────────────────────────────────────────
113//
114// De-duplicates the character-input boilerplate shared by rename_active,
115// touch_active, and mkdir_active.
116//
117// Parameters
118// ----------
119// $self     – the `&mut self` receiver (ident)
120// $key      – the `KeyEvent` local (ident, taken by value in handle_key)
121// $active   – the boolean field name (e.g. `rename_active`)
122// $input    – the String field name  (e.g. `rename_input`)
123// $on_enter – an expression that is spliced in as the `KeyCode::Enter` arm
124//             body.  It must arrange for `$active` to be set to false,
125//             `$input` to be cleared, and for the function to return.
126//
127// The macro wraps the whole match in `if $self.$active { … }` so execution
128// falls through when the mode is inactive.
129
130macro_rules! handle_input_mode {
131    ($self:ident, $key:ident, $active:ident, $input:ident, $on_enter:expr) => {
132        if $self.$active {
133            match $key.code {
134                // Printable character (no modifiers, or Shift only) → append.
135                KeyCode::Char(c)
136                    if $key.modifiers.is_empty()
137                        || $key.modifiers == crossterm::event::KeyModifiers::SHIFT =>
138                {
139                    $self.$input.push(c);
140                    return ExplorerOutcome::Pending;
141                }
142                // Backspace → pop last char.
143                KeyCode::Backspace => {
144                    $self.$input.pop();
145                    return ExplorerOutcome::Pending;
146                }
147                // Enter → caller-supplied logic.
148                KeyCode::Enter => $on_enter,
149                // Esc → cancel without committing.
150                KeyCode::Esc => {
151                    $self.$active = false;
152                    $self.$input.clear();
153                    return ExplorerOutcome::Pending;
154                }
155                // Any other key → stay in mode, consume the event.
156                _ => return ExplorerOutcome::Pending,
157            }
158        }
159    };
160}
161
162impl FileExplorer {
163    // ── Construction ─────────────────────────────────────────────────────────
164
165    /// Create a new explorer starting at `initial_dir`.
166    ///
167    /// `extension_filter` is a list of lower-case extensions *without* the
168    /// leading dot (e.g. `vec!["iso".into(), "img".into()]`).
169    /// Pass an empty `Vec` to allow all files.
170    ///
171    /// For more configuration options use [`FileExplorer::builder`] instead.
172    pub fn new(initial_dir: PathBuf, extension_filter: Vec<String>) -> Self {
173        let mut explorer = Self {
174            current_dir: initial_dir,
175            entries: Vec::new(),
176            cursor: 0,
177            scroll_offset: 0,
178            extension_filter,
179            show_hidden: false,
180            status: String::new(),
181            sort_mode: SortMode::default(),
182            search_query: String::new(),
183            search_active: false,
184            marked: HashSet::new(),
185            mkdir_active: false,
186            mkdir_input: String::new(),
187            touch_active: false,
188            touch_input: String::new(),
189            rename_active: false,
190            rename_input: String::new(),
191            theme_name: String::new(),
192            editor_name: String::new(),
193        };
194        explorer.reload();
195        explorer
196    }
197
198    /// Return a [`FileExplorerBuilder`] for constructing an explorer with
199    /// fine-grained configuration.
200    ///
201    /// # Example
202    ///
203    /// ```no_run
204    /// use tui_file_explorer::{FileExplorer, SortMode};
205    ///
206    /// let explorer = FileExplorer::builder(std::env::current_dir().unwrap())
207    ///     .extension_filter(vec!["rs".into(), "toml".into()])
208    ///     .show_hidden(true)
209    ///     .sort_mode(SortMode::SizeDesc)
210    ///     .build();
211    /// ```
212    pub fn builder(initial_dir: PathBuf) -> FileExplorerBuilder {
213        FileExplorerBuilder::new(initial_dir)
214    }
215
216    /// Navigate to `path`, resetting cursor, scroll, and any active search.
217    ///
218    /// Accepts anything that converts into a [`PathBuf`] — a [`PathBuf`],
219    /// `&Path`, `&str`, or `String` all work.
220    ///
221    /// ```no_run
222    /// use tui_file_explorer::FileExplorer;
223    ///
224    /// let mut explorer = FileExplorer::new(std::env::current_dir().unwrap(), vec![]);
225    /// explorer.navigate_to("/tmp");
226    /// explorer.navigate_to(std::path::Path::new("/home"));
227    /// ```
228    pub fn navigate_to(&mut self, path: impl Into<PathBuf>) {
229        self.current_dir = path.into();
230        self.cursor = 0;
231        self.scroll_offset = 0;
232        self.reload();
233    }
234
235    // ── Key handling ─────────────────────────────────────────────────────────
236
237    /// Process a single keyboard event and return the [`ExplorerOutcome`].
238    ///
239    /// Call this from your application's key-handling function and act on
240    /// [`ExplorerOutcome::Selected`] / [`ExplorerOutcome::Dismissed`].
241    /// Return the set of currently marked paths (for multi-item operations).
242    pub fn marked_paths(&self) -> &HashSet<PathBuf> {
243        &self.marked
244    }
245
246    /// Toggle the space-mark on the currently highlighted entry and move
247    /// the cursor down by one.
248    pub fn toggle_mark(&mut self) {
249        if let Some(entry) = self.entries.get(self.cursor) {
250            let path = entry.path.clone();
251            if self.marked.contains(&path) {
252                self.marked.remove(&path);
253            } else {
254                self.marked.insert(path);
255            }
256        }
257        self.move_down();
258    }
259
260    /// Clear all space-marks (called after a multi-delete or on navigation).
261    pub fn clear_marks(&mut self) {
262        self.marked.clear();
263    }
264
265    pub fn handle_key(&mut self, key: KeyEvent) -> ExplorerOutcome {
266        // Only react to key-press events.  On Windows (and terminals that
267        // negotiate the kitty keyboard protocol) crossterm delivers both
268        // Press *and* Release events for every physical key-press.  Without
269        // this guard the handler runs twice per key — which double-toggles
270        // marks, double-navigates, etc.
271        if key.kind != crossterm::event::KeyEventKind::Press {
272            return ExplorerOutcome::Pending;
273        }
274
275        // ── Rename-mode interception ──────────────────────────────────────────
276        // When rename mode is active every printable character feeds the new
277        // name.  Enter confirms the rename; Esc cancels.
278        handle_input_mode!(self, key, rename_active, rename_input, {
279            let new_name = self.rename_input.trim().to_string();
280            self.rename_active = false;
281            self.rename_input.clear();
282            if new_name.is_empty() {
283                return ExplorerOutcome::Pending;
284            }
285            // Grab the source path before we reload.
286            let src = match self.entries.get(self.cursor) {
287                Some(e) => e.path.clone(),
288                None => return ExplorerOutcome::Pending,
289            };
290            let dst = self.current_dir.join(&new_name);
291            match std::fs::rename(&src, &dst) {
292                Ok(()) => {
293                    self.reload();
294                    // Move cursor to the renamed entry.
295                    if let Some(idx) = self.entries.iter().position(|e| e.path == dst) {
296                        self.cursor = idx;
297                    }
298                    return ExplorerOutcome::RenameCompleted(dst);
299                }
300                Err(e) => {
301                    self.status = format!("rename failed: {e}");
302                    return ExplorerOutcome::Pending;
303                }
304            }
305        });
306
307        // ── Touch-mode interception ───────────────────────────────────────────
308        // When touch mode is active every printable character feeds the new
309        // file name.  Enter confirms creation; Esc cancels.
310        handle_input_mode!(self, key, touch_active, touch_input, {
311            let name = self.touch_input.trim().to_string();
312            self.touch_active = false;
313            self.touch_input.clear();
314            if name.is_empty() {
315                return ExplorerOutcome::Pending;
316            }
317            let new_file = self.current_dir.join(&name);
318            // Create parent dirs if the name contains path separators,
319            // then create (or truncate-to-zero) the file itself.
320            let create_result = (|| -> std::io::Result<()> {
321                if let Some(parent) = new_file.parent() {
322                    std::fs::create_dir_all(parent)?;
323                }
324                // OpenOptions::create(true) + write(true) creates the
325                // file if absent and leaves an existing one untouched.
326                std::fs::OpenOptions::new()
327                    .write(true)
328                    .create(true)
329                    .truncate(false)
330                    .open(&new_file)?;
331                Ok(())
332            })();
333            match create_result {
334                Ok(()) => {
335                    self.reload();
336                    // Move cursor to the newly created file.
337                    if let Some(idx) = self.entries.iter().position(|e| e.path == new_file) {
338                        self.cursor = idx;
339                    }
340                    return ExplorerOutcome::TouchCreated(new_file);
341                }
342                Err(e) => {
343                    self.status = format!("touch failed: {e}");
344                    return ExplorerOutcome::Pending;
345                }
346            }
347        });
348
349        // ── Mkdir-mode interception ───────────────────────────────────────────
350        // When mkdir mode is active every printable character feeds the new
351        // folder name.  Enter confirms creation; Esc cancels.
352        handle_input_mode!(self, key, mkdir_active, mkdir_input, {
353            let name = self.mkdir_input.trim().to_string();
354            self.mkdir_active = false;
355            self.mkdir_input.clear();
356            if name.is_empty() {
357                return ExplorerOutcome::Pending;
358            }
359            let new_dir = self.current_dir.join(&name);
360            match std::fs::create_dir_all(&new_dir) {
361                Ok(()) => {
362                    self.reload();
363                    // Move cursor to the newly created directory.
364                    if let Some(idx) = self.entries.iter().position(|e| e.path == new_dir) {
365                        self.cursor = idx;
366                    }
367                    return ExplorerOutcome::MkdirCreated(new_dir);
368                }
369                Err(e) => {
370                    self.status = format!("mkdir failed: {e}");
371                    return ExplorerOutcome::Pending;
372                }
373            }
374        });
375
376        // ── Search-mode interception ──────────────────────────────────────────
377        // When search is active, printable characters feed the query rather than
378        // triggering navigation shortcuts.  Navigation keys (arrows, Enter, etc.)
379        // fall through to the normal handler below so the list remains usable
380        // while filtering.
381        if self.search_active {
382            match key.code {
383                KeyCode::Char(c) if key.modifiers.is_empty() => {
384                    self.search_query.push(c);
385                    self.cursor = 0;
386                    self.scroll_offset = 0;
387                    self.reload();
388                    return ExplorerOutcome::Pending;
389                }
390                KeyCode::Backspace => {
391                    if self.search_query.is_empty() {
392                        // Nothing left to erase — deactivate search.
393                        self.search_active = false;
394                    } else {
395                        self.search_query.pop();
396                        self.cursor = 0;
397                        self.scroll_offset = 0;
398                        self.reload();
399                    }
400                    return ExplorerOutcome::Pending;
401                }
402                KeyCode::Esc => {
403                    // First Esc cancels search; second Esc (when already
404                    // inactive) dismisses the explorer entirely.
405                    self.search_active = false;
406                    self.search_query.clear();
407                    self.cursor = 0;
408                    self.scroll_offset = 0;
409                    self.reload();
410                    return ExplorerOutcome::Pending;
411                }
412                _ => {} // navigation keys fall through
413            }
414        }
415
416        match key.code {
417            // ── Dismiss ──────────────────────────────────────────────────────
418            KeyCode::Esc => ExplorerOutcome::Dismissed,
419
420            // ── Vim-style quit ───────────────────────────────────────────────
421            KeyCode::Char('q') if key.modifiers.is_empty() => ExplorerOutcome::Dismissed,
422
423            // ── Move up ──────────────────────────────────────────────────────
424            KeyCode::Up | KeyCode::Char('k') => {
425                self.move_up();
426                ExplorerOutcome::Pending
427            }
428
429            // ── Move down ────────────────────────────────────────────────────
430            KeyCode::Down | KeyCode::Char('j') => {
431                self.move_down();
432                ExplorerOutcome::Pending
433            }
434
435            // ── Page up ──────────────────────────────────────────────────────
436            KeyCode::PageUp => {
437                for _ in 0..10 {
438                    self.move_up();
439                }
440                ExplorerOutcome::Pending
441            }
442
443            // ── Page down ────────────────────────────────────────────────────
444            KeyCode::PageDown => {
445                for _ in 0..10 {
446                    self.move_down();
447                }
448                ExplorerOutcome::Pending
449            }
450
451            // ── Jump to top ──────────────────────────────────────────────────
452            KeyCode::Home | KeyCode::Char('g') => {
453                self.cursor = 0;
454                self.scroll_offset = 0;
455                ExplorerOutcome::Pending
456            }
457
458            // ── Jump to bottom ───────────────────────────────────────────────
459            KeyCode::End | KeyCode::Char('G') => {
460                if !self.entries.is_empty() {
461                    self.cursor = self.entries.len() - 1;
462                }
463                ExplorerOutcome::Pending
464            }
465
466            // ── Ascend (go to parent) ─────────────────────────────────────────
467            // Left arrow / Backspace / h all ascend to the parent directory.
468            KeyCode::Left | KeyCode::Backspace | KeyCode::Char('h') => {
469                self.ascend();
470                ExplorerOutcome::Pending
471            }
472
473            // ── Navigate right (pure navigation, never exits) ─────────────────
474            // Right arrow descends into a directory; on a file it just moves
475            // the cursor down so the user can keep browsing.
476            KeyCode::Right => self.navigate(),
477
478            // ── Confirm / descend ─────────────────────────────────────────────
479            // Enter / l descend into a directory or confirm (select) a file,
480            // which signals the caller to exit the TUI.
481            KeyCode::Enter | KeyCode::Char('l') => self.confirm(),
482
483            // ── Toggle hidden files ───────────────────────────────────────────
484            KeyCode::Char('.') => {
485                self.show_hidden = !self.show_hidden;
486                let was = self.cursor;
487                self.reload();
488                self.cursor = was.min(self.entries.len().saturating_sub(1));
489                ExplorerOutcome::Pending
490            }
491
492            // ── Activate incremental search ───────────────────────────────────
493            KeyCode::Char('/') if key.modifiers.is_empty() => {
494                self.search_active = true;
495                ExplorerOutcome::Pending
496            }
497
498            // ── Cycle sort mode ───────────────────────────────────────────────
499            KeyCode::Char('s') if key.modifiers.is_empty() => {
500                self.sort_mode = self.sort_mode.next();
501                let was = self.cursor;
502                self.reload();
503                self.cursor = was.min(self.entries.len().saturating_sub(1));
504                ExplorerOutcome::Pending
505            }
506
507            // ── Toggle space-mark on current entry ────────────────────────────
508            KeyCode::Char(' ') => {
509                self.toggle_mark();
510                ExplorerOutcome::Pending
511            }
512
513            // ── Activate mkdir mode ───────────────────────────────────────────
514            KeyCode::Char('n') if key.modifiers.is_empty() => {
515                self.mkdir_active = true;
516                self.mkdir_input.clear();
517                ExplorerOutcome::Pending
518            }
519
520            // ── Activate touch (new file) mode ────────────────────────────────
521            // Shift+N — complement to `n` (mkdir).
522            KeyCode::Char('N') if key.modifiers.is_empty() => {
523                self.touch_active = true;
524                self.touch_input.clear();
525                ExplorerOutcome::Pending
526            }
527
528            // ── Activate rename mode ──────────────────────────────────────────
529            // `r` — pre-fills the input with the current entry's name so the
530            // user can edit it rather than type from scratch.
531            KeyCode::Char('r') if key.modifiers.is_empty() => {
532                if let Some(entry) = self.entries.get(self.cursor) {
533                    self.rename_input = entry.name.clone();
534                    self.rename_active = true;
535                }
536                ExplorerOutcome::Pending
537            }
538
539            _ => ExplorerOutcome::Unhandled,
540        }
541    }
542
543    // ── Queries ───────────────────────────────────────────────────────────────
544
545    /// The currently highlighted [`FsEntry`], or `None` if the list is empty.
546    pub fn current_entry(&self) -> Option<&FsEntry> {
547        self.entries.get(self.cursor)
548    }
549
550    /// Whether the explorer is in mkdir (new-folder input) mode.
551    pub fn is_mkdir_active(&self) -> bool {
552        self.mkdir_active
553    }
554
555    /// The folder name being typed when mkdir mode is active.
556    pub fn mkdir_input(&self) -> &str {
557        &self.mkdir_input
558    }
559
560    /// Whether the explorer is in touch (new-file input) mode.
561    pub fn is_touch_active(&self) -> bool {
562        self.touch_active
563    }
564
565    /// The file name being typed when touch mode is active.
566    pub fn touch_input(&self) -> &str {
567        &self.touch_input
568    }
569
570    /// Whether the explorer is in rename (entry-rename input) mode.
571    pub fn is_rename_active(&self) -> bool {
572        self.rename_active
573    }
574
575    /// The new name being typed when rename mode is active.
576    pub fn rename_input(&self) -> &str {
577        &self.rename_input
578    }
579
580    // ── Inspectors ────────────────────────────────────────────────────────────
581
582    /// Returns `true` when the explorer is at the filesystem root and cannot
583    /// ascend any further.
584    ///
585    /// ```no_run
586    /// use tui_file_explorer::FileExplorer;
587    ///
588    /// let mut explorer = FileExplorer::new(std::path::PathBuf::from("/"), vec![]);
589    /// assert!(explorer.is_at_root());
590    /// ```
591    pub fn is_at_root(&self) -> bool {
592        self.current_dir.parent().is_none()
593    }
594
595    /// Returns `true` when the current directory contains no visible entries.
596    ///
597    /// This reflects the *filtered, visible* set — hidden files are excluded
598    /// unless `show_hidden` is `true`, and an active search query narrows
599    /// the set further.
600    pub fn is_empty(&self) -> bool {
601        self.entries.is_empty()
602    }
603
604    /// The number of visible entries in the current directory.
605    ///
606    /// Equivalent to `explorer.entries.len()` but reads more naturally in
607    /// condition checks.
608    pub fn entry_count(&self) -> usize {
609        self.entries.len()
610    }
611
612    /// The current human-readable status message.
613    ///
614    /// The status is set by the widget when an error occurs (e.g. attempting
615    /// to select a file that does not match the extension filter) and is
616    /// cleared on the next successful navigation.  Returns an empty string
617    /// when there is nothing to report.
618    pub fn status(&self) -> &str {
619        &self.status
620    }
621
622    /// The current sort mode.
623    ///
624    /// ```
625    /// use tui_file_explorer::{FileExplorer, SortMode};
626    ///
627    /// let explorer = FileExplorer::new(std::path::PathBuf::from("/tmp"), vec![]);
628    /// assert_eq!(explorer.sort_mode(), SortMode::Name);
629    /// ```
630    pub fn sort_mode(&self) -> SortMode {
631        self.sort_mode
632    }
633
634    /// The current incremental-search query string.
635    ///
636    /// Returns an empty string when no search is active.
637    pub fn search_query(&self) -> &str {
638        &self.search_query
639    }
640
641    /// Returns `true` when the explorer is actively capturing keystrokes for
642    /// incremental search input.
643    pub fn is_searching(&self) -> bool {
644        self.search_active
645    }
646
647    // ── Mutating setters ──────────────────────────────────────────────────────
648
649    /// Set whether hidden (dot-file) entries are visible and reload the
650    /// directory listing immediately.
651    ///
652    /// The user can also toggle this at runtime with the `.` key.
653    ///
654    /// ```no_run
655    /// use tui_file_explorer::FileExplorer;
656    ///
657    /// let mut explorer = FileExplorer::new(std::env::current_dir().unwrap(), vec![]);
658    /// explorer.set_show_hidden(true);
659    /// assert!(explorer.show_hidden);
660    /// ```
661    pub fn set_show_hidden(&mut self, show: bool) {
662        self.show_hidden = show;
663        self.reload();
664    }
665
666    /// Replace the extension filter and reload the directory listing
667    /// immediately.
668    ///
669    /// Accepts any iterable of values that convert to [`String`] — plain
670    /// `&str` slices, `String` values, and arrays all work:
671    ///
672    /// ```no_run
673    /// use tui_file_explorer::FileExplorer;
674    ///
675    /// let mut explorer = FileExplorer::new(std::env::current_dir().unwrap(), vec![]);
676    ///
677    /// // Array of &str — no .into() needed
678    /// explorer.set_extension_filter(["rs", "toml"]);
679    ///
680    /// // Vec<String>
681    /// explorer.set_extension_filter(vec!["iso".to_string(), "img".to_string()]);
682    ///
683    /// // Empty — allow all files
684    /// explorer.set_extension_filter([] as [&str; 0]);
685    /// ```
686    pub fn set_extension_filter<I, S>(&mut self, filter: I)
687    where
688        I: IntoIterator<Item = S>,
689        S: Into<String>,
690    {
691        self.extension_filter = filter.into_iter().map(Into::into).collect();
692        self.reload();
693    }
694
695    /// Change the sort mode and reload the directory listing immediately.
696    ///
697    /// The user can also cycle through modes at runtime with the `s` key.
698    ///
699    /// ```no_run
700    /// use tui_file_explorer::{FileExplorer, SortMode};
701    ///
702    /// let mut explorer = FileExplorer::new(std::env::current_dir().unwrap(), vec![]);
703    /// explorer.set_sort_mode(SortMode::SizeDesc);
704    /// assert_eq!(explorer.sort_mode(), SortMode::SizeDesc);
705    /// ```
706    pub fn set_sort_mode(&mut self, mode: SortMode) {
707        self.sort_mode = mode;
708        self.reload();
709    }
710
711    // ── Internal navigation helpers ───────────────────────────────────────────
712
713    fn move_up(&mut self) {
714        if self.cursor > 0 {
715            self.cursor -= 1;
716        }
717        // If entries shrank (e.g. external deletion) clamp to valid range.
718        self.clamp_cursor();
719    }
720
721    fn move_down(&mut self) {
722        let last = self.entries.len().saturating_sub(1);
723        if !self.entries.is_empty() && self.cursor < last {
724            self.cursor += 1;
725        }
726        self.clamp_cursor();
727    }
728
729    /// Clamp `cursor` and `scroll_offset` so they never exceed the current
730    /// entries length.  Safe to call at any time — a no-op when everything is
731    /// already in range.
732    fn clamp_cursor(&mut self) {
733        let max = self.entries.len().saturating_sub(1);
734        if self.cursor > max {
735            self.cursor = max;
736        }
737        if self.scroll_offset > self.cursor {
738            self.scroll_offset = self.cursor;
739        }
740    }
741
742    fn ascend(&mut self) {
743        if let Some(parent) = self.current_dir.parent().map(|p| p.to_path_buf()) {
744            let prev = self.current_dir.clone();
745            self.current_dir = parent;
746            self.cursor = 0;
747            self.scroll_offset = 0;
748            // Clear search and marks when navigating to a different directory.
749            self.search_active = false;
750            self.search_query.clear();
751            self.marked.clear();
752            self.reload();
753            // Try to land the cursor on the directory we just came from.
754            if let Some(idx) = self.entries.iter().position(|e| e.path == prev) {
755                self.cursor = idx;
756            }
757            // Always clamp in case the parent is empty or shorter than expected.
758            self.clamp_cursor();
759        } else {
760            // Already at root — stay put, do nothing.
761            self.status = "Already at the filesystem root.".to_string();
762        }
763    }
764
765    /// Navigate into the highlighted entry without ever exiting the TUI.
766    ///
767    /// - **Directory** → descend (same as `confirm` on a dir).
768    /// - **File** → move the cursor down one step so the user can keep
769    ///   browsing without accidentally triggering a selection/exit.
770    fn navigate(&mut self) -> ExplorerOutcome {
771        let Some(entry) = self.entries.get(self.cursor) else {
772            return ExplorerOutcome::Pending;
773        };
774
775        if entry.is_dir {
776            let path = entry.path.clone();
777            self.search_active = false;
778            self.search_query.clear();
779            self.marked.clear();
780            self.navigate_to(path);
781        } else {
782            self.move_down();
783        }
784        ExplorerOutcome::Pending
785    }
786
787    fn confirm(&mut self) -> ExplorerOutcome {
788        let Some(entry) = self.entries.get(self.cursor) else {
789            return ExplorerOutcome::Pending;
790        };
791
792        if entry.is_dir {
793            let path = entry.path.clone();
794            // Clear search and marks when descending into a subdirectory.
795            self.search_active = false;
796            self.search_query.clear();
797            self.marked.clear();
798            self.navigate_to(path);
799            ExplorerOutcome::Pending
800        } else {
801            // All visible files already passed the extension filter in load_entries,
802            // so every non-directory entry is unconditionally selectable here.
803            ExplorerOutcome::Selected(entry.path.clone())
804        }
805    }
806
807    // ── Directory loading ─────────────────────────────────────────────────────
808
809    /// Re-read the current directory from the filesystem.
810    ///
811    /// Called automatically after every navigation action or configuration
812    /// change.  Callers can invoke it manually after external filesystem
813    /// mutations (e.g. a file was created or deleted in the watched directory).
814    pub fn reload(&mut self) {
815        self.status.clear();
816        self.entries = load_entries(
817            &self.current_dir,
818            self.show_hidden,
819            &self.extension_filter,
820            self.sort_mode,
821            &self.search_query,
822        );
823        // After every reload the entry count may have shrunk (filter change,
824        // external deletion, empty directory).  Clamp so cursor and
825        // scroll_offset never point past the end of the new list.
826        self.clamp_cursor();
827    }
828}
829
830// ── FileExplorerBuilder ───────────────────────────────────────────────────────
831
832/// Builder for [`FileExplorer`].
833///
834/// Obtain one via [`FileExplorer::builder`].
835///
836/// # Example
837///
838/// ```no_run
839/// use tui_file_explorer::{FileExplorer, SortMode};
840///
841/// let explorer = FileExplorer::builder(std::env::current_dir().unwrap())
842///     .allow_extension("iso")
843///     .allow_extension("img")
844///     .show_hidden(false)
845///     .sort_mode(SortMode::SizeDesc)
846///     .build();
847/// ```
848pub struct FileExplorerBuilder {
849    initial_dir: PathBuf,
850    extension_filter: Vec<String>,
851    show_hidden: bool,
852    sort_mode: SortMode,
853}
854
855impl FileExplorerBuilder {
856    /// Create a builder rooted at `initial_dir`.
857    pub fn new(initial_dir: PathBuf) -> Self {
858        Self {
859            initial_dir,
860            extension_filter: Vec::new(),
861            show_hidden: false,
862            sort_mode: SortMode::default(),
863        }
864    }
865
866    /// Set the full extension filter list at once.
867    ///
868    /// Replaces any extensions added with [`allow_extension`](Self::allow_extension).
869    ///
870    /// ```no_run
871    /// use tui_file_explorer::FileExplorer;
872    ///
873    /// let explorer = FileExplorer::builder(std::env::current_dir().unwrap())
874    ///     .extension_filter(vec!["iso".into(), "img".into()])
875    ///     .build();
876    /// ```
877    pub fn extension_filter(mut self, filter: Vec<String>) -> Self {
878        self.extension_filter = filter;
879        self
880    }
881
882    /// Append a single allowed extension.
883    ///
884    /// Call multiple times to build up the filter:
885    ///
886    /// ```no_run
887    /// use tui_file_explorer::FileExplorer;
888    ///
889    /// let explorer = FileExplorer::builder(std::env::current_dir().unwrap())
890    ///     .allow_extension("iso")
891    ///     .allow_extension("img")
892    ///     .build();
893    /// ```
894    pub fn allow_extension(mut self, ext: impl Into<String>) -> Self {
895        self.extension_filter.push(ext.into());
896        self
897    }
898
899    /// Set whether hidden (dot-file) entries are shown on startup.
900    ///
901    /// ```no_run
902    /// use tui_file_explorer::FileExplorer;
903    ///
904    /// let explorer = FileExplorer::builder(std::env::current_dir().unwrap())
905    ///     .show_hidden(true)
906    ///     .build();
907    /// ```
908    pub fn show_hidden(mut self, show: bool) -> Self {
909        self.show_hidden = show;
910        self
911    }
912
913    /// Set the initial sort mode.
914    ///
915    /// ```no_run
916    /// use tui_file_explorer::{FileExplorer, SortMode};
917    ///
918    /// let explorer = FileExplorer::builder(std::env::current_dir().unwrap())
919    ///     .sort_mode(SortMode::SizeDesc)
920    ///     .build();
921    /// ```
922    pub fn sort_mode(mut self, mode: SortMode) -> Self {
923        self.sort_mode = mode;
924        self
925    }
926
927    /// Consume the builder and return a fully initialised [`FileExplorer`].
928    pub fn build(self) -> FileExplorer {
929        let mut explorer = FileExplorer {
930            current_dir: self.initial_dir,
931            entries: Vec::new(),
932            cursor: 0,
933            scroll_offset: 0,
934            extension_filter: self.extension_filter,
935            show_hidden: self.show_hidden,
936            status: String::new(),
937            sort_mode: self.sort_mode,
938            search_query: String::new(),
939            search_active: false,
940            marked: HashSet::new(),
941            mkdir_active: false,
942            mkdir_input: String::new(),
943            touch_active: false,
944            touch_input: String::new(),
945            rename_active: false,
946            rename_input: String::new(),
947            theme_name: String::new(),
948            editor_name: String::new(),
949        };
950        explorer.reload();
951        explorer
952    }
953}
954
955// ── Directory loader ──────────────────────────────────────────────────────────
956
957/// Read `dir`, apply all active filters, sort entries, and return the result.
958///
959/// * Hidden entries are excluded unless `show_hidden` is `true`.
960/// * When `ext_filter` is non-empty only files whose extension is in the list
961///   are included (directories are always included).
962/// * When `search_query` is non-empty only entries whose name contains the
963///   query (case-insensitive) are included.
964/// * Entries are sorted according to `sort_mode`; directories are always
965///   placed before files regardless of the sort mode.
966pub(crate) fn load_entries(
967    dir: &Path,
968    show_hidden: bool,
969    ext_filter: &[String],
970    sort_mode: SortMode,
971    search_query: &str,
972) -> Vec<FsEntry> {
973    let read = match fs::read_dir(dir) {
974        Ok(r) => r,
975        Err(_) => return Vec::new(),
976    };
977
978    let mut dirs: Vec<FsEntry> = Vec::new();
979    let mut files: Vec<FsEntry> = Vec::new();
980
981    for entry in read.flatten() {
982        let path = entry.path();
983        let name = entry.file_name().to_string_lossy().to_string();
984
985        if !show_hidden && name.starts_with('.') {
986            continue;
987        }
988
989        let is_dir = path.is_dir();
990        let extension = if is_dir {
991            String::new()
992        } else {
993            path.extension()
994                .map(|e| e.to_string_lossy().to_lowercase())
995                .unwrap_or_default()
996        };
997
998        // Extension filter — applied to files only; directories always pass.
999        if !is_dir && !ext_filter.is_empty() {
1000            let matches = ext_filter
1001                .iter()
1002                .any(|f| f.eq_ignore_ascii_case(&extension));
1003            if !matches {
1004                continue;
1005            }
1006        }
1007
1008        // Search query filter — applied to both files and directories.
1009        if !search_query.is_empty() {
1010            let q = search_query.to_lowercase();
1011            if !name.to_lowercase().contains(&q) {
1012                continue;
1013            }
1014        }
1015
1016        let size = if is_dir {
1017            None
1018        } else {
1019            entry.metadata().ok().map(|m| m.len())
1020        };
1021
1022        let fs_entry = FsEntry {
1023            name,
1024            path,
1025            is_dir,
1026            size,
1027            extension,
1028        };
1029
1030        if is_dir {
1031            dirs.push(fs_entry);
1032        } else {
1033            files.push(fs_entry);
1034        }
1035    }
1036
1037    // Sort each group according to the active mode.
1038    // Directories always sort alphabetically among themselves.
1039    dirs.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
1040
1041    match sort_mode {
1042        SortMode::Name => {
1043            files.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
1044        }
1045        SortMode::SizeDesc => {
1046            // Largest first; treat missing size as 0.
1047            files.sort_by(|a, b| b.size.unwrap_or(0).cmp(&a.size.unwrap_or(0)));
1048        }
1049        SortMode::Extension => {
1050            // By extension first, then by name within each extension group.
1051            files.sort_by(|a, b| {
1052                a.extension
1053                    .cmp(&b.extension)
1054                    .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
1055            });
1056        }
1057    }
1058
1059    // Dirs first, then sorted files.
1060    dirs.extend(files);
1061    dirs
1062}
1063
1064// ── Utilities ─────────────────────────────────────────────────────────────────
1065
1066/// Choose a Unicode icon for a directory entry.
1067///
1068/// Exposed as a public helper so that custom renderers can reuse the same
1069/// icon mapping without duplicating the match table.
1070pub fn entry_icon(entry: &FsEntry) -> &'static str {
1071    if entry.is_dir {
1072        return "📁";
1073    }
1074    match entry.extension.as_str() {
1075        // Disk images
1076        "iso" | "dmg" => "💿",
1077        "img" => "🖼 ",
1078        // Archives
1079        "zip" | "gz" | "xz" | "zst" | "bz2" | "tar" | "7z" | "rar" | "tgz" | "tbz2" => "📦",
1080        // Documents
1081        "pdf" => "📕",
1082        "txt" | "log" | "rst" => "📄",
1083        "md" | "mdx" | "markdown" => "📝",
1084        // Config / data
1085        "toml" | "yaml" | "yml" | "json" | "xml" | "ini" | "cfg" | "conf" | "env" => "⚙ ",
1086        "lock" => "🔒",
1087        // Source — languages
1088        "rs" => "🦀",
1089        "py" | "pyw" => "🐍",
1090        "js" | "mjs" | "cjs" => "📜",
1091        "ts" | "mts" | "cts" => "📜",
1092        "jsx" | "tsx" => "📜",
1093        "go" => "📜",
1094        "c" | "h" => "📜",
1095        "cpp" | "cc" | "cxx" | "hpp" | "hxx" => "📜",
1096        "java" | "kt" | "kts" => "📜",
1097        "rb" | "erb" => "📜",
1098        "php" => "📜",
1099        "swift" => "📜",
1100        "cs" => "📜",
1101        "lua" => "📜",
1102        "zig" => "📜",
1103        "ex" | "exs" => "📜",
1104        "hs" | "lhs" => "📜",
1105        "ml" | "mli" => "📜",
1106        // Shell scripts
1107        "sh" | "bash" | "zsh" | "fish" | "nu" => "📜",
1108        "bat" | "cmd" | "ps1" => "📜",
1109        // Web
1110        "html" | "htm" | "xhtml" => "🌐",
1111        "css" | "scss" | "sass" | "less" => "🎨",
1112        "svg" => "🎨",
1113        // Images (raster)
1114        "png" | "jpg" | "jpeg" | "gif" | "bmp" | "webp" | "ico" | "tiff" | "tif" | "avif"
1115        | "heic" | "heif" => "🖼 ",
1116        // Video
1117        "mp4" | "mkv" | "avi" | "mov" | "webm" | "flv" | "wmv" | "m4v" => "🎬",
1118        // Audio
1119        "mp3" | "wav" | "flac" | "ogg" | "aac" | "m4a" | "opus" | "wma" => "🎵",
1120        // Fonts
1121        "ttf" | "otf" | "woff" | "woff2" | "eot" => "🔤",
1122        // Executables / binaries
1123        "exe" | "msi" | "deb" | "rpm" | "appimage" | "apk" => "⚙ ",
1124        _ => "📄",
1125    }
1126}
1127
1128/// Format a byte count as a human-readable size string.
1129///
1130/// Exposed as a public helper so that custom renderers can reuse the same
1131/// formatting logic without reimplementing it.
1132///
1133/// ```
1134/// use tui_file_explorer::fmt_size;
1135///
1136/// assert_eq!(fmt_size(512),           "512 B");
1137/// assert_eq!(fmt_size(1_536),         "1.5 KB");
1138/// assert_eq!(fmt_size(2_097_152),     "2.0 MB");
1139/// assert_eq!(fmt_size(1_073_741_824), "1.0 GB");
1140/// ```
1141pub fn fmt_size(bytes: u64) -> String {
1142    const KB: u64 = 1_024;
1143    const MB: u64 = 1_024 * KB;
1144    const GB: u64 = 1_024 * MB;
1145    if bytes >= GB {
1146        format!("{:.1} GB", bytes as f64 / GB as f64)
1147    } else if bytes >= MB {
1148        format!("{:.1} MB", bytes as f64 / MB as f64)
1149    } else if bytes >= KB {
1150        format!("{:.1} KB", bytes as f64 / KB as f64)
1151    } else {
1152        format!("{bytes} B")
1153    }
1154}
1155
1156// ── Tests ─────────────────────────────────────────────────────────────────────
1157
1158#[cfg(test)]
1159mod tests {
1160    use super::*;
1161    use crossterm::event::{KeyEvent, KeyModifiers};
1162    use std::fs;
1163    use tempfile::{tempdir, TempDir};
1164
1165    // ── Fixtures ──────────────────────────────────────────────────────────────
1166
1167    fn temp_dir_with_files() -> TempDir {
1168        let dir = tempfile::tempdir().expect("temp dir");
1169        fs::write(dir.path().join("ubuntu.iso"), b"fake iso content").unwrap();
1170        fs::write(dir.path().join("debian.img"), b"fake img content").unwrap();
1171        fs::write(dir.path().join("readme.txt"), b"some text").unwrap();
1172        fs::create_dir(dir.path().join("subdir")).unwrap();
1173        dir
1174    }
1175
1176    fn key(code: KeyCode) -> KeyEvent {
1177        KeyEvent::new(code, KeyModifiers::NONE)
1178    }
1179
1180    // ── Existing tests ────────────────────────────────────────────────────────
1181
1182    #[test]
1183    fn new_loads_entries() {
1184        let tmp = temp_dir_with_files();
1185        let explorer =
1186            FileExplorer::new(tmp.path().to_path_buf(), vec!["iso".into(), "img".into()]);
1187        assert!(explorer
1188            .entries
1189            .iter()
1190            .any(|e| e.name == "subdir" && e.is_dir));
1191        assert!(explorer.entries.iter().any(|e| e.name == "ubuntu.iso"));
1192        assert!(explorer.entries.iter().any(|e| e.name == "debian.img"));
1193        // .txt excluded by filter
1194        assert!(!explorer.entries.iter().any(|e| e.name == "readme.txt"));
1195    }
1196
1197    #[test]
1198    fn no_filter_shows_all_files() {
1199        let tmp = temp_dir_with_files();
1200        let explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1201        assert!(explorer.entries.iter().any(|e| e.name == "readme.txt"));
1202    }
1203
1204    #[test]
1205    fn dirs_listed_before_files() {
1206        let tmp = temp_dir_with_files();
1207        let explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1208        let first_file_idx = explorer
1209            .entries
1210            .iter()
1211            .position(|e| !e.is_dir)
1212            .unwrap_or(usize::MAX);
1213        let last_dir_idx = explorer.entries.iter().rposition(|e| e.is_dir).unwrap_or(0);
1214        assert!(
1215            last_dir_idx < first_file_idx,
1216            "all dirs must appear before any file"
1217        );
1218    }
1219
1220    #[test]
1221    fn move_down_increments_cursor() {
1222        let tmp = temp_dir_with_files();
1223        let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1224        explorer.move_down();
1225        assert_eq!(explorer.cursor, 1);
1226    }
1227
1228    #[test]
1229    fn move_up_clamps_at_zero() {
1230        let tmp = temp_dir_with_files();
1231        let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1232        explorer.move_up();
1233        assert_eq!(explorer.cursor, 0);
1234    }
1235
1236    #[test]
1237    fn move_down_clamps_at_last() {
1238        let tmp = temp_dir_with_files();
1239        let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1240        let last = explorer.entries.len() - 1;
1241        explorer.cursor = last;
1242        explorer.move_down();
1243        assert_eq!(explorer.cursor, last);
1244    }
1245
1246    #[test]
1247    fn handle_key_down_moves_cursor() {
1248        let tmp = temp_dir_with_files();
1249        let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1250        let before = explorer.cursor;
1251        explorer.handle_key(key(KeyCode::Down));
1252        assert_eq!(explorer.cursor, before + 1);
1253    }
1254
1255    #[test]
1256    fn handle_key_esc_dismisses() {
1257        let tmp = temp_dir_with_files();
1258        let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1259        assert_eq!(
1260            explorer.handle_key(key(KeyCode::Esc)),
1261            ExplorerOutcome::Dismissed
1262        );
1263    }
1264
1265    #[test]
1266    fn handle_key_enter_on_dir_descends() {
1267        let tmp = temp_dir_with_files();
1268        let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1269        // Place cursor on the directory (dirs sort first).
1270        let dir_idx = explorer
1271            .entries
1272            .iter()
1273            .position(|e| e.is_dir)
1274            .expect("no dir in fixture");
1275        explorer.cursor = dir_idx;
1276        let expected_path = explorer.entries[dir_idx].path.clone();
1277        let outcome = explorer.handle_key(key(KeyCode::Enter));
1278        assert_eq!(outcome, ExplorerOutcome::Pending);
1279        assert_eq!(explorer.current_dir, expected_path);
1280    }
1281
1282    #[test]
1283    fn handle_key_enter_on_valid_file_selects() {
1284        let tmp = temp_dir_with_files();
1285        let mut explorer =
1286            FileExplorer::new(tmp.path().to_path_buf(), vec!["iso".into(), "img".into()]);
1287        let file_idx = explorer
1288            .entries
1289            .iter()
1290            .position(|e| !e.is_dir)
1291            .expect("no file in fixture");
1292        explorer.cursor = file_idx;
1293        let expected = explorer.entries[file_idx].path.clone();
1294        let outcome = explorer.handle_key(key(KeyCode::Enter));
1295        assert_eq!(outcome, ExplorerOutcome::Selected(expected));
1296    }
1297
1298    #[test]
1299    fn handle_key_backspace_ascends() {
1300        let tmp = temp_dir_with_files();
1301        let subdir = tmp.path().join("subdir");
1302        let mut explorer = FileExplorer::new(subdir, vec![]);
1303        explorer.handle_key(key(KeyCode::Backspace));
1304        assert_eq!(explorer.current_dir, tmp.path());
1305    }
1306
1307    #[test]
1308    fn toggle_hidden_changes_visibility() {
1309        let tmp = temp_dir_with_files();
1310        fs::write(tmp.path().join(".hidden_file"), b"").unwrap();
1311        let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1312        assert!(!explorer.entries.iter().any(|e| e.name == ".hidden_file"));
1313        explorer.set_show_hidden(true);
1314        assert!(explorer.entries.iter().any(|e| e.name == ".hidden_file"));
1315    }
1316
1317    #[test]
1318    fn fmt_size_formats_bytes() {
1319        assert_eq!(fmt_size(512), "512 B");
1320        assert_eq!(fmt_size(1_536), "1.5 KB");
1321        assert_eq!(fmt_size(2_097_152), "2.0 MB");
1322        assert_eq!(fmt_size(1_073_741_824), "1.0 GB");
1323    }
1324
1325    #[test]
1326    fn extension_filter_only_shows_matching_files() {
1327        // The real selectability contract lives in load_entries: only files
1328        // whose extension matches the filter appear in entries at all.
1329        let tmp = temp_dir_with_files();
1330        let explorer = FileExplorer::new(tmp.path().to_path_buf(), vec!["iso".into()]);
1331
1332        // Matching file is present.
1333        assert!(
1334            explorer.entries.iter().any(|e| e.name == "ubuntu.iso"),
1335            "iso file should appear in entries"
1336        );
1337        // Non-matching file is absent.
1338        assert!(
1339            !explorer.entries.iter().any(|e| e.name == "debian.img"),
1340            "img file should be excluded by filter"
1341        );
1342        // Directories are always present regardless of the filter.
1343        assert!(
1344            explorer.entries.iter().any(|e| e.is_dir),
1345            "directories should always be visible"
1346        );
1347        // Every visible non-directory entry has the expected extension.
1348        assert!(
1349            explorer
1350                .entries
1351                .iter()
1352                .filter(|e| !e.is_dir)
1353                .all(|e| e.extension == "iso"),
1354            "all visible files must match the active filter"
1355        );
1356    }
1357
1358    #[test]
1359    fn navigate_to_resets_cursor_and_scroll() {
1360        let tmp = temp_dir_with_files();
1361        let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1362        explorer.cursor = 2;
1363        explorer.scroll_offset = 1;
1364        explorer.navigate_to(tmp.path().to_path_buf());
1365        assert_eq!(explorer.cursor, 0);
1366        assert_eq!(explorer.scroll_offset, 0);
1367    }
1368
1369    #[test]
1370    fn current_entry_returns_highlighted() {
1371        let tmp = temp_dir_with_files();
1372        let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1373        explorer.cursor = 0;
1374        let entry = explorer.current_entry().expect("should have entry");
1375        assert_eq!(entry, explorer.entries.first().unwrap());
1376    }
1377
1378    #[test]
1379    fn unrecognised_key_returns_unhandled() {
1380        let tmp = temp_dir_with_files();
1381        let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1382        assert_eq!(
1383            explorer.handle_key(key(KeyCode::F(5))),
1384            ExplorerOutcome::Unhandled
1385        );
1386    }
1387
1388    // ── Search tests ──────────────────────────────────────────────────────────
1389
1390    #[test]
1391    fn slash_activates_search_mode() {
1392        let tmp = temp_dir_with_files();
1393        let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1394        assert!(!explorer.search_active);
1395        explorer.handle_key(key(KeyCode::Char('/')));
1396        assert!(explorer.search_active);
1397        assert_eq!(explorer.search_query(), "");
1398    }
1399
1400    #[test]
1401    fn search_active_chars_append_to_query() {
1402        let tmp = temp_dir_with_files();
1403        let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1404        explorer.handle_key(key(KeyCode::Char('/')));
1405        explorer.handle_key(key(KeyCode::Char('u')));
1406        explorer.handle_key(key(KeyCode::Char('b')));
1407        explorer.handle_key(key(KeyCode::Char('u')));
1408        assert_eq!(explorer.search_query(), "ubu");
1409        assert!(explorer.search_active);
1410    }
1411
1412    #[test]
1413    fn search_filters_entries_by_name() {
1414        let tmp = temp_dir_with_files();
1415        let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1416        // Activate search and type a query that matches only ubuntu.iso
1417        explorer.handle_key(key(KeyCode::Char('/')));
1418        for c in "ubu".chars() {
1419            explorer.handle_key(key(KeyCode::Char(c)));
1420        }
1421        // Only ubuntu.iso (and nothing else) should be visible.
1422        assert_eq!(explorer.entries.len(), 1);
1423        assert_eq!(explorer.entries[0].name, "ubuntu.iso");
1424    }
1425
1426    #[test]
1427    fn search_backspace_pops_last_char() {
1428        let tmp = temp_dir_with_files();
1429        let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1430        explorer.handle_key(key(KeyCode::Char('/')));
1431        explorer.handle_key(key(KeyCode::Char('u')));
1432        explorer.handle_key(key(KeyCode::Char('b')));
1433        explorer.handle_key(key(KeyCode::Backspace));
1434        assert_eq!(explorer.search_query(), "u");
1435        assert!(explorer.search_active);
1436    }
1437
1438    #[test]
1439    fn search_backspace_on_empty_deactivates() {
1440        let tmp = temp_dir_with_files();
1441        let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1442        explorer.handle_key(key(KeyCode::Char('/')));
1443        assert!(explorer.search_active);
1444        // Backspace on an empty query deactivates search.
1445        explorer.handle_key(key(KeyCode::Backspace));
1446        assert!(!explorer.search_active);
1447        assert_eq!(explorer.search_query(), "");
1448    }
1449
1450    #[test]
1451    fn search_esc_clears_and_deactivates_returns_pending() {
1452        let tmp = temp_dir_with_files();
1453        let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1454        explorer.handle_key(key(KeyCode::Char('/')));
1455        explorer.handle_key(key(KeyCode::Char('u')));
1456        let outcome = explorer.handle_key(key(KeyCode::Esc));
1457        assert_eq!(
1458            outcome,
1459            ExplorerOutcome::Pending,
1460            "Esc should clear search, not dismiss"
1461        );
1462        assert!(!explorer.search_active);
1463        assert_eq!(explorer.search_query(), "");
1464    }
1465
1466    #[test]
1467    fn esc_when_not_searching_dismisses() {
1468        let tmp = temp_dir_with_files();
1469        let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1470        assert!(!explorer.search_active);
1471        assert_eq!(
1472            explorer.handle_key(key(KeyCode::Esc)),
1473            ExplorerOutcome::Dismissed
1474        );
1475    }
1476
1477    #[test]
1478    fn search_clears_on_directory_descend() {
1479        let tmp = temp_dir_with_files();
1480        let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1481        explorer.search_active = true;
1482        explorer.search_query = "sub".into();
1483        // Navigate into subdir
1484        explorer.cursor = explorer.entries.iter().position(|e| e.is_dir).unwrap();
1485        explorer.handle_key(key(KeyCode::Enter));
1486        assert!(!explorer.search_active);
1487        assert_eq!(explorer.search_query(), "");
1488    }
1489
1490    #[test]
1491    fn search_clears_on_ascend() {
1492        let tmp = temp_dir_with_files();
1493        let subdir = tmp.path().join("subdir");
1494        let mut explorer = FileExplorer::new(subdir, vec![]);
1495
1496        // Manually inject search state (simulates user having typed a query
1497        // while already inside subdir, then pressing the ascend key).
1498        // When search_active is true, ALL KeyCode::Char(_) keys are consumed
1499        // by the search interception block — they append to the query rather
1500        // than triggering navigation.  Backspace pops the query.  The only
1501        // way to ascend while search is active is via the non-char ascend
1502        // keys, but those aren't exposed through handle_key without going
1503        // through the search block first.  Call ascend() directly: this is
1504        // the correct unit test for the ascend() logic itself, independent
1505        // of key dispatch.
1506        explorer.search_active = true;
1507        explorer.search_query = "foo".into();
1508
1509        // Call ascend() directly — ascend() clears search state unconditionally.
1510        explorer.ascend();
1511
1512        assert!(
1513            !explorer.search_active,
1514            "search must be deactivated after ascend"
1515        );
1516        assert_eq!(
1517            explorer.search_query(),
1518            "",
1519            "query must be cleared after ascend"
1520        );
1521        assert_eq!(
1522            explorer.current_dir,
1523            tmp.path(),
1524            "must have ascended to parent"
1525        );
1526    }
1527
1528    #[test]
1529    fn backspace_in_search_pops_char_not_ascend() {
1530        // Verify Backspace is consumed by search interception (pops the query)
1531        // and does NOT trigger ascend when search is active with a non-empty query.
1532        let tmp = temp_dir_with_files();
1533        let subdir = tmp.path().join("subdir");
1534        let mut explorer = FileExplorer::new(subdir.clone(), vec![]);
1535        explorer.search_active = true;
1536        explorer.search_query = "foo".into();
1537
1538        explorer.handle_key(key(KeyCode::Backspace)); // should pop 'o', not ascend
1539
1540        assert_eq!(explorer.current_dir, subdir, "must NOT have ascended");
1541        assert_eq!(
1542            explorer.search_query(),
1543            "fo",
1544            "Backspace should pop last char"
1545        );
1546        assert!(explorer.search_active, "search must still be active");
1547    }
1548
1549    // ── Sort tests ────────────────────────────────────────────────────────────
1550
1551    #[test]
1552    fn default_sort_mode_is_name() {
1553        let tmp = temp_dir_with_files();
1554        let explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1555        assert_eq!(explorer.sort_mode(), SortMode::Name);
1556    }
1557
1558    #[test]
1559    fn sort_mode_cycles_on_s_key() {
1560        let tmp = temp_dir_with_files();
1561        let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1562        assert_eq!(explorer.sort_mode(), SortMode::Name);
1563        explorer.handle_key(key(KeyCode::Char('s')));
1564        assert_eq!(explorer.sort_mode(), SortMode::SizeDesc);
1565        explorer.handle_key(key(KeyCode::Char('s')));
1566        assert_eq!(explorer.sort_mode(), SortMode::Extension);
1567        explorer.handle_key(key(KeyCode::Char('s')));
1568        assert_eq!(explorer.sort_mode(), SortMode::Name);
1569    }
1570
1571    #[test]
1572    fn sort_size_desc_orders_largest_first() {
1573        let tmp = tempfile::tempdir().expect("temp dir");
1574        // Create files with clearly different sizes.
1575        fs::write(tmp.path().join("small.txt"), vec![0u8; 10]).unwrap();
1576        fs::write(tmp.path().join("large.txt"), vec![0u8; 10_000]).unwrap();
1577        fs::write(tmp.path().join("medium.txt"), vec![0u8; 1_000]).unwrap();
1578
1579        let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1580        explorer.set_sort_mode(SortMode::SizeDesc);
1581
1582        let sizes: Vec<u64> = explorer.entries.iter().filter_map(|e| e.size).collect();
1583        let mut sorted_desc = sizes.clone();
1584        sorted_desc.sort_by(|a, b| b.cmp(a));
1585        assert_eq!(sizes, sorted_desc, "files should be sorted largest-first");
1586    }
1587
1588    #[test]
1589    fn sort_extension_groups_by_ext() {
1590        let tmp = tempfile::tempdir().expect("temp dir");
1591        fs::write(tmp.path().join("b.toml"), b"").unwrap();
1592        fs::write(tmp.path().join("a.rs"), b"").unwrap();
1593        fs::write(tmp.path().join("c.toml"), b"").unwrap();
1594        fs::write(tmp.path().join("z.rs"), b"").unwrap();
1595
1596        let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1597        explorer.set_sort_mode(SortMode::Extension);
1598
1599        let exts: Vec<&str> = explorer
1600            .entries
1601            .iter()
1602            .filter(|e| !e.is_dir)
1603            .map(|e| e.extension.as_str())
1604            .collect();
1605
1606        // All rs entries should appear before toml entries (r < t).
1607        let rs_last = exts.iter().rposition(|&e| e == "rs").unwrap_or(0);
1608        let toml_first = exts.iter().position(|&e| e == "toml").unwrap_or(usize::MAX);
1609        assert!(rs_last < toml_first, "rs group must precede toml group");
1610    }
1611
1612    #[test]
1613    fn builder_sort_mode_applied() {
1614        let tmp = temp_dir_with_files();
1615        let explorer = FileExplorer::builder(tmp.path().to_path_buf())
1616            .sort_mode(SortMode::SizeDesc)
1617            .build();
1618        assert_eq!(explorer.sort_mode(), SortMode::SizeDesc);
1619    }
1620
1621    #[test]
1622    fn set_sort_mode_reloads() {
1623        let tmp = temp_dir_with_files();
1624        let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1625        explorer.set_sort_mode(SortMode::Extension);
1626        assert_eq!(explorer.sort_mode(), SortMode::Extension);
1627        // Entries should still be present after the reload triggered by set_sort_mode.
1628        assert!(!explorer.entries.is_empty());
1629    }
1630
1631    // ── Vim key tests ─────────────────────────────────────────────────────────
1632
1633    #[test]
1634    fn j_key_moves_cursor_down() {
1635        let tmp = temp_dir_with_files();
1636        let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1637        let before = explorer.cursor;
1638        explorer.handle_key(key(KeyCode::Char('j')));
1639        assert_eq!(explorer.cursor, before + 1);
1640    }
1641
1642    #[test]
1643    fn k_key_moves_cursor_up() {
1644        let tmp = temp_dir_with_files();
1645        let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1646        explorer.cursor = 2;
1647        explorer.handle_key(key(KeyCode::Char('k')));
1648        assert_eq!(explorer.cursor, 1);
1649    }
1650
1651    #[test]
1652    fn h_key_ascends_to_parent() {
1653        let tmp = temp_dir_with_files();
1654        let subdir = tmp.path().join("subdir");
1655        let mut explorer = FileExplorer::new(subdir, vec![]);
1656        explorer.handle_key(key(KeyCode::Char('h')));
1657        assert_eq!(explorer.current_dir, tmp.path());
1658    }
1659
1660    #[test]
1661    fn l_key_descends_into_dir() {
1662        let tmp = temp_dir_with_files();
1663        let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1664        let dir_idx = explorer.entries.iter().position(|e| e.is_dir).unwrap();
1665        explorer.cursor = dir_idx;
1666        let expected = explorer.entries[dir_idx].path.clone();
1667        let outcome = explorer.handle_key(key(KeyCode::Char('l')));
1668        assert_eq!(outcome, ExplorerOutcome::Pending);
1669        assert_eq!(explorer.current_dir, expected);
1670    }
1671
1672    #[test]
1673    fn right_arrow_descends_into_dir() {
1674        let tmp = temp_dir_with_files();
1675        let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1676        let dir_idx = explorer.entries.iter().position(|e| e.is_dir).unwrap();
1677        explorer.cursor = dir_idx;
1678        let expected = explorer.entries[dir_idx].path.clone();
1679        let outcome = explorer.handle_key(key(KeyCode::Right));
1680        assert_eq!(
1681            outcome,
1682            ExplorerOutcome::Pending,
1683            "Right arrow should descend into directory"
1684        );
1685        assert_eq!(
1686            explorer.current_dir, expected,
1687            "Right arrow should change into the selected directory"
1688        );
1689    }
1690
1691    #[test]
1692    fn right_arrow_on_file_moves_down_not_exits() {
1693        let tmp = temp_dir_with_files();
1694        let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1695        // Pick the first file entry that is not the last entry so cursor can advance.
1696        let file_idx = explorer.entries.iter().position(|e| !e.is_dir).unwrap();
1697        // Ensure there is an entry after it to move to.
1698        assert!(
1699            file_idx + 1 < explorer.entries.len(),
1700            "fixture must have an entry after the first file"
1701        );
1702        explorer.cursor = file_idx;
1703        let original_dir = explorer.current_dir.clone();
1704        let outcome = explorer.handle_key(key(KeyCode::Right));
1705        assert_eq!(
1706            outcome,
1707            ExplorerOutcome::Pending,
1708            "Right arrow on a file must never exit (always Pending)"
1709        );
1710        assert_eq!(
1711            explorer.current_dir, original_dir,
1712            "Right arrow on a file must not change directory"
1713        );
1714        assert_eq!(
1715            explorer.cursor,
1716            file_idx + 1,
1717            "Right arrow on a file must advance the cursor by one"
1718        );
1719    }
1720
1721    #[test]
1722    fn right_arrow_on_file_at_last_entry_does_not_overflow() {
1723        let tmp = temp_dir_with_files();
1724        let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1725        let last = explorer.entries.len() - 1;
1726        // Force cursor onto the last entry (guaranteed to exist in the fixture).
1727        explorer.cursor = last;
1728        explorer.handle_key(key(KeyCode::Right));
1729        assert_eq!(
1730            explorer.cursor, last,
1731            "Right arrow at the last entry must not overflow past it"
1732        );
1733    }
1734
1735    #[test]
1736    fn enter_on_file_still_confirms_and_exits() {
1737        let tmp = temp_dir_with_files();
1738        let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1739        let file_idx = explorer.entries.iter().position(|e| !e.is_dir).unwrap();
1740        explorer.cursor = file_idx;
1741        let expected = explorer.entries[file_idx].path.clone();
1742        let outcome = explorer.handle_key(key(KeyCode::Enter));
1743        assert_eq!(
1744            outcome,
1745            ExplorerOutcome::Selected(expected),
1746            "Enter on a file should confirm (select) it and exit"
1747        );
1748    }
1749
1750    #[test]
1751    fn left_arrow_ascends_to_parent() {
1752        let tmp = temp_dir_with_files();
1753        let subdir = tmp.path().join("subdir");
1754        let mut explorer = FileExplorer::new(subdir, vec![]);
1755        let outcome = explorer.handle_key(key(KeyCode::Left));
1756        assert_eq!(
1757            outcome,
1758            ExplorerOutcome::Pending,
1759            "Left arrow should return Pending after ascending"
1760        );
1761        assert_eq!(
1762            explorer.current_dir,
1763            tmp.path(),
1764            "Left arrow should ascend to the parent directory"
1765        );
1766    }
1767
1768    #[test]
1769    fn right_arrow_clears_search_on_dir_descend() {
1770        let tmp = temp_dir_with_files();
1771        let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1772        // Activate search so we can verify navigate() clears it.
1773        explorer.search_active = true;
1774        explorer.search_query = "sub".to_string();
1775        explorer.reload();
1776        // The search should have narrowed entries to the subdir.
1777        let dir_idx = explorer
1778            .entries
1779            .iter()
1780            .position(|e| e.is_dir)
1781            .expect("fixture subdir must match 'sub'");
1782        explorer.cursor = dir_idx;
1783        explorer.handle_key(key(KeyCode::Right));
1784        assert!(
1785            !explorer.search_active,
1786            "navigate() must deactivate search on directory descend"
1787        );
1788        assert!(
1789            explorer.search_query.is_empty(),
1790            "navigate() must clear search query on directory descend"
1791        );
1792    }
1793
1794    #[test]
1795    fn right_arrow_clears_marks_on_dir_descend() {
1796        let tmp = temp_dir_with_files();
1797        let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1798        let dir_idx = explorer
1799            .entries
1800            .iter()
1801            .position(|e| e.is_dir)
1802            .expect("fixture has a subdir");
1803        // Mark an entry before descending.
1804        explorer.toggle_mark();
1805        assert!(
1806            !explorer.marked.is_empty(),
1807            "should have a mark before descend"
1808        );
1809        // Reset cursor back to the directory entry.
1810        explorer.cursor = explorer
1811            .entries
1812            .iter()
1813            .position(|e| e.is_dir)
1814            .expect("fixture has a subdir");
1815        explorer.handle_key(key(KeyCode::Right));
1816        assert!(
1817            explorer.marked.is_empty(),
1818            "navigate() must clear marks on directory descend"
1819        );
1820        let _ = dir_idx;
1821    }
1822
1823    #[test]
1824    fn backspace_still_ascends() {
1825        let tmp = temp_dir_with_files();
1826        let subdir = tmp.path().join("subdir");
1827        let mut explorer = FileExplorer::new(subdir, vec![]);
1828        explorer.handle_key(key(KeyCode::Backspace));
1829        assert_eq!(explorer.current_dir, tmp.path());
1830    }
1831
1832    #[test]
1833    fn q_key_dismisses() {
1834        let tmp = temp_dir_with_files();
1835        let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1836        assert_eq!(
1837            explorer.handle_key(key(KeyCode::Char('q'))),
1838            ExplorerOutcome::Dismissed
1839        );
1840    }
1841
1842    // ── Page / jump key tests ─────────────────────────────────────────────────
1843
1844    #[test]
1845    fn page_down_advances_cursor_by_ten() {
1846        let tmp = tempfile::tempdir().unwrap();
1847        for i in 0..15 {
1848            fs::write(tmp.path().join(format!("file{i:02}.txt")), b"").unwrap();
1849        }
1850        let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1851        explorer.cursor = 0;
1852        explorer.handle_key(key(KeyCode::PageDown));
1853        assert_eq!(explorer.cursor, 10);
1854    }
1855
1856    #[test]
1857    fn page_up_retreats_cursor_by_ten() {
1858        let tmp = tempfile::tempdir().unwrap();
1859        for i in 0..15 {
1860            fs::write(tmp.path().join(format!("file{i:02}.txt")), b"").unwrap();
1861        }
1862        let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1863        explorer.cursor = 12;
1864        explorer.handle_key(key(KeyCode::PageUp));
1865        assert_eq!(explorer.cursor, 2);
1866    }
1867
1868    #[test]
1869    fn home_key_jumps_to_top() {
1870        let tmp = temp_dir_with_files();
1871        let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1872        explorer.cursor = explorer.entries.len() - 1;
1873        explorer.handle_key(key(KeyCode::Home));
1874        assert_eq!(explorer.cursor, 0);
1875        assert_eq!(explorer.scroll_offset, 0);
1876    }
1877
1878    #[test]
1879    fn g_key_jumps_to_top() {
1880        let tmp = temp_dir_with_files();
1881        let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1882        explorer.cursor = explorer.entries.len() - 1;
1883        explorer.handle_key(key(KeyCode::Char('g')));
1884        assert_eq!(explorer.cursor, 0);
1885        assert_eq!(explorer.scroll_offset, 0);
1886    }
1887
1888    #[test]
1889    fn end_key_jumps_to_bottom() {
1890        let tmp = temp_dir_with_files();
1891        let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1892        explorer.cursor = 0;
1893        explorer.handle_key(key(KeyCode::End));
1894        assert_eq!(explorer.cursor, explorer.entries.len() - 1);
1895    }
1896
1897    #[test]
1898    fn capital_g_key_jumps_to_bottom() {
1899        let tmp = temp_dir_with_files();
1900        let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1901        explorer.cursor = 0;
1902        let key_g = KeyEvent::new(KeyCode::Char('G'), KeyModifiers::NONE);
1903        explorer.handle_key(key_g);
1904        assert_eq!(explorer.cursor, explorer.entries.len() - 1);
1905    }
1906
1907    // ── Root / status tests ───────────────────────────────────────────────────
1908
1909    #[test]
1910    fn ascend_at_root_sets_status() {
1911        // Use "/" as a reliable filesystem root on macOS/Linux.
1912        let root = std::path::PathBuf::from("/");
1913        let mut explorer = FileExplorer::new(root.clone(), vec![]);
1914        assert!(explorer.is_at_root());
1915        // Still at root after attempted ascend.
1916        explorer.handle_key(key(KeyCode::Backspace));
1917        assert_eq!(explorer.current_dir, root);
1918        assert!(
1919            !explorer.status().is_empty(),
1920            "status should report already at root"
1921        );
1922    }
1923
1924    #[test]
1925    fn is_at_root_false_for_subdir() {
1926        let tmp = temp_dir_with_files();
1927        let explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1928        assert!(!explorer.is_at_root());
1929    }
1930
1931    // ── Accessor tests ────────────────────────────────────────────────────────
1932
1933    #[test]
1934    fn is_empty_reflects_visible_entries() {
1935        let empty_dir = tempfile::tempdir().unwrap();
1936        let explorer = FileExplorer::new(empty_dir.path().to_path_buf(), vec![]);
1937        assert!(explorer.is_empty());
1938
1939        let tmp = temp_dir_with_files();
1940        let explorer2 = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1941        assert!(!explorer2.is_empty());
1942    }
1943
1944    #[test]
1945    fn entry_count_matches_entries_len() {
1946        let tmp = temp_dir_with_files();
1947        let explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1948        assert_eq!(explorer.entry_count(), explorer.entries.len());
1949        assert!(explorer.entry_count() > 0);
1950    }
1951
1952    #[test]
1953    fn search_query_empty_when_not_searching() {
1954        let tmp = temp_dir_with_files();
1955        let explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1956        assert!(!explorer.is_searching());
1957        assert_eq!(explorer.search_query(), "");
1958    }
1959
1960    // ── Case-insensitivity tests ──────────────────────────────────────────────
1961
1962    #[test]
1963    fn search_is_case_insensitive() {
1964        let tmp = temp_dir_with_files();
1965        let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
1966        // Type "UBU" in uppercase — should still match "ubuntu.iso".
1967        explorer.handle_key(key(KeyCode::Char('/')));
1968        for c in "UBU".chars() {
1969            explorer.handle_key(key(KeyCode::Char(c)));
1970        }
1971        assert_eq!(explorer.entries.len(), 1);
1972        assert_eq!(explorer.entries[0].name, "ubuntu.iso");
1973    }
1974
1975    #[test]
1976    fn extension_filter_is_case_insensitive() {
1977        let tmp = tempfile::tempdir().unwrap();
1978        // File whose on-disk extension is upper-case.
1979        fs::write(tmp.path().join("disk.ISO"), b"data").unwrap();
1980        fs::write(tmp.path().join("other.txt"), b"text").unwrap();
1981
1982        // Filter expressed in lower-case should still match the upper-case ext.
1983        let explorer = FileExplorer::new(tmp.path().to_path_buf(), vec!["iso".into()]);
1984        assert!(
1985            explorer.entries.iter().any(|e| e.name == "disk.ISO"),
1986            "upper-case extension should be matched by lower-case filter"
1987        );
1988        assert!(
1989            !explorer.entries.iter().any(|e| e.name == "other.txt"),
1990            "non-matching extension should be excluded"
1991        );
1992    }
1993
1994    // ── Builder tests ─────────────────────────────────────────────────────────
1995
1996    #[test]
1997    fn builder_allow_extension_filters_entries() {
1998        let tmp = temp_dir_with_files();
1999        let explorer = FileExplorer::builder(tmp.path().to_path_buf())
2000            .allow_extension("iso")
2001            .build();
2002        assert!(explorer.entries.iter().any(|e| e.name == "ubuntu.iso"));
2003        assert!(!explorer.entries.iter().any(|e| e.name == "debian.img"));
2004        assert!(!explorer.entries.iter().any(|e| e.name == "readme.txt"));
2005    }
2006
2007    #[test]
2008    fn builder_show_hidden_shows_dotfiles() {
2009        let tmp = temp_dir_with_files();
2010        fs::write(tmp.path().join(".dotfile"), b"").unwrap();
2011
2012        let hidden_explorer = FileExplorer::builder(tmp.path().to_path_buf())
2013            .show_hidden(true)
2014            .build();
2015        assert!(hidden_explorer.entries.iter().any(|e| e.name == ".dotfile"));
2016
2017        let normal_explorer = FileExplorer::builder(tmp.path().to_path_buf())
2018            .show_hidden(false)
2019            .build();
2020        assert!(!normal_explorer.entries.iter().any(|e| e.name == ".dotfile"));
2021    }
2022
2023    #[test]
2024    fn set_extension_filter_updates_entries() {
2025        let tmp = temp_dir_with_files();
2026        let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
2027        // All files visible with no filter.
2028        assert!(explorer.entries.iter().any(|e| e.name == "readme.txt"));
2029
2030        explorer.set_extension_filter(["iso"]);
2031        assert!(explorer.entries.iter().any(|e| e.name == "ubuntu.iso"));
2032        assert!(!explorer.entries.iter().any(|e| e.name == "readme.txt"));
2033    }
2034
2035    // ── entry_icon tests ──────────────────────────────────────────────────────
2036
2037    #[test]
2038    fn entry_icon_directory() {
2039        let entry = FsEntry {
2040            name: "mydir".into(),
2041            path: std::path::PathBuf::from("/mydir"),
2042            is_dir: true,
2043            size: None,
2044            extension: String::new(),
2045        };
2046        assert_eq!(entry_icon(&entry), "📁");
2047    }
2048
2049    #[test]
2050    fn entry_icon_recognises_known_extensions() {
2051        let make = |name: &str, ext: &str| FsEntry {
2052            name: name.into(),
2053            path: std::path::PathBuf::from(name),
2054            is_dir: false,
2055            size: Some(0),
2056            extension: ext.into(),
2057        };
2058
2059        assert_eq!(entry_icon(&make("archive.zip", "zip")), "📦");
2060        assert_eq!(entry_icon(&make("doc.pdf", "pdf")), "📕");
2061        assert_eq!(entry_icon(&make("notes.md", "md")), "📝");
2062        assert_eq!(entry_icon(&make("config.toml", "toml")), "⚙ ");
2063        assert_eq!(entry_icon(&make("main.rs", "rs")), "🦀");
2064        assert_eq!(entry_icon(&make("script.py", "py")), "🐍");
2065        assert_eq!(entry_icon(&make("page.html", "html")), "🌐");
2066        assert_eq!(entry_icon(&make("image.png", "png")), "🖼 ");
2067        assert_eq!(entry_icon(&make("video.mp4", "mp4")), "🎬");
2068        assert_eq!(entry_icon(&make("song.mp3", "mp3")), "🎵");
2069        assert_eq!(entry_icon(&make("unknown.xyz", "xyz")), "📄");
2070    }
2071
2072    // ── fmt_size boundary tests ───────────────────────────────────────────────
2073
2074    #[test]
2075    fn fmt_size_exact_boundaries() {
2076        // Exact powers of 1024.
2077        assert_eq!(fmt_size(1_024), "1.0 KB");
2078        assert_eq!(fmt_size(1_048_576), "1.0 MB");
2079        assert_eq!(fmt_size(1_073_741_824), "1.0 GB");
2080        // Just below each boundary stays in the lower unit.
2081        assert_eq!(fmt_size(1_023), "1023 B");
2082        assert_eq!(fmt_size(1_047_552), "1023.0 KB"); // 1023 * 1024
2083    }
2084
2085    // ── toggle_mark / clear_marks / Space key ─────────────────────────────────
2086
2087    #[test]
2088    fn toggle_mark_adds_entry_to_marked_set() {
2089        let dir = temp_dir_with_files();
2090        let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
2091        assert!(!explorer.entries.is_empty(), "need at least one entry");
2092
2093        explorer.toggle_mark();
2094
2095        assert_eq!(explorer.marked.len(), 1, "one entry should be marked");
2096    }
2097
2098    #[test]
2099    fn toggle_mark_removes_already_marked_entry() {
2100        let dir = temp_dir_with_files();
2101        let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
2102
2103        explorer.toggle_mark(); // mark
2104        let cursor_after_first = explorer.cursor;
2105        explorer.cursor = 0; // reset to the same entry
2106        explorer.toggle_mark(); // unmark
2107
2108        assert!(
2109            explorer.marked.is_empty(),
2110            "second toggle on same entry should unmark it"
2111        );
2112        let _ = cursor_after_first; // suppress unused warning
2113    }
2114
2115    #[test]
2116    fn toggle_mark_advances_cursor_down() {
2117        let dir = temp_dir_with_files();
2118        let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
2119        // Ensure there are at least two entries so the cursor can advance.
2120        assert!(
2121            explorer.entries.len() >= 2,
2122            "fixture must have at least 2 entries"
2123        );
2124
2125        let before = explorer.cursor;
2126        explorer.toggle_mark();
2127
2128        assert_eq!(
2129            explorer.cursor,
2130            before + 1,
2131            "cursor should advance by one after toggle_mark"
2132        );
2133    }
2134
2135    #[test]
2136    fn toggle_mark_at_last_entry_does_not_overflow() {
2137        let dir = temp_dir_with_files();
2138        let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
2139        explorer.cursor = explorer.entries.len() - 1;
2140
2141        explorer.toggle_mark();
2142
2143        assert_eq!(
2144            explorer.cursor,
2145            explorer.entries.len() - 1,
2146            "cursor should stay at the last entry, not overflow"
2147        );
2148    }
2149
2150    #[test]
2151    fn clear_marks_empties_marked_set() {
2152        let dir = temp_dir_with_files();
2153        let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
2154
2155        explorer.toggle_mark();
2156        assert!(
2157            !explorer.marked.is_empty(),
2158            "should have a mark before clear"
2159        );
2160
2161        explorer.clear_marks();
2162
2163        assert!(
2164            explorer.marked.is_empty(),
2165            "marked set should be empty after clear_marks"
2166        );
2167    }
2168
2169    #[test]
2170    fn space_key_marks_current_entry() {
2171        let dir = temp_dir_with_files();
2172        let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
2173        assert!(!explorer.entries.is_empty(), "need at least one entry");
2174
2175        let outcome = explorer.handle_key(key(KeyCode::Char(' ')));
2176
2177        assert_eq!(
2178            outcome,
2179            ExplorerOutcome::Pending,
2180            "Space should return Pending"
2181        );
2182        assert_eq!(
2183            explorer.marked.len(),
2184            1,
2185            "Space should mark the current entry"
2186        );
2187    }
2188
2189    #[test]
2190    fn space_key_toggles_mark_off() {
2191        let dir = temp_dir_with_files();
2192        let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
2193
2194        explorer.handle_key(key(KeyCode::Char(' '))); // mark → cursor moves down
2195        explorer.cursor = 0; // reset to entry 0
2196        explorer.handle_key(key(KeyCode::Char(' '))); // unmark
2197
2198        assert!(
2199            explorer.marked.is_empty(),
2200            "second Space on same entry should unmark it"
2201        );
2202    }
2203
2204    #[test]
2205    fn marks_cleared_when_ascending_to_parent() {
2206        let dir = temp_dir_with_files();
2207        // Start inside the subdir so we can ascend.
2208        let sub = dir.path().join("subdir");
2209        fs::write(sub.join("inner.txt"), b"inner").unwrap();
2210        let mut explorer = FileExplorer::new(sub.clone(), vec![]);
2211
2212        explorer.toggle_mark();
2213        assert!(
2214            !explorer.marked.is_empty(),
2215            "should have a mark before ascend"
2216        );
2217
2218        // Ascend via Backspace.
2219        explorer.handle_key(key(KeyCode::Backspace));
2220
2221        assert!(
2222            explorer.marked.is_empty(),
2223            "marks should be cleared after ascending to parent"
2224        );
2225    }
2226
2227    #[test]
2228    fn marks_cleared_when_descending_into_directory() {
2229        let dir = temp_dir_with_files();
2230        let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
2231
2232        // Mark the subdirectory entry.
2233        let sub_idx = explorer
2234            .entries
2235            .iter()
2236            .position(|e| e.is_dir)
2237            .expect("fixture has a subdir");
2238        explorer.cursor = sub_idx;
2239        explorer.toggle_mark();
2240        assert!(
2241            !explorer.marked.is_empty(),
2242            "should have a mark before descend"
2243        );
2244
2245        // Reset cursor back to the directory entry (toggle_mark advanced it).
2246        explorer.cursor = explorer
2247            .entries
2248            .iter()
2249            .position(|e| e.is_dir)
2250            .expect("fixture has a subdir");
2251
2252        // Descend into the subdirectory — confirm() clears marks.
2253        explorer.handle_key(key(KeyCode::Enter));
2254
2255        assert!(
2256            explorer.marked.is_empty(),
2257            "marks should be cleared after descending into a directory"
2258        );
2259    }
2260
2261    #[test]
2262    fn can_mark_multiple_entries() {
2263        let dir = temp_dir_with_files();
2264        let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
2265        let total = explorer.entries.len();
2266        assert!(total >= 2, "fixture must have at least 2 entries");
2267
2268        // Mark every entry.
2269        for _ in 0..total {
2270            explorer.toggle_mark();
2271        }
2272
2273        assert_eq!(explorer.marked.len(), total, "all entries should be marked");
2274    }
2275
2276    // ── Cursor / scroll boundary safety ──────────────────────────────────────
2277
2278    #[test]
2279    fn move_up_at_top_does_not_underflow() {
2280        let dir = tempdir().expect("tempdir");
2281        fs::write(dir.path().join("a.txt"), b"a").unwrap();
2282        let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
2283        explorer.cursor = 0;
2284        // Should be a no-op, not a panic.
2285        explorer.handle_key(key(KeyCode::Up));
2286        assert_eq!(explorer.cursor, 0);
2287    }
2288
2289    #[test]
2290    fn move_down_at_bottom_does_not_overflow() {
2291        let dir = tempdir().expect("tempdir");
2292        fs::write(dir.path().join("a.txt"), b"a").unwrap();
2293        let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
2294        let last = explorer.entries.len().saturating_sub(1);
2295        explorer.cursor = last;
2296        explorer.handle_key(key(KeyCode::Down));
2297        assert_eq!(explorer.cursor, last);
2298    }
2299
2300    #[test]
2301    fn move_down_on_empty_dir_does_not_panic() {
2302        let dir = tempdir().expect("tempdir");
2303        let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
2304        assert!(explorer.entries.is_empty());
2305        // Must not panic.
2306        explorer.handle_key(key(KeyCode::Down));
2307        assert_eq!(explorer.cursor, 0);
2308    }
2309
2310    #[test]
2311    fn move_up_on_empty_dir_does_not_panic() {
2312        let dir = tempdir().expect("tempdir");
2313        let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
2314        assert!(explorer.entries.is_empty());
2315        explorer.handle_key(key(KeyCode::Up));
2316        assert_eq!(explorer.cursor, 0);
2317    }
2318
2319    #[test]
2320    fn page_down_at_bottom_does_not_overflow() {
2321        let dir = tempdir().expect("tempdir");
2322        for i in 0..5 {
2323            fs::write(dir.path().join(format!("{i}.txt")), b"x").unwrap();
2324        }
2325        let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
2326        let last = explorer.entries.len().saturating_sub(1);
2327        explorer.cursor = last;
2328        explorer.handle_key(key(KeyCode::PageDown));
2329        assert_eq!(explorer.cursor, last);
2330    }
2331
2332    #[test]
2333    fn page_up_at_top_does_not_underflow() {
2334        let dir = tempdir().expect("tempdir");
2335        fs::write(dir.path().join("a.txt"), b"a").unwrap();
2336        let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
2337        explorer.cursor = 0;
2338        explorer.handle_key(key(KeyCode::PageUp));
2339        assert_eq!(explorer.cursor, 0);
2340    }
2341
2342    #[test]
2343    fn ascend_at_root_does_not_panic() {
2344        let mut explorer = FileExplorer::new(std::path::PathBuf::from("/"), vec![]);
2345        // Pressing Backspace at root must not panic — it should stay put.
2346        explorer.handle_key(key(KeyCode::Backspace));
2347        assert_eq!(explorer.current_dir, std::path::PathBuf::from("/"));
2348    }
2349
2350    #[test]
2351    fn cursor_clamped_after_reload_with_fewer_entries() {
2352        let dir = tempdir().expect("tempdir");
2353        fs::write(dir.path().join("a.txt"), b"a").unwrap();
2354        fs::write(dir.path().join("b.txt"), b"b").unwrap();
2355        fs::write(dir.path().join("c.txt"), b"c").unwrap();
2356        let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
2357        // Move to last entry.
2358        explorer.cursor = explorer.entries.len() - 1;
2359        // Now apply a filter that shows only one file — reload happens inside.
2360        explorer.set_extension_filter(["a"]);
2361        // Cursor must be clamped to the new (smaller) list.
2362        assert!(
2363            explorer.cursor < explorer.entries.len().max(1),
2364            "cursor {} out of range for {} entries",
2365            explorer.cursor,
2366            explorer.entries.len()
2367        );
2368    }
2369
2370    #[test]
2371    fn scroll_offset_clamped_after_reload_with_empty_entries() {
2372        let dir = tempdir().expect("tempdir");
2373        fs::write(dir.path().join("test.rs"), b"fn main(){}").unwrap();
2374        let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
2375        explorer.scroll_offset = 5; // artificially stale
2376        explorer.cursor = 0;
2377        // Apply a filter that matches nothing — entries becomes empty.
2378        explorer.set_extension_filter(["xyz"]);
2379        assert_eq!(explorer.cursor, 0);
2380        assert_eq!(explorer.scroll_offset, 0);
2381    }
2382
2383    #[test]
2384    fn marked_paths_returns_reference_to_marked_set() {
2385        let dir = temp_dir_with_files();
2386        let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
2387
2388        explorer.toggle_mark();
2389
2390        assert_eq!(
2391            explorer.marked_paths().len(),
2392            explorer.marked.len(),
2393            "marked_paths() should reflect the same set as the field"
2394        );
2395    }
2396
2397    // ── entry_icon — extended coverage ───────────────────────────────────────
2398
2399    fn make_file_entry(name: &str) -> crate::types::FsEntry {
2400        let ext = std::path::Path::new(name)
2401            .extension()
2402            .map(|e| e.to_string_lossy().to_lowercase())
2403            .unwrap_or_default();
2404        crate::types::FsEntry {
2405            name: name.to_string(),
2406            path: std::path::PathBuf::from(name),
2407            is_dir: false,
2408            size: None,
2409            extension: ext,
2410        }
2411    }
2412
2413    #[test]
2414    fn entry_icon_iso_returns_disc() {
2415        let e = make_file_entry("release.iso");
2416        assert_eq!(entry_icon(&e), "💿");
2417    }
2418
2419    #[test]
2420    fn entry_icon_dmg_returns_disc() {
2421        let e = make_file_entry("app.dmg");
2422        assert_eq!(entry_icon(&e), "💿");
2423    }
2424
2425    #[test]
2426    fn entry_icon_zip_returns_package() {
2427        let e = make_file_entry("archive.zip");
2428        assert_eq!(entry_icon(&e), "📦");
2429    }
2430
2431    #[test]
2432    fn entry_icon_tar_returns_package() {
2433        let e = make_file_entry("src.tar");
2434        assert_eq!(entry_icon(&e), "📦");
2435    }
2436
2437    #[test]
2438    fn entry_icon_gz_returns_package() {
2439        let e = make_file_entry("data.gz");
2440        assert_eq!(entry_icon(&e), "📦");
2441    }
2442
2443    #[test]
2444    fn entry_icon_pdf_returns_book() {
2445        let e = make_file_entry("manual.pdf");
2446        assert_eq!(entry_icon(&e), "📕");
2447    }
2448
2449    #[test]
2450    fn entry_icon_md_returns_memo() {
2451        let e = make_file_entry("README.md");
2452        assert_eq!(entry_icon(&e), "📝");
2453    }
2454
2455    #[test]
2456    fn entry_icon_toml_returns_gear() {
2457        let e = make_file_entry("Cargo.toml");
2458        assert_eq!(entry_icon(&e), "⚙ ");
2459    }
2460
2461    #[test]
2462    fn entry_icon_json_returns_gear() {
2463        let e = make_file_entry("config.json");
2464        assert_eq!(entry_icon(&e), "⚙ ");
2465    }
2466
2467    #[test]
2468    fn entry_icon_lock_returns_lock() {
2469        let e = make_file_entry("Cargo.lock");
2470        assert_eq!(entry_icon(&e), "🔒");
2471    }
2472
2473    #[test]
2474    fn entry_icon_py_returns_snake() {
2475        let e = make_file_entry("script.py");
2476        assert_eq!(entry_icon(&e), "🐍");
2477    }
2478
2479    #[test]
2480    fn entry_icon_html_returns_globe() {
2481        let e = make_file_entry("index.html");
2482        assert_eq!(entry_icon(&e), "🌐");
2483    }
2484
2485    #[test]
2486    fn entry_icon_css_returns_palette() {
2487        let e = make_file_entry("style.css");
2488        assert_eq!(entry_icon(&e), "🎨");
2489    }
2490
2491    #[test]
2492    fn entry_icon_svg_returns_palette() {
2493        let e = make_file_entry("logo.svg");
2494        assert_eq!(entry_icon(&e), "🎨");
2495    }
2496
2497    #[test]
2498    fn entry_icon_png_returns_image() {
2499        let e = make_file_entry("photo.png");
2500        assert_eq!(entry_icon(&e), "🖼 ");
2501    }
2502
2503    #[test]
2504    fn entry_icon_jpg_returns_image() {
2505        let e = make_file_entry("photo.jpg");
2506        assert_eq!(entry_icon(&e), "🖼 ");
2507    }
2508
2509    #[test]
2510    fn entry_icon_mp4_returns_film() {
2511        let e = make_file_entry("video.mp4");
2512        assert_eq!(entry_icon(&e), "🎬");
2513    }
2514
2515    #[test]
2516    fn entry_icon_mp3_returns_music() {
2517        let e = make_file_entry("song.mp3");
2518        assert_eq!(entry_icon(&e), "🎵");
2519    }
2520
2521    #[test]
2522    fn entry_icon_ttf_returns_font() {
2523        let e = make_file_entry("font.ttf");
2524        assert_eq!(entry_icon(&e), "🔤");
2525    }
2526
2527    #[test]
2528    fn entry_icon_exe_returns_gear() {
2529        let e = make_file_entry("setup.exe");
2530        assert_eq!(entry_icon(&e), "⚙ ");
2531    }
2532
2533    #[test]
2534    fn entry_icon_unknown_extension_returns_document() {
2535        let e = make_file_entry("mystery.xyz");
2536        assert_eq!(entry_icon(&e), "📄");
2537    }
2538
2539    #[test]
2540    fn entry_icon_no_extension_returns_document() {
2541        let e = crate::types::FsEntry {
2542            name: "Makefile".into(),
2543            path: std::path::PathBuf::from("Makefile"),
2544            is_dir: false,
2545            size: None,
2546            extension: String::new(),
2547        };
2548        assert_eq!(entry_icon(&e), "📄");
2549    }
2550
2551    // ── fmt_size — full boundary coverage ────────────────────────────────────
2552
2553    #[test]
2554    fn fmt_size_zero_bytes() {
2555        assert_eq!(fmt_size(0), "0 B");
2556    }
2557
2558    #[test]
2559    fn fmt_size_one_byte() {
2560        assert_eq!(fmt_size(1), "1 B");
2561    }
2562
2563    #[test]
2564    fn fmt_size_1023_bytes_stays_bytes() {
2565        assert_eq!(fmt_size(1_023), "1023 B");
2566    }
2567
2568    #[test]
2569    fn fmt_size_exactly_1_kb() {
2570        assert_eq!(fmt_size(1_024), "1.0 KB");
2571    }
2572
2573    #[test]
2574    fn fmt_size_1_5_kb() {
2575        assert_eq!(fmt_size(1_536), "1.5 KB");
2576    }
2577
2578    #[test]
2579    fn fmt_size_1_mb_boundary() {
2580        assert_eq!(fmt_size(1_048_576), "1.0 MB");
2581    }
2582
2583    #[test]
2584    fn fmt_size_2_mb() {
2585        assert_eq!(fmt_size(2_097_152), "2.0 MB");
2586    }
2587
2588    #[test]
2589    fn fmt_size_1_gb_boundary() {
2590        assert_eq!(fmt_size(1_073_741_824), "1.0 GB");
2591    }
2592
2593    #[test]
2594    fn fmt_size_large_value() {
2595        // 10 GB
2596        assert_eq!(fmt_size(10 * 1_073_741_824), "10.0 GB");
2597    }
2598
2599    // ── navigate_to — &str and &Path inputs ──────────────────────────────────
2600
2601    #[test]
2602    fn navigate_to_accepts_str_slice() {
2603        let dir = tempdir().expect("tempdir");
2604        let sub = dir.path().join("sub");
2605        fs::create_dir(&sub).unwrap();
2606
2607        let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
2608        explorer.navigate_to(sub.to_str().unwrap());
2609        assert_eq!(explorer.current_dir, sub);
2610    }
2611
2612    #[test]
2613    fn navigate_to_accepts_path_ref() {
2614        let dir = tempdir().expect("tempdir");
2615        let sub = dir.path().join("sub2");
2616        fs::create_dir(&sub).unwrap();
2617
2618        let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
2619        explorer.navigate_to(sub.as_path());
2620        assert_eq!(explorer.current_dir, sub);
2621    }
2622
2623    #[test]
2624    fn navigate_to_resets_cursor_to_zero() {
2625        let dir = tempdir().expect("tempdir");
2626        let sub = dir.path().join("sub3");
2627        fs::create_dir(&sub).unwrap();
2628
2629        let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
2630        explorer.cursor = 99;
2631        explorer.scroll_offset = 5;
2632        explorer.navigate_to(sub.as_path());
2633        assert_eq!(explorer.cursor, 0);
2634        assert_eq!(explorer.scroll_offset, 0);
2635    }
2636
2637    // ── is_searching accessor ─────────────────────────────────────────────────
2638
2639    #[test]
2640    fn is_searching_false_by_default() {
2641        let dir = tempdir().expect("tempdir");
2642        let explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
2643        assert!(!explorer.is_searching());
2644    }
2645
2646    #[test]
2647    fn is_searching_true_after_slash_key() {
2648        let dir = tempdir().expect("tempdir");
2649        let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
2650        explorer.handle_key(key(KeyCode::Char('/')));
2651        assert!(explorer.is_searching());
2652    }
2653
2654    #[test]
2655    fn is_searching_false_after_esc_cancels_search() {
2656        let dir = tempdir().expect("tempdir");
2657        let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
2658        explorer.handle_key(key(KeyCode::Char('/')));
2659        explorer.handle_key(key(KeyCode::Esc));
2660        assert!(!explorer.is_searching());
2661    }
2662
2663    // ── status cleared on reload ──────────────────────────────────────────────
2664
2665    #[test]
2666    fn status_is_empty_on_fresh_explorer() {
2667        let dir = tempdir().expect("tempdir");
2668        let explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
2669        assert!(explorer.status().is_empty());
2670    }
2671
2672    #[test]
2673    fn status_cleared_after_reload() {
2674        let dir = tempdir().expect("tempdir");
2675        let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
2676        // Manually set a stale status message.
2677        explorer.status = "stale message".into();
2678        explorer.reload();
2679        assert!(
2680            explorer.status().is_empty(),
2681            "reload should clear the status message"
2682        );
2683    }
2684
2685    // ── load_entries with an empty directory ──────────────────────────────────
2686
2687    #[test]
2688    fn load_entries_empty_dir_returns_empty_vec() {
2689        let dir = tempdir().expect("tempdir");
2690        let entries = load_entries(dir.path(), false, &[], crate::types::SortMode::Name, "");
2691        assert!(
2692            entries.is_empty(),
2693            "empty directory should produce no entries"
2694        );
2695    }
2696
2697    #[test]
2698    fn load_entries_hidden_excluded_by_default() {
2699        let dir = tempdir().expect("tempdir");
2700        fs::write(dir.path().join(".hidden"), b"h").unwrap();
2701        fs::write(dir.path().join("visible.txt"), b"v").unwrap();
2702
2703        let entries = load_entries(dir.path(), false, &[], crate::types::SortMode::Name, "");
2704        assert_eq!(entries.len(), 1);
2705        assert_eq!(entries[0].name, "visible.txt");
2706    }
2707
2708    #[test]
2709    fn load_entries_hidden_included_when_show_hidden_true() {
2710        let dir = tempdir().expect("tempdir");
2711        fs::write(dir.path().join(".hidden"), b"h").unwrap();
2712        fs::write(dir.path().join("visible.txt"), b"v").unwrap();
2713
2714        let entries = load_entries(dir.path(), true, &[], crate::types::SortMode::Name, "");
2715        assert_eq!(entries.len(), 2);
2716    }
2717
2718    #[test]
2719    fn load_entries_nonexistent_dir_returns_empty_vec() {
2720        let entries = load_entries(
2721            std::path::Path::new("/nonexistent/path/that/does/not/exist"),
2722            false,
2723            &[],
2724            crate::types::SortMode::Name,
2725            "",
2726        );
2727        assert!(entries.is_empty());
2728    }
2729
2730    #[test]
2731    fn load_entries_search_query_is_case_insensitive() {
2732        let dir = tempdir().expect("tempdir");
2733        fs::write(dir.path().join("README.md"), b"r").unwrap();
2734        fs::write(dir.path().join("main.rs"), b"m").unwrap();
2735
2736        let entries = load_entries(
2737            dir.path(),
2738            false,
2739            &[],
2740            crate::types::SortMode::Name,
2741            "readme",
2742        );
2743        assert_eq!(entries.len(), 1);
2744        assert_eq!(entries[0].name, "README.md");
2745    }
2746
2747    #[test]
2748    fn load_entries_dirs_always_precede_files() {
2749        let dir = tempdir().expect("tempdir");
2750        fs::write(dir.path().join("z_file.txt"), b"z").unwrap();
2751        fs::create_dir(dir.path().join("a_dir")).unwrap();
2752
2753        let entries = load_entries(dir.path(), false, &[], crate::types::SortMode::Name, "");
2754        assert!(entries[0].is_dir, "directory must come before file");
2755        assert!(!entries[1].is_dir);
2756    }
2757
2758    #[test]
2759    fn load_entries_ext_filter_excludes_non_matching_files() {
2760        let dir = tempdir().expect("tempdir");
2761        fs::write(dir.path().join("main.rs"), b"r").unwrap();
2762        fs::write(dir.path().join("Cargo.toml"), b"t").unwrap();
2763
2764        let filter = vec!["rs".to_string()];
2765        let entries = load_entries(dir.path(), false, &filter, crate::types::SortMode::Name, "");
2766        assert_eq!(entries.len(), 1);
2767        assert_eq!(entries[0].extension, "rs");
2768    }
2769
2770    #[test]
2771    fn load_entries_ext_filter_always_includes_dirs() {
2772        let dir = tempdir().expect("tempdir");
2773        fs::create_dir(dir.path().join("subdir")).unwrap();
2774        fs::write(dir.path().join("file.txt"), b"t").unwrap();
2775
2776        // Filter for .rs — the dir should still appear, the .txt file should not.
2777        let filter = vec!["rs".to_string()];
2778        let entries = load_entries(dir.path(), false, &filter, crate::types::SortMode::Name, "");
2779        assert_eq!(entries.len(), 1);
2780        assert!(entries[0].is_dir);
2781    }
2782
2783    // ── Rename mode ───────────────────────────────────────────────────────────
2784
2785    #[test]
2786    fn r_key_activates_rename_mode_with_prefilled_name() {
2787        let tmp = temp_dir_with_files();
2788        let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
2789        // Move cursor to a known file.
2790        let idx = explorer
2791            .entries
2792            .iter()
2793            .position(|e| e.name == "readme.txt")
2794            .expect("readme.txt present");
2795        explorer.cursor = idx;
2796
2797        let outcome = explorer.handle_key(key(KeyCode::Char('r')));
2798        assert_eq!(outcome, ExplorerOutcome::Pending);
2799        assert!(explorer.is_rename_active());
2800        assert_eq!(explorer.rename_input(), "readme.txt");
2801    }
2802
2803    #[test]
2804    fn r_key_on_empty_dir_does_not_activate_rename() {
2805        let dir = tempdir().expect("tempdir");
2806        let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
2807        assert!(explorer.entries.is_empty());
2808
2809        let outcome = explorer.handle_key(key(KeyCode::Char('r')));
2810        assert_eq!(outcome, ExplorerOutcome::Pending);
2811        assert!(!explorer.is_rename_active());
2812    }
2813
2814    #[test]
2815    fn rename_mode_chars_append_to_input() {
2816        let tmp = temp_dir_with_files();
2817        let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
2818        explorer.handle_key(key(KeyCode::Char('r')));
2819        assert!(explorer.is_rename_active());
2820
2821        // Clear the prefilled name and type a fresh one.
2822        let original_len = explorer.rename_input().len();
2823        for _ in 0..original_len {
2824            explorer.handle_key(key(KeyCode::Backspace));
2825        }
2826        explorer.handle_key(key(KeyCode::Char('n')));
2827        explorer.handle_key(key(KeyCode::Char('e')));
2828        explorer.handle_key(key(KeyCode::Char('w')));
2829
2830        assert_eq!(explorer.rename_input(), "new");
2831        assert!(explorer.is_rename_active());
2832    }
2833
2834    #[test]
2835    fn rename_mode_backspace_pops_last_char() {
2836        let tmp = temp_dir_with_files();
2837        let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
2838        explorer.handle_key(key(KeyCode::Char('r')));
2839
2840        // Pop all chars then type "ab".
2841        let original_len = explorer.rename_input().len();
2842        for _ in 0..original_len {
2843            explorer.handle_key(key(KeyCode::Backspace));
2844        }
2845        explorer.handle_key(key(KeyCode::Char('a')));
2846        explorer.handle_key(key(KeyCode::Char('b')));
2847        assert_eq!(explorer.rename_input(), "ab");
2848
2849        explorer.handle_key(key(KeyCode::Backspace));
2850        assert_eq!(explorer.rename_input(), "a");
2851    }
2852
2853    #[test]
2854    fn rename_mode_esc_cancels_without_renaming() {
2855        let tmp = temp_dir_with_files();
2856        let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
2857        let idx = explorer
2858            .entries
2859            .iter()
2860            .position(|e| e.name == "readme.txt")
2861            .expect("readme.txt present");
2862        explorer.cursor = idx;
2863
2864        explorer.handle_key(key(KeyCode::Char('r')));
2865        assert!(explorer.is_rename_active());
2866
2867        let outcome = explorer.handle_key(key(KeyCode::Esc));
2868        assert_eq!(outcome, ExplorerOutcome::Pending);
2869        assert!(!explorer.is_rename_active());
2870        assert_eq!(explorer.rename_input(), "");
2871        // File must still exist under the old name.
2872        assert!(tmp.path().join("readme.txt").exists());
2873    }
2874
2875    #[test]
2876    fn rename_mode_enter_renames_file_and_returns_rename_completed() {
2877        let tmp = temp_dir_with_files();
2878        let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
2879        let idx = explorer
2880            .entries
2881            .iter()
2882            .position(|e| e.name == "readme.txt")
2883            .expect("readme.txt present");
2884        explorer.cursor = idx;
2885
2886        // Activate rename, clear prefill, type new name.
2887        explorer.handle_key(key(KeyCode::Char('r')));
2888        let prefill_len = explorer.rename_input().len();
2889        for _ in 0..prefill_len {
2890            explorer.handle_key(key(KeyCode::Backspace));
2891        }
2892        for c in "notes.txt".chars() {
2893            explorer.handle_key(key(KeyCode::Char(c)));
2894        }
2895
2896        let outcome = explorer.handle_key(key(KeyCode::Enter));
2897
2898        assert!(!explorer.is_rename_active());
2899        assert_eq!(explorer.rename_input(), "");
2900        assert!(tmp.path().join("notes.txt").exists(), "new name must exist");
2901        assert!(
2902            !tmp.path().join("readme.txt").exists(),
2903            "old name must be gone"
2904        );
2905        assert!(
2906            matches!(outcome, ExplorerOutcome::RenameCompleted(p) if p.file_name().unwrap() == "notes.txt")
2907        );
2908    }
2909
2910    #[test]
2911    fn rename_mode_cursor_moves_to_renamed_entry() {
2912        let tmp = temp_dir_with_files();
2913        let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
2914        let idx = explorer
2915            .entries
2916            .iter()
2917            .position(|e| e.name == "readme.txt")
2918            .expect("readme.txt present");
2919        explorer.cursor = idx;
2920
2921        explorer.handle_key(key(KeyCode::Char('r')));
2922        let prefill_len = explorer.rename_input().len();
2923        for _ in 0..prefill_len {
2924            explorer.handle_key(key(KeyCode::Backspace));
2925        }
2926        for c in "zzz_last.txt".chars() {
2927            explorer.handle_key(key(KeyCode::Char(c)));
2928        }
2929        explorer.handle_key(key(KeyCode::Enter));
2930
2931        let new_idx = explorer
2932            .entries
2933            .iter()
2934            .position(|e| e.name == "zzz_last.txt")
2935            .expect("renamed entry in list");
2936        assert_eq!(explorer.cursor, new_idx);
2937    }
2938
2939    #[test]
2940    fn rename_mode_enter_with_empty_input_is_noop() {
2941        let tmp = temp_dir_with_files();
2942        let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
2943        let idx = explorer
2944            .entries
2945            .iter()
2946            .position(|e| e.name == "readme.txt")
2947            .expect("readme.txt present");
2948        explorer.cursor = idx;
2949
2950        explorer.handle_key(key(KeyCode::Char('r')));
2951        // Erase the prefilled name entirely, then confirm.
2952        let prefill_len = explorer.rename_input().len();
2953        for _ in 0..prefill_len {
2954            explorer.handle_key(key(KeyCode::Backspace));
2955        }
2956        assert_eq!(explorer.rename_input(), "");
2957
2958        let outcome = explorer.handle_key(key(KeyCode::Enter));
2959        assert_eq!(outcome, ExplorerOutcome::Pending);
2960        assert!(!explorer.is_rename_active());
2961        // Original file must still exist.
2962        assert!(tmp.path().join("readme.txt").exists());
2963    }
2964
2965    #[test]
2966    fn rename_mode_can_rename_directory() {
2967        let tmp = temp_dir_with_files();
2968        let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
2969        let idx = explorer
2970            .entries
2971            .iter()
2972            .position(|e| e.name == "subdir" && e.is_dir)
2973            .expect("subdir present");
2974        explorer.cursor = idx;
2975
2976        explorer.handle_key(key(KeyCode::Char('r')));
2977        let prefill_len = explorer.rename_input().len();
2978        for _ in 0..prefill_len {
2979            explorer.handle_key(key(KeyCode::Backspace));
2980        }
2981        for c in "renamed_dir".chars() {
2982            explorer.handle_key(key(KeyCode::Char(c)));
2983        }
2984        let outcome = explorer.handle_key(key(KeyCode::Enter));
2985
2986        assert!(tmp.path().join("renamed_dir").exists());
2987        assert!(!tmp.path().join("subdir").exists());
2988        assert!(matches!(outcome, ExplorerOutcome::RenameCompleted(_)));
2989    }
2990
2991    #[test]
2992    fn rename_mode_unrecognised_key_returns_pending_without_cancelling() {
2993        let tmp = temp_dir_with_files();
2994        let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
2995        explorer.handle_key(key(KeyCode::Char('r')));
2996        assert!(explorer.is_rename_active());
2997
2998        // F1 is not handled inside rename mode.
2999        let outcome = explorer.handle_key(key(KeyCode::F(1)));
3000        assert_eq!(outcome, ExplorerOutcome::Pending);
3001        assert!(explorer.is_rename_active(), "rename mode must stay active");
3002    }
3003
3004    #[test]
3005    fn is_rename_active_false_by_default() {
3006        let tmp = temp_dir_with_files();
3007        let explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
3008        assert!(!explorer.is_rename_active());
3009    }
3010
3011    #[test]
3012    fn rename_input_empty_by_default() {
3013        let tmp = temp_dir_with_files();
3014        let explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
3015        assert_eq!(explorer.rename_input(), "");
3016    }
3017
3018    // ── handle_input_mode! macro tests ───────────────────────────────────────
3019    // These tests exercise the Char-push, Backspace-pop, Esc-cancel, and
3020    // unknown-key fallthrough paths that the macro generates for every mode.
3021
3022    // ── mkdir_mode via macro ──────────────────────────────────────────────────
3023
3024    #[test]
3025    fn mkdir_mode_char_pushes_to_input_via_macro() {
3026        let dir = tempdir().expect("tempdir");
3027        let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
3028        explorer.mkdir_active = true;
3029        explorer.mkdir_input.clear();
3030
3031        let outcome = explorer.handle_key(key(KeyCode::Char('a')));
3032        assert_eq!(outcome, ExplorerOutcome::Pending);
3033        assert_eq!(explorer.mkdir_input, "a");
3034        assert!(explorer.mkdir_active, "mode must remain active after Char");
3035    }
3036
3037    #[test]
3038    fn mkdir_mode_backspace_pops_via_macro() {
3039        let dir = tempdir().expect("tempdir");
3040        let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
3041        explorer.mkdir_active = true;
3042        explorer.mkdir_input = "ab".to_string();
3043
3044        let outcome = explorer.handle_key(key(KeyCode::Backspace));
3045        assert_eq!(outcome, ExplorerOutcome::Pending);
3046        assert_eq!(explorer.mkdir_input, "a");
3047        assert!(explorer.mkdir_active);
3048    }
3049
3050    #[test]
3051    fn mkdir_mode_esc_cancels_via_macro() {
3052        let dir = tempdir().expect("tempdir");
3053        let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
3054        explorer.mkdir_active = true;
3055        explorer.mkdir_input = "half".to_string();
3056
3057        let outcome = explorer.handle_key(key(KeyCode::Esc));
3058        assert_eq!(outcome, ExplorerOutcome::Pending);
3059        assert!(!explorer.mkdir_active, "mode must be deactivated by Esc");
3060        assert!(
3061            explorer.mkdir_input.is_empty(),
3062            "input must be cleared by Esc"
3063        );
3064    }
3065
3066    #[test]
3067    fn mkdir_mode_unknown_key_returns_pending_via_macro() {
3068        let dir = tempdir().expect("tempdir");
3069        let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
3070        explorer.mkdir_active = true;
3071        explorer.mkdir_input = "foo".to_string();
3072
3073        let outcome = explorer.handle_key(key(KeyCode::F(2)));
3074        assert_eq!(outcome, ExplorerOutcome::Pending);
3075        // Mode and input must be unchanged.
3076        assert!(explorer.mkdir_active);
3077        assert_eq!(explorer.mkdir_input, "foo");
3078    }
3079
3080    // ── touch_mode via macro ──────────────────────────────────────────────────
3081
3082    #[test]
3083    fn touch_mode_char_pushes_to_input_via_macro() {
3084        let dir = tempdir().expect("tempdir");
3085        let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
3086        explorer.touch_active = true;
3087        explorer.touch_input.clear();
3088
3089        let outcome = explorer.handle_key(key(KeyCode::Char('z')));
3090        assert_eq!(outcome, ExplorerOutcome::Pending);
3091        assert_eq!(explorer.touch_input, "z");
3092        assert!(explorer.touch_active);
3093    }
3094
3095    #[test]
3096    fn touch_mode_backspace_pops_via_macro() {
3097        let dir = tempdir().expect("tempdir");
3098        let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
3099        explorer.touch_active = true;
3100        explorer.touch_input = "xy".to_string();
3101
3102        let outcome = explorer.handle_key(key(KeyCode::Backspace));
3103        assert_eq!(outcome, ExplorerOutcome::Pending);
3104        assert_eq!(explorer.touch_input, "x");
3105        assert!(explorer.touch_active);
3106    }
3107
3108    #[test]
3109    fn touch_mode_esc_cancels_via_macro() {
3110        let dir = tempdir().expect("tempdir");
3111        let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
3112        explorer.touch_active = true;
3113        explorer.touch_input = "half".to_string();
3114
3115        let outcome = explorer.handle_key(key(KeyCode::Esc));
3116        assert_eq!(outcome, ExplorerOutcome::Pending);
3117        assert!(!explorer.touch_active);
3118        assert!(explorer.touch_input.is_empty());
3119    }
3120
3121    #[test]
3122    fn touch_mode_unknown_key_returns_pending_via_macro() {
3123        let dir = tempdir().expect("tempdir");
3124        let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
3125        explorer.touch_active = true;
3126        explorer.touch_input = "bar".to_string();
3127
3128        let outcome = explorer.handle_key(key(KeyCode::F(3)));
3129        assert_eq!(outcome, ExplorerOutcome::Pending);
3130        assert!(explorer.touch_active);
3131        assert_eq!(explorer.touch_input, "bar");
3132    }
3133
3134    // ── rename_mode via macro ─────────────────────────────────────────────────
3135
3136    #[test]
3137    fn rename_mode_char_pushes_to_input_via_macro() {
3138        let dir = tempdir().expect("tempdir");
3139        let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
3140        explorer.rename_active = true;
3141        explorer.rename_input.clear();
3142
3143        let outcome = explorer.handle_key(key(KeyCode::Char('r')));
3144        // NOTE: 'r' is normally the "activate rename" key, but because
3145        // rename_active is already true the mode interception runs first and
3146        // pushes 'r' to the input — it never reaches the normal key dispatch.
3147        assert_eq!(outcome, ExplorerOutcome::Pending);
3148        assert_eq!(explorer.rename_input, "r");
3149        assert!(explorer.rename_active);
3150    }
3151
3152    #[test]
3153    fn rename_mode_backspace_pops_via_macro() {
3154        let dir = tempdir().expect("tempdir");
3155        let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
3156        explorer.rename_active = true;
3157        explorer.rename_input = "cd".to_string();
3158
3159        let outcome = explorer.handle_key(key(KeyCode::Backspace));
3160        assert_eq!(outcome, ExplorerOutcome::Pending);
3161        assert_eq!(explorer.rename_input, "c");
3162        assert!(explorer.rename_active);
3163    }
3164
3165    #[test]
3166    fn rename_mode_esc_cancels_via_macro() {
3167        let dir = tempdir().expect("tempdir");
3168        let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
3169        explorer.rename_active = true;
3170        explorer.rename_input = "draft".to_string();
3171
3172        let outcome = explorer.handle_key(key(KeyCode::Esc));
3173        assert_eq!(outcome, ExplorerOutcome::Pending);
3174        assert!(!explorer.rename_active);
3175        assert!(explorer.rename_input.is_empty());
3176    }
3177
3178    #[test]
3179    fn rename_mode_unknown_key_returns_pending_via_macro() {
3180        let dir = tempdir().expect("tempdir");
3181        let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
3182        explorer.rename_active = true;
3183        explorer.rename_input = "baz".to_string();
3184
3185        let outcome = explorer.handle_key(key(KeyCode::F(4)));
3186        assert_eq!(outcome, ExplorerOutcome::Pending);
3187        assert!(explorer.rename_active);
3188        assert_eq!(explorer.rename_input, "baz");
3189    }
3190}