Skip to main content

tui_file_explorer/explorer/
mod.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//! ```
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    fmt, io,
35    path::{Path, PathBuf},
36};
37
38use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
39
40use crate::keymap::Keymap;
41use crate::types::{
42    DiskUsage, ExplorerCommand, ExplorerOutcome, FsEntry, Prompt, PromptKind, SelectionMode,
43    SortMode,
44};
45
46/// Default number of entries scrolled by Page Up / Page Down.
47pub const PAGE_SIZE: usize = 10;
48
49/// Error returned by checked explorer filesystem operations.
50#[derive(Debug)]
51pub struct ExplorerError {
52    /// The path the operation failed on.
53    pub path: PathBuf,
54    /// The underlying I/O error.
55    pub source: io::Error,
56}
57
58impl fmt::Display for ExplorerError {
59    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
60        write!(f, "{}: {}", self.path.display(), self.source)
61    }
62}
63
64impl std::error::Error for ExplorerError {
65    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
66        Some(&self.source)
67    }
68}
69
70// ── FileExplorer ──────────────────────────────────────────────────────────────
71
72/// State for the file-explorer widget.
73///
74/// Keep one instance in your application state and pass a mutable reference
75/// to [`crate::render()`] and [`FileExplorer::handle_key`] on every frame /
76/// key event.
77///
78/// # Example
79///
80/// ```
81/// use tui_file_explorer::{FileExplorer, ExplorerOutcome};
82/// use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
83///
84/// let mut explorer = FileExplorer::new(
85///     std::env::current_dir().unwrap(),
86///     vec!["iso".into(), "img".into()],
87/// );
88///
89/// # let key = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
90/// match explorer.handle_key(key) {
91///     ExplorerOutcome::Selected(path) => println!("chosen: {}", path.display()),
92///     ExplorerOutcome::Dismissed      => println!("closed"),
93///     _                               => {}
94/// }
95/// ```
96#[derive(Debug)]
97pub struct FileExplorer {
98    /// The directory currently being browsed.
99    pub current_dir: PathBuf,
100    /// Backend used for directory and mutation operations.
101    pub(crate) filesystem: crate::SharedFileSystem,
102    /// The name of the currently active theme (used in the header display).
103    pub theme_name: String,
104    /// The label of the currently configured editor (used in the header display).
105    pub editor_name: String,
106    /// Sorted, search-filtered list of visible entries (dirs first, then files).
107    pub entries: Vec<FsEntry>,
108    /// Index of the highlighted entry.
109    pub cursor: usize,
110    /// Index of the first visible entry (for scrolling).
111    pub(crate) scroll_offset: usize,
112    /// Only files whose extension is in this list are selectable.
113    /// Directories are always shown and always navigable.
114    /// An empty `Vec` means *all* files are selectable.
115    pub extension_filter: Vec<String>,
116    /// Whether to show dotfiles / hidden entries.
117    pub show_hidden: bool,
118    /// Human-readable status message (shown in the footer).
119    pub(crate) status: String,
120    /// Current sort order for directory entries.
121    pub sort_mode: SortMode,
122    /// Number of entries scrolled by Page Up / Page Down (default: 10).
123    pub page_size: usize,
124    /// Which entry kinds can be returned as selections.
125    pub selection_mode: SelectionMode,
126    /// Current incremental-search query (empty = no search active).
127    pub search_query: String,
128    /// Whether the explorer is currently capturing keystrokes for search input.
129    pub search_active: bool,
130    /// Paths that have been space-marked for a multi-item operation.
131    pub marked: HashSet<PathBuf>,
132    /// The open rename / new-file / new-folder prompt, if any. While `Some`,
133    /// the prompt owns the keyboard.
134    pub prompt: Option<Prompt>,
135    /// Key bindings used by [`handle_key`](Self::handle_key) and
136    /// [`handle_command`](Self::handle_command).
137    pub keymap: Keymap,
138    /// Cache of computed recursive directory sizes, keyed by absolute path.
139    ///
140    /// Each entry stores `(total_bytes, is_partial, item_count_at_computation)`.
141    /// A cached value is reused as long as the directory's shallow
142    /// [`FsEntry::item_count`] hasn't changed since it was computed — a cheap
143    /// heuristic that invalidates the cache when files are added, removed, or
144    /// renamed directly inside that directory, without requiring a full
145    /// recursive re-walk on every render or keystroke. See
146    /// [`Self::clear_dir_size_cache`].
147    /// Lazily resolved symlink targets for visible rows (`None` = not a link).
148    /// Cleared on every reload.
149    /// Read directories on a worker thread instead of blocking the caller.
150    ///
151    /// Off by default (so `reload` is deterministic for embedders and tests);
152    /// the `tfe` binary turns it on. A read that finishes within a few
153    /// milliseconds is applied synchronously; slower ones show
154    /// [`is_loading`](Self::is_loading) until they complete.
155    pub async_loading: bool,
156    /// Off-thread work in flight (folder sizes, `git status`).
157    pub(crate) bg: background::Background,
158    /// Whether the git change column is shown (see [`Self::set_show_git`]).
159    pub show_git: bool,
160    /// Latest `git status` for the current directory, when enabled.
161    pub(crate) git: Option<crate::git::GitStatus>,
162    pub(crate) link_cache: std::collections::HashMap<PathBuf, Option<PathBuf>>,
163    pub(crate) dir_size_cache: std::collections::HashMap<PathBuf, (u64, bool, usize)>,
164    /// Total/free space for the storage device backing `current_dir`.
165    ///
166    /// Refreshed on every [`Self::reload`] (i.e. on every navigation), since
167    /// crossing a mount point changes which device applies. `None` when the
168    /// underlying OS query fails or isn't supported on this platform.
169    pub disk_usage: Option<DiskUsage>,
170    /// Whether file/folder sizes are shown in the entry list.
171    ///
172    /// When `false`, the (potentially expensive) recursive directory-size
173    /// walk (`ensure_dir_size_before`) is skipped entirely and only the
174    /// cheap shallow item count is shown for directories — useful for
175    /// snappier browsing of huge trees (network mounts, `node_modules`,
176    /// build output, ...). Toggle with `z`. Defaults to `true`.
177    pub show_sizes: bool,
178    /// Whether directories show their *recursive* byte size.
179    ///
180    /// Only meaningful while [`show_sizes`](Self::show_sizes) is `true`. When
181    /// `false`, files still show their size but directories show only the
182    /// cheap shallow item count and the recursive walk never runs. Toggle
183    /// with `Shift+Z`. Defaults to `true`.
184    pub show_dir_sizes: bool,
185}
186
187mod background;
188mod builder;
189mod entries;
190mod keys;
191
192pub use self::builder::FileExplorerBuilder;
193#[cfg(test)]
194use self::entries::load_entries;
195use self::entries::try_load_entries;
196pub use self::entries::{entry_icon, fmt_size};
197
198impl FileExplorer {
199    // ── Construction ─────────────────────────────────────────────────────────
200
201    /// Create a new explorer starting at `initial_dir`.
202    ///
203    /// `extension_filter` is a list of lower-case extensions *without* the
204    /// leading dot (e.g. `vec!["iso".into(), "img".into()]`).
205    /// Pass an empty `Vec` to allow all files.
206    ///
207    /// For more configuration options use [`FileExplorer::builder`] instead.
208    #[must_use]
209    pub fn new(initial_dir: PathBuf, extension_filter: Vec<String>) -> Self {
210        Self::builder(initial_dir)
211            .extension_filter(extension_filter)
212            .build()
213    }
214
215    /// Return a [`FileExplorerBuilder`] for constructing an explorer with
216    /// fine-grained configuration.
217    ///
218    /// # Example
219    ///
220    /// ```
221    /// use tui_file_explorer::{FileExplorer, SortMode};
222    ///
223    /// let explorer = FileExplorer::builder(std::env::current_dir().unwrap())
224    ///     .extension_filter(vec!["rs".into(), "toml".into()])
225    ///     .show_hidden(true)
226    ///     .sort_mode(SortMode::SizeDesc)
227    ///     .build();
228    /// ```
229    #[must_use]
230    pub fn builder(initial_dir: PathBuf) -> FileExplorerBuilder {
231        FileExplorerBuilder::new(initial_dir)
232    }
233
234    /// Return the configured filesystem backend.
235    #[must_use]
236    pub fn filesystem(&self) -> &crate::SharedFileSystem {
237        &self.filesystem
238    }
239
240    /// Navigate to `path`, resetting cursor, scroll, and any active search.
241    ///
242    /// Accepts anything that converts into a [`PathBuf`] — a [`PathBuf`],
243    /// `&Path`, `&str`, or `String` all work.
244    ///
245    /// ```
246    /// use tui_file_explorer::FileExplorer;
247    ///
248    /// let mut explorer = FileExplorer::new(std::env::current_dir().unwrap(), vec![]);
249    /// explorer.navigate_to("/tmp");
250    /// explorer.navigate_to(std::path::Path::new("/home"));
251    /// ```
252    pub fn navigate_to(&mut self, path: impl Into<PathBuf>) {
253        if let Err(error) = self.try_navigate_to(path) {
254            self.entries.clear();
255            self.disk_usage = None;
256            self.status = error.to_string();
257            self.clamp_cursor();
258        }
259    }
260
261    /// Navigate to `path` while preserving filesystem errors for the host.
262    pub fn try_navigate_to(&mut self, path: impl Into<PathBuf>) -> Result<(), ExplorerError> {
263        let previous_dir = std::mem::replace(&mut self.current_dir, path.into());
264        let previous_cursor = self.cursor;
265        let previous_scroll = self.scroll_offset;
266        let previous_search_active = self.search_active;
267        let previous_search_query = std::mem::take(&mut self.search_query);
268        self.cursor = 0;
269        self.scroll_offset = 0;
270        self.search_active = false;
271        if let Err(error) = self.try_reload() {
272            self.current_dir = previous_dir;
273            self.cursor = previous_cursor;
274            self.scroll_offset = previous_scroll;
275            self.search_active = previous_search_active;
276            self.search_query = previous_search_query;
277            return Err(error);
278        }
279        Ok(())
280    }
281
282    // ── Key handling ─────────────────────────────────────────────────────────
283
284    /// Process a single keyboard event and return the [`ExplorerOutcome`].
285    ///
286    /// Call this from your application's key-handling function and act on
287    /// [`ExplorerOutcome::Selected`] / [`ExplorerOutcome::Dismissed`].
288    /// Return the set of currently marked paths (for multi-item operations).
289    #[must_use]
290    pub fn marked_paths(&self) -> &HashSet<PathBuf> {
291        &self.marked
292    }
293
294    /// Toggle the space-mark on the currently highlighted entry and move
295    /// the cursor down by one.
296    pub fn toggle_mark(&mut self) {
297        if let Some(entry) = self.entries.get(self.cursor) {
298            let path = entry.path.clone();
299            if self.marked.contains(&path) {
300                self.marked.remove(&path);
301            } else {
302                self.marked.insert(path);
303            }
304        }
305        self.move_down();
306    }
307
308    /// Clear all space-marks (called after a multi-delete or on navigation).
309    pub fn clear_marks(&mut self) {
310        self.marked.clear();
311    }
312
313    // ── Queries ───────────────────────────────────────────────────────────────
314
315    /// The currently highlighted [`FsEntry`], or `None` if the list is empty.
316    #[must_use]
317    pub fn current_entry(&self) -> Option<&FsEntry> {
318        self.entries.get(self.cursor)
319    }
320
321    /// The text typed into the open prompt of `kind` (empty when another
322    /// prompt, or none, is open).
323    fn prompt_text(&self, kind: PromptKind) -> &str {
324        match &self.prompt {
325            Some(p) if p.kind == kind => &p.text,
326            _ => "",
327        }
328    }
329
330    /// Whether a prompt of `kind` is open.
331    #[must_use]
332    pub fn is_prompt_active(&self, kind: PromptKind) -> bool {
333        self.prompt.as_ref().is_some_and(|p| p.kind == kind)
334    }
335
336    /// Whether any text prompt (rename / new file / new folder) is open.
337    #[must_use]
338    pub fn has_prompt(&self) -> bool {
339        self.prompt.is_some()
340    }
341
342    /// Open a prompt of `kind` pre-filled with `text`.
343    pub fn open_prompt(&mut self, kind: PromptKind, text: impl Into<String>) {
344        self.prompt = Some(Prompt {
345            kind,
346            text: text.into(),
347        });
348    }
349
350    /// Whether the explorer is in mkdir (new-folder input) mode.
351    #[must_use]
352    pub fn is_mkdir_active(&self) -> bool {
353        self.is_prompt_active(PromptKind::Mkdir)
354    }
355
356    /// The folder name being typed when mkdir mode is active.
357    #[must_use]
358    pub fn mkdir_input(&self) -> &str {
359        self.prompt_text(PromptKind::Mkdir)
360    }
361
362    /// Whether the explorer is in touch (new-file input) mode.
363    #[must_use]
364    pub fn is_touch_active(&self) -> bool {
365        self.is_prompt_active(PromptKind::Touch)
366    }
367
368    /// The file name being typed when touch mode is active.
369    #[must_use]
370    pub fn touch_input(&self) -> &str {
371        self.prompt_text(PromptKind::Touch)
372    }
373
374    /// Whether the explorer is in rename (entry-rename input) mode.
375    #[must_use]
376    pub fn is_rename_active(&self) -> bool {
377        self.is_prompt_active(PromptKind::Rename)
378    }
379
380    /// The new name being typed when rename mode is active.
381    #[must_use]
382    pub fn rename_input(&self) -> &str {
383        self.prompt_text(PromptKind::Rename)
384    }
385
386    // ── Inspectors ────────────────────────────────────────────────────────────
387
388    /// Returns `true` when the explorer is at the filesystem root and cannot
389    /// ascend any further.
390    ///
391    /// ```
392    /// use tui_file_explorer::FileExplorer;
393    ///
394    /// let mut explorer = FileExplorer::new(std::path::PathBuf::from("/"), vec![]);
395    /// assert!(explorer.is_at_root());
396    /// ```
397    #[must_use]
398    pub fn is_at_root(&self) -> bool {
399        self.current_dir.parent().is_none()
400    }
401
402    /// Returns `true` when the current directory contains no visible entries.
403    ///
404    /// This reflects the *filtered, visible* set — hidden files are excluded
405    /// unless `show_hidden` is `true`, and an active search query narrows
406    /// the set further.
407    #[must_use]
408    pub fn is_empty(&self) -> bool {
409        self.entries.is_empty()
410    }
411
412    /// The number of visible entries in the current directory.
413    ///
414    /// Equivalent to `explorer.entries.len()` but reads more naturally in
415    /// condition checks.
416    #[must_use]
417    pub fn entry_count(&self) -> usize {
418        self.entries.len()
419    }
420
421    /// Replace visible entries supplied by a host-owned asynchronous loader.
422    ///
423    /// Entries are used as provided; hosts can sort/filter before applying.
424    pub fn replace_entries(&mut self, entries: Vec<FsEntry>) {
425        self.entries = entries;
426        self.clamp_cursor();
427    }
428
429    /// Set a host-provided status message.
430    pub fn set_status(&mut self, status: impl Into<String>) {
431        self.status = status.into();
432    }
433
434    /// The current human-readable status message.
435    ///
436    /// The status is set by the widget when an error occurs (e.g. attempting
437    /// to select a file that does not match the extension filter) and is
438    /// cleared on the next successful navigation.  Returns an empty string
439    /// when there is nothing to report.
440    #[must_use]
441    pub fn status(&self) -> &str {
442        &self.status
443    }
444
445    /// The current sort mode.
446    ///
447    /// ```
448    /// use tui_file_explorer::{FileExplorer, SortMode};
449    ///
450    /// let explorer = FileExplorer::new(std::path::PathBuf::from("/tmp"), vec![]);
451    /// assert_eq!(explorer.sort_mode(), SortMode::Name);
452    /// ```
453    #[must_use]
454    pub fn sort_mode(&self) -> SortMode {
455        self.sort_mode
456    }
457
458    /// The current incremental-search query string.
459    ///
460    /// Returns an empty string when no search is active.
461    #[must_use]
462    pub fn search_query(&self) -> &str {
463        &self.search_query
464    }
465
466    /// Returns `true` when the explorer is actively capturing keystrokes for
467    /// incremental search input.
468    #[must_use]
469    pub fn is_searching(&self) -> bool {
470        self.search_active
471    }
472
473    // ── Mutating setters ──────────────────────────────────────────────────────
474
475    /// Set whether hidden (dot-file) entries are visible and reload the
476    /// directory listing immediately.
477    ///
478    /// The user can also toggle this at runtime with the `.` key.
479    ///
480    /// ```
481    /// use tui_file_explorer::FileExplorer;
482    ///
483    /// let mut explorer = FileExplorer::new(std::env::current_dir().unwrap(), vec![]);
484    /// explorer.set_show_hidden(true);
485    /// assert!(explorer.show_hidden);
486    /// ```
487    pub fn set_show_hidden(&mut self, show: bool) {
488        self.show_hidden = show;
489        self.reload();
490    }
491
492    /// Replace the extension filter and reload the directory listing
493    /// immediately.
494    ///
495    /// Accepts any iterable of values that convert to [`String`] — plain
496    /// `&str` slices, `String` values, and arrays all work:
497    ///
498    /// ```
499    /// use tui_file_explorer::FileExplorer;
500    ///
501    /// let mut explorer = FileExplorer::new(std::env::current_dir().unwrap(), vec![]);
502    ///
503    /// // Array of &str — no .into() needed
504    /// explorer.set_extension_filter(["rs", "toml"]);
505    ///
506    /// // Vec<String>
507    /// explorer.set_extension_filter(vec!["iso".to_string(), "img".to_string()]);
508    ///
509    /// // Empty — allow all files
510    /// explorer.set_extension_filter([] as [&str; 0]);
511    /// ```
512    pub fn set_extension_filter<I, S>(&mut self, filter: I)
513    where
514        I: IntoIterator<Item = S>,
515        S: Into<String>,
516    {
517        self.extension_filter = filter.into_iter().map(Into::into).collect();
518        self.reload();
519    }
520
521    /// Change the sort mode and reload the directory listing immediately.
522    ///
523    /// The user can also cycle through modes at runtime with the `s` key.
524    ///
525    /// ```
526    /// use tui_file_explorer::{FileExplorer, SortMode};
527    ///
528    /// let mut explorer = FileExplorer::new(std::env::current_dir().unwrap(), vec![]);
529    /// explorer.set_sort_mode(SortMode::SizeDesc);
530    /// assert_eq!(explorer.sort_mode(), SortMode::SizeDesc);
531    /// ```
532    pub fn set_sort_mode(&mut self, mode: SortMode) {
533        self.sort_mode = mode;
534        self.reload();
535    }
536
537    // ── Directory loading ─────────────────────────────────────────────────────
538
539    /// Re-read the current directory from the filesystem.
540    ///
541    /// Called automatically after every navigation action or configuration
542    /// change.  Callers can invoke it manually after external filesystem
543    /// mutations (e.g. a file was created or deleted in the watched directory).
544    ///
545    /// With [`async_loading`](Self::async_loading) enabled a slow read does
546    /// not block: the list shows "loading" and fills in on a later
547    /// [`poll_background`](Self::poll_background).
548    pub fn reload(&mut self) {
549        let cursor = self.cursor;
550        self.reload_with(background::AfterLoad {
551            select: None,
552            cursor: Some(cursor),
553        });
554    }
555
556    /// Reload directory contents and preserve filesystem failures for hosts.
557    ///
558    /// Always synchronous (it must return the error), and it supersedes any
559    /// background read in flight.
560    pub fn try_reload(&mut self) -> Result<(), ExplorerError> {
561        self.bg.cancel_load();
562        self.status.clear();
563        self.entries = try_load_entries(
564            self.filesystem.as_ref(),
565            &self.current_dir,
566            self.show_hidden,
567            &self.extension_filter,
568            self.sort_mode,
569            &self.search_query,
570        )
571        .map_err(|source| ExplorerError {
572            path: self.current_dir.clone(),
573            source,
574        })?;
575        self.disk_usage = self.filesystem.disk_usage(&self.current_dir);
576        self.link_cache.clear();
577        self.bg.clear_counted();
578        self.refresh_git();
579        // After every reload the entry count may have shrunk (filter change,
580        // external deletion, empty directory).  Clamp so cursor and
581        // scroll_offset never point past the end of the new list.
582        self.clamp_cursor();
583        Ok(())
584    }
585
586    // ── Directory size cache ─────────────────────────────────────────────
587
588    /// Clear every cached recursive directory size.
589    ///
590    /// Call this after operations that can change file sizes *without*
591    /// necessarily changing any visible directory's immediate entry count
592    /// (the heuristic `ensure_dir_size_before` relies on) — e.g. a
593    /// multi-file paste or delete that touches nested subdirectories.
594    /// The symlink target of `path`, if it is a symlink (cached until reload).
595    pub(crate) fn link_target(&mut self, path: &Path) -> Option<PathBuf> {
596        if !self.filesystem.is_local() {
597            return None;
598        }
599        self.link_cache
600            .entry(path.to_path_buf())
601            .or_insert_with(|| std::fs::read_link(path).ok())
602            .clone()
603    }
604
605    /// Forget cached directory sizes so they are measured again.
606    pub fn clear_dir_size_cache(&mut self) {
607        self.dir_size_cache.clear();
608        self.bg.clear_dir_sizes();
609    }
610}
611
612#[cfg(test)]
613#[path = "tests.rs"]
614mod tests;