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;