Skip to main content

ratatui_explorer/
file_explorer.rs

1use std::{
2    io::Result,
3    path::{Path, PathBuf},
4    sync::Arc,
5};
6
7use ratatui::widgets::WidgetRef;
8
9use crate::{Theme, input::Input, widget::Renderer};
10
11mod builder;
12mod file;
13
14pub use builder::FileExplorerBuilder;
15pub use file::File;
16
17type Filter = dyn Fn(File) -> Option<File> + Send + Sync + 'static;
18
19/// A file explorer that allows browsing and selecting files and directories.
20///
21/// The `FileExplorer` struct represents a file explorer widget that can be used to navigate
22/// through the file system.
23/// You can obtain a renderable widget from it with the [`widget`](FileExplorer::widget) method.
24/// It provides methods for handling user input from [crossterm](https://crates.io/crates/crossterm),
25/// [termion](https://crates.io/crates/termion) and [termwiz](https://crates.io/crates/termwiz) or your own backend (depending on what feature is enabled).
26///
27/// # Examples
28///
29/// Creating a new `FileExplorer` widget:
30///
31/// ```no_run
32/// # use ratatui_explorer::FileExplorer;
33/// let file_explorer = FileExplorer::new().unwrap();
34/// let widget = file_explorer.widget();
35/// ```
36///
37/// Handling user input:
38///
39/// ```no_run
40/// # fn get_event() -> ratatui_explorer::Input {
41/// #   unimplemented!()
42/// # }
43/// # use ratatui_explorer::FileExplorer;
44/// let mut file_explorer = FileExplorer::new().unwrap();
45/// let event = get_event(); // Get the event from the terminal (with crossterm, termion or termwiz)
46/// file_explorer.handle(event).unwrap();
47/// ```
48///
49/// Accessing information about the current file selected and/or the current working directory:
50///
51/// ```no_run
52/// # use ratatui_explorer::FileExplorer;
53/// let file_explorer = FileExplorer::new().unwrap();
54///
55/// let current_file = file_explorer.current();
56/// let current_working_directory = file_explorer.cwd();
57/// println!("Current Directory: {}", current_working_directory.display());
58/// println!("Name: {}", current_file.name);
59/// ```
60#[derive(Clone, educe::Educe)]
61#[educe(Debug, PartialEq, Eq, Hash)]
62pub struct FileExplorer {
63    cwd: PathBuf,
64    files: Vec<File>,
65    show_hidden: bool,
66    selected: usize,
67    theme: Theme,
68    #[educe(Debug(ignore), PartialEq(ignore), Hash(ignore))]
69    filter: Option<Arc<Filter>>,
70}
71
72impl FileExplorer {
73    /// Creates a new instance of `FileExplorer`.
74    ///
75    /// This method initializes a `FileExplorer` with the current working directory.
76    /// By default, hidden files are not shown.
77    ///
78    /// You can use the [`FileExplorerBuilder`](FileExplorerBuilder) to create a `FileExplorer` with a custom working
79    /// directory, theme, and other options. See its documentation for more information.
80    ///
81    /// # Errors
82    ///
83    /// Will return `Err` if the current working directory can not be listed.
84    /// See [`current_dir`](https://doc.rust-lang.org/stable/std/env/fn.current_dir.html) for more information.
85    ///
86    /// # Examples
87    /// Suppose you have this tree file and your current working directory is `/Documents`:
88    /// ```plaintext
89    /// /
90    /// ├── .git
91    /// └── Documents  <- current working directory
92    ///     ├── passport.png
93    ///     └── resume.pdf
94    /// ```
95    /// You can create a new `FileExplorer` like this:
96    /// ```no_run
97    /// # use ratatui_explorer::FileExplorer;
98    /// let file_explorer = FileExplorer::new().unwrap();
99    /// assert_eq!(file_explorer.cwd().display().to_string(), "/Documents");
100    /// ```
101    pub fn new() -> Result<FileExplorer> {
102        let cwd = std::env::current_dir()?;
103        let files = Self::get_files(&cwd, false, None)?;
104        let file_explorer = Self {
105            cwd,
106            files,
107            show_hidden: false,
108            selected: 0,
109            theme: Theme::new(),
110            filter: None,
111        };
112
113        Ok(file_explorer)
114    }
115
116    /// Build a ratatui widget to render the file explorer. The widget can then
117    /// be rendered with [`Frame::render_widget`](https://docs.rs/ratatui/latest/ratatui/struct.Frame.html#method.render_widget)
118    /// or [`FrameExt::render_widget_ref`](https://docs.rs/ratatui/latest/ratatui/widgets/trait.FrameExt.html#tymethod.render_widget_ref).
119    ///
120    /// # Examples
121    ///
122    /// ```no_run
123    /// use ratatui::{Terminal, backend::CrosstermBackend, widgets::FrameExt as _};
124    /// use ratatui_explorer::FileExplorer;
125    ///
126    /// let mut file_explorer = FileExplorer::new().unwrap();
127    ///
128    /// let mut terminal = Terminal::new(CrosstermBackend::new(std::io::stdout())).unwrap();
129    ///
130    /// loop {
131    ///     terminal.draw(|f| {
132    ///         let widget = file_explorer.widget(); // Get the widget to render the file explorer
133    ///         f.render_widget_ref(widget, f.area());
134    ///     }).unwrap();
135    ///
136    ///     // ...
137    /// }
138    /// ```
139    #[inline]
140    #[must_use]
141    pub const fn widget(&self) -> impl WidgetRef + '_ {
142        Renderer(self)
143    }
144
145    /// Handles input from user and updates the state of the file explorer.
146    /// The different inputs are interpreted as follows:
147    /// - `Up`: Move the selection up.
148    /// - `Down`: Move the selection down.
149    /// - `Left`: Move to the parent directory.
150    /// - `Right`: Move to the selected directory.
151    /// - `Home`: Select the first entry.
152    /// - `End`: Select the last entry.
153    /// - `PageUp`: Scroll the selection up.
154    /// - `PageDown`: Scroll the selection down.
155    /// - `ToggleShowHidden`: Toggle between showing hidden files or not.
156    /// - `None`: Do nothing.
157    ///
158    /// [`Input`](crate::input::Input) implement [`From<Event>`](https://doc.rust-lang.org/stable/std/convert/trait.From.html)
159    /// for `Event` from [crossterm](https://docs.rs/crossterm/latest/crossterm/event/enum.Event.html),
160    /// [termion](https://docs.rs/termion/latest/termion/event/enum.Event.html)
161    /// and [termwiz](https://docs.rs/termwiz/latest/termwiz/input/enum.InputEvent.html) (`InputEvent` in the latter).
162    /// Here, the [default bindings](https://docs.rs/ratatui-explorer/latest/ratatui_explorer/#bindings).
163    ///
164    /// # Errors
165    ///
166    /// Will return `Err` if the new current working directory can not be listed.
167    ///
168    /// # Examples
169    ///
170    /// Suppose you have this tree file, with `passport.png` selected inside `file_explorer`:
171    /// ```plaintext
172    /// /
173    /// ├── .git
174    /// └── Documents
175    ///     ├── passport.png  <- selected
176    ///     └── resume.pdf
177    /// ```
178    /// You can handle input like this:
179    /// ```no_run
180    /// # use ratatui_explorer::{FileExplorer, Input};
181    /// let mut file_explorer = FileExplorer::new().unwrap();
182    /// file_explorer.set_show_hidden(true);
183    ///
184    /// /* user select `password.png` */
185    ///
186    /// file_explorer.handle(Input::Down).unwrap();
187    /// assert_eq!(file_explorer.current().name, "resume.pdf");
188    ///
189    /// file_explorer.handle(Input::Up).unwrap();
190    /// file_explorer.handle(Input::Up).unwrap();
191    /// assert_eq!(file_explorer.current().name, "../");
192    ///
193    /// file_explorer.handle(Input::Left).unwrap();
194    /// assert_eq!(file_explorer.cwd().display().to_string(), "/");
195    ///
196    /// file_explorer.handle(Input::Right).unwrap();
197    /// assert_eq!(file_explorer.cwd().display().to_string(), "/.git");
198    /// ```
199    pub fn handle<I: Into<Input>>(&mut self, input: I) -> Result<()> {
200        const SCROLL_COUNT: usize = 12;
201
202        let input = input.into();
203
204        match input {
205            Input::Up => {
206                self.selected = self.selected.wrapping_sub(1).min(self.files.len() - 1);
207            }
208            Input::Down => {
209                self.selected = (self.selected + 1) % self.files.len();
210            }
211            Input::Home => {
212                self.selected = 0;
213            }
214            Input::End => {
215                self.selected = self.files.len() - 1;
216            }
217            Input::PageUp => {
218                self.selected = self.selected.saturating_sub(SCROLL_COUNT);
219            }
220            Input::PageDown => {
221                self.selected = (self.selected + SCROLL_COUNT).min(self.files.len() - 1);
222            }
223            Input::Left => {
224                let parent = self.cwd.parent();
225
226                if let Some(parent) = parent {
227                    let path = parent.to_path_buf();
228                    self.set_cwd(path)?;
229                }
230            }
231            Input::Right => {
232                if self.files[self.selected].path.is_dir() {
233                    let path = self.files.swap_remove(self.selected).path;
234                    self.set_cwd(path)?;
235                }
236            }
237            Input::ToggleShowHidden => self.set_show_hidden(!self.show_hidden)?,
238            Input::None => (),
239        }
240
241        Ok(())
242    }
243
244    /// Sets the current working directory of the file explorer.
245    ///
246    /// # Errors
247    ///
248    /// Will return `Err` if the directory `cwd` can not be listed.
249    ///
250    /// # Examples
251    ///
252    /// ```no_run
253    /// # use ratatui_explorer::FileExplorer;
254    /// let mut file_explorer = FileExplorer::new().unwrap();
255    ///
256    /// file_explorer.set_cwd("/Documents").unwrap();
257    /// assert_eq!(file_explorer.cwd().display().to_string(), "/Documents");
258    /// ```
259    #[inline]
260    pub fn set_cwd<P: Into<PathBuf>>(&mut self, cwd: P) -> Result<()> {
261        let cwd = cwd.into();
262        self.files = Self::get_files(&cwd, self.show_hidden, self.filter.as_ref())?;
263
264        self.cwd = cwd;
265        self.selected = 0;
266
267        Ok(())
268    }
269
270    /// Same as [`set_cwd`](FileExplorer::set_cwd) but will pre-select the file in the working directory.
271    ///
272    /// This method set the working directory to the parent directory of the provided file and select the file in the file explorer.
273    /// You can also select a directory (eg. select `/Documents` inside `/`).
274    ///
275    /// # Examples
276    /// Suppose you have this tree file:
277    /// ```plaintext
278    /// /
279    /// ├── .git
280    /// └── Documents
281    ///     ├── passport.png
282    ///     └── resume.pdf
283    /// ```
284    /// You can create a new `FileExplorer` selecting `passport.png` like this:
285    /// ```no_run
286    /// # use ratatui_explorer::FileExplorer;
287    /// let mut file_explorer = FileExplorer::new().unwrap();
288    /// file_explorer.set_working_file("/Documents/passport.png").unwrap();
289    ///
290    /// assert_eq!(file_explorer.cwd().display().to_string(), "/Documents");
291    /// assert_eq!(file_explorer.current().path.display().to_string(), "/Documents/passport.png");
292    /// ```
293    #[inline]
294    pub fn set_working_file<P: Into<PathBuf>>(&mut self, working_file: P) -> Result<()> {
295        let working_file = working_file.into();
296
297        let cwd = working_file
298            .parent()
299            .map(|p| p.to_owned())
300            .unwrap_or_else(|| working_file.clone());
301
302        self.files = Self::get_files(&cwd, self.show_hidden, self.filter.as_ref())?;
303
304        let selected_path = working_file;
305        let selected = self
306            .files
307            .iter()
308            .position(|file| file.path == selected_path)
309            .unwrap_or_default();
310
311        self.cwd = cwd;
312        self.selected = selected;
313
314        Ok(())
315    }
316
317    /// Sets whether hidden files should be shown in the file explorer.
318    ///
319    /// # Errors
320    ///
321    /// Will return `Err` if the current working directory can not be listed.
322    ///
323    /// # Examples
324    ///
325    /// Suppose you have this tree file:
326    /// ```plaintext
327    /// /
328    /// ├── .git
329    /// └── Documents
330    ///     ├── passport.png
331    ///     └── resume.pdf
332    /// ```
333    /// ```no_run
334    /// # use ratatui_explorer::FileExplorerBuilder;
335    /// let mut file_explorer = FileExplorerBuilder::build_with_working_dir("/").unwrap();
336    /// assert_eq!(file_explorer.files().len(), 1); // Only /Documents is shown
337    ///
338    /// file_explorer.set_show_hidden(true).unwrap();
339    /// assert_eq!(file_explorer.files().len(), 2); // /Documents and /.git are shown
340    /// ```
341    #[inline]
342    pub fn set_show_hidden(&mut self, show_hidden: bool) -> Result<()> {
343        self.show_hidden = show_hidden;
344        self.files = Self::get_files(&self.cwd, show_hidden, self.filter.as_ref())?;
345        self.selected = 0;
346
347        Ok(())
348    }
349
350    /// Filters and maps the files in the `FileExplorer`.
351    ///
352    /// If not set, all files are shown. Hidden files are filtered **before** this
353    /// filter will be apply.
354    ///
355    /// To remove the filter, use [`remove_filter_map`](FileExplorer::remove_filter_map).
356    ///
357    /// # Errors
358    ///
359    /// Will return `Err` if the current working directory can not be listed.
360    ///
361    ///  # Examples:
362    ///
363    /// ```no_run
364    /// # use ratatui_explorer::FileExplorer;
365    /// const SUPPORTED_FORMATS: [&str; 2] = ["wav", "mp3"];
366    ///
367    /// // A file explorer for browsing my favorite musics
368    /// let mut music_file_explorer = FileExplorer::new().unwrap();
369    /// music_file_explorer.set_filter_map(|file| {
370    ///     let keep = match file.path.extension() {
371    ///         Some(extension) => {
372    ///             let extension = extension.to_str().unwrap_or_default();
373    ///             SUPPORTED_FORMATS.contains(&extension)
374    ///         }
375    ///         None => file.is_dir,
376    ///     };
377    ///
378    ///     if keep { Some(file) } else { None }
379    /// }).unwrap();
380    ///
381    /// // My old terminal only display ASCII :(
382    /// let mut ascii_file_explorer = FileExplorer::new().unwrap();
383    /// ascii_file_explorer.set_filter_map(|mut file| {
384    ///     file.name = file.name.chars()
385    ///         .map(|c| if c.is_ascii() { c } else { '_' })
386    ///         .collect();
387    ///
388    ///     Some(file)
389    /// }).unwrap();
390    /// ```
391    pub fn set_filter_map(
392        &mut self,
393        f: impl Fn(File) -> Option<File> + Send + Sync + 'static,
394    ) -> Result<()> {
395        self.filter = Some(Arc::new(f));
396        self.files = Self::get_files(&self.cwd, self.show_hidden, self.filter.as_ref())?;
397        self.selected = 0;
398
399        Ok(())
400    }
401
402    /// Removes the current filter and returns it if it exists.
403    ///
404    /// # Errors
405    ///
406    /// Will return `Err` if the current working directory can not be listed.
407    ///
408    /// # Examples
409    /// ```no_run
410    /// # use ratatui_explorer::FileExplorer;
411    /// let mut file_explorer = FileExplorer::new().unwrap();
412    /// file_explorer.set_filter_map(|file| if file.is_dir { Some(file) } else { None }).unwrap();
413    ///
414    ///  /* Only directories are shown */
415    ///
416    /// let filter = file_explorer.remove_filter_map().unwrap();
417    ///
418    /// /* All files and directories are shown again */
419    /// ```
420    pub fn remove_filter_map(&mut self) -> Result<Option<Arc<Filter>>> {
421        let filter = self.filter.take();
422
423        self.files = Self::get_files(&self.cwd, self.show_hidden, None)?;
424        self.selected = 0;
425
426        Ok(filter)
427    }
428
429    /// Sets the theme of the file explorer.
430    ///
431    /// # Examples
432    ///
433    /// ```no_run
434    /// # use ratatui_explorer::{FileExplorer, Theme};
435    /// let mut file_explorer = FileExplorer::new().unwrap();
436    ///
437    /// file_explorer.set_theme(Theme::default().add_default_title());
438    /// ```
439    #[inline]
440    pub fn set_theme(&mut self, theme: Theme) {
441        self.theme = theme;
442    }
443
444    /// Sets the selected file or directory index inside the current [`Vec`](https://doc.rust-lang.org/stable/std/vec/struct.Vec.html)
445    /// of files and directories in the file explorer.
446    ///
447    /// The file explorer add the parent directory at the beginning of the
448    /// [`Vec`](https://doc.rust-lang.org/stable/std/vec/struct.Vec.html) of files, so setting the selected index to 0
449    /// will select the parent directory (if the current working directory not the root directory).
450    ///
451    /// # Panics
452    ///
453    /// Panics if `selected` is greater or equal to the number of files (plus the parent directory if it exist) in the
454    /// current working directory.
455    ///
456    /// # Examples
457    ///
458    /// Suppose you have this tree file, with `passport.png` selected inside `file_explorer`:
459    /// ```plaintext
460    /// /
461    /// ├── .git
462    /// └── Documents
463    ///     ├── passport.png  <- selected (index 1)
464    ///     └── resume.pdf
465    /// ```
466    /// You can set the selected index like this:
467    /// ```no_run
468    /// # use ratatui_explorer::FileExplorer;
469    /// let mut file_explorer = FileExplorer::new().unwrap();
470    ///
471    /// /* user select `password.png` */
472    ///
473    /// // Because the file explorer add the parent directory at the beginning
474    /// // of the `Vec` of files, index 0 is indeed the parent directory.
475    /// file_explorer.set_selected_idx(0);
476    /// assert_eq!(file_explorer.current().path.display().to_string(), "/");
477    ///
478    /// file_explorer.set_selected_idx(1);
479    /// assert_eq!(file_explorer.current().path.display().to_string(), "/Documents/passport.png");
480    ///
481    /// #[test]
482    /// #[should_panic]
483    /// fn index_out_of_bound() {
484    ///    let mut file_explorer = FileExplorer::new().unwrap();
485    ///    file_explorer.set_selected_idx(3);
486    /// }
487    /// ```
488    #[inline]
489    pub fn set_selected_idx(&mut self, selected: usize) {
490        assert!(selected < self.files.len());
491        self.selected = selected;
492    }
493
494    /// Returns the current file or directory selected.
495    ///
496    /// # Examples
497    ///
498    /// Suppose you have this tree file, with `passport.png` selected inside `file_explorer`:
499    /// ```plaintext
500    /// /
501    /// ├── .git
502    /// └── Documents
503    ///     ├── passport.png  <- selected
504    ///     └── resume.pdf
505    /// ```
506    /// You can get the current file like this:
507    /// ```no_run
508    /// # use ratatui_explorer::FileExplorer;
509    /// let file_explorer = FileExplorer::new().unwrap();
510    ///
511    /// /* user select `password.png` */
512    ///
513    /// let file = file_explorer.current();
514    /// assert_eq!(file.name, "passport.png");
515    /// ```
516    #[inline]
517    #[must_use]
518    pub fn current(&self) -> &File {
519        &self.files[self.selected]
520    }
521
522    /// Returns the current working directory of the file explorer.
523    ///
524    /// # Examples
525    ///
526    /// Suppose you have this tree file, with `passport.png` selected inside `file_explorer`:
527    /// ```plaintext
528    /// /
529    /// ├── .git
530    /// └── Documents
531    ///     ├── passport.png  <- selected
532    ///     └── resume.pdf
533    /// ```
534    /// You can get the current working directory like this:
535    /// ```no_run
536    /// # use ratatui_explorer::FileExplorer;
537    /// let file_explorer = FileExplorer::new().unwrap();
538    ///
539    /// /* user select `password.png` */
540    ///
541    /// let cwd = file_explorer.cwd();
542    /// assert_eq!(cwd.display().to_string(), "/Documents");
543    /// ```
544    #[inline]
545    #[must_use]
546    pub const fn cwd(&self) -> &PathBuf {
547        &self.cwd
548    }
549
550    /// Indicates whether hidden files are currently visible in the file explorer.
551    ///
552    /// # Examples
553    ///
554    ///
555    /// You can get the current value like this:
556    /// ```no_run
557    /// # use ratatui_explorer::FileExplorer;
558    /// let mut file_explorer = FileExplorer::new().unwrap();
559    ///
560    /// // By default, hidden files are not shown.
561    /// assert_eq!(file_explorer.show_hidden(), false);
562    ///
563    /// file_explorer.set_show_hidden(true);
564    /// assert_eq!(file_explorer.show_hidden(), true);
565    /// ```
566    #[inline]
567    #[must_use]
568    pub const fn show_hidden(&self) -> bool {
569        self.show_hidden
570    }
571
572    /// Returns the a [`Vec`](https://doc.rust-lang.org/stable/std/vec/struct.Vec.html) of files and directories in the
573    /// current working directory of the file explorer, plus the parent directory if it exist.
574    ///
575    /// # Examples
576    ///
577    /// Suppose you have this tree file, with `passport.png` selected inside `file_explorer`:
578    /// ```plaintext
579    /// /
580    /// ├── .git
581    /// └── Documents
582    ///     ├── passport.png  <- selected
583    ///     └── resume.pdf
584    /// ```
585    /// You can get the [`Vec`](https://doc.rust-lang.org/stable/std/vec/struct.Vec.html) of files and directories like this:
586    /// ```no_run
587    /// # use ratatui_explorer::FileExplorer;
588    /// let file_explorer = FileExplorer::new().unwrap();
589    ///
590    /// /* user select `password.png` */
591    ///
592    /// let files = file_explorer.files();
593    /// assert_eq!(files.len(), 3); // 2 files and 1 parent directory
594    /// ```
595    #[inline]
596    #[must_use]
597    pub const fn files(&self) -> &Vec<File> {
598        &self.files
599    }
600
601    /// Returns the index of the selected file or directory in the current [`Vec`](https://doc.rust-lang.org/stable/std/vec/struct.Vec.html)
602    /// of files and directories in the current working directory of the file explorer.
603    ///
604    /// # Examples
605    ///
606    /// Suppose you have this tree file, with `passport.png` selected inside `file_explorer`:
607    /// ```plaintext
608    /// /
609    /// ├── .git
610    /// └── Documents
611    ///     ├── passport.png  <- selected (index 1)
612    ///     └── resume.pdf
613    /// ```
614    /// You can get the selected index like this:
615    /// ```no_run
616    /// # use ratatui_explorer::FileExplorer;
617    /// let file_explorer = FileExplorer::new().unwrap();
618    ///
619    /// /* user select `password.png` */
620    ///
621    /// let selected_idx = file_explorer.selected_idx();
622    ///
623    /// // Because the file explorer add the parent directory at the beginning
624    /// // of the `Vec` of files, the selected index will be 1.
625    /// assert_eq!(selected_idx, 1);
626    /// ```
627    #[inline]
628    #[must_use]
629    pub const fn selected_idx(&self) -> usize {
630        self.selected
631    }
632
633    /// Returns the theme of the file explorer.
634    ///
635    /// # Examples
636    ///
637    /// ```no_run
638    /// # use ratatui_explorer::{FileExplorer, Theme};
639    /// let file_explorer = FileExplorer::new().unwrap();
640    ///
641    /// assert_eq!(file_explorer.theme(), &Theme::new());
642    /// ```
643    #[inline]
644    #[must_use]
645    pub const fn theme(&self) -> &Theme {
646        &self.theme
647    }
648
649    #[allow(missing_docs)]
650    #[inline]
651    #[deprecated(
652        since = "0.3.0",
653        note = "Use `FileExplorerBuilder::build_with_theme` instead"
654    )]
655    pub fn with_theme(theme: Theme) -> Result<FileExplorer> {
656        FileExplorerBuilder::build_with_theme(theme)
657    }
658
659    /// Get the files and directories in the current working directory and set them in the file explorer.
660    /// It add the parent directory at the beginning of the [`Vec`](https://doc.rust-lang.org/stable/std/vec/struct.Vec.html)
661    /// of files if it exist.
662    fn get_files(
663        working_dir: &Path,
664        show_hidden: bool,
665        filter: Option<&Arc<Filter>>,
666    ) -> Result<Vec<File>> {
667        let (mut dirs, mut none_dirs): (Vec<_>, Vec<_>) = std::fs::read_dir(working_dir)?
668            .filter_map(|entry| {
669                let entry = entry.ok()?;
670                let path = entry.path();
671                let metadata = path.metadata().ok();
672                let file_type = metadata.as_ref().map(|f| f.file_type());
673                let is_dir = file_type.is_some_and(|f| f.is_dir());
674
675                let name = entry.file_name().to_string_lossy().into_owned();
676                let name = if is_dir { format!("{name}/") } else { name };
677
678                let is_hidden = {
679                    #[cfg(unix)]
680                    {
681                        name.starts_with('.')
682                    }
683
684                    #[cfg(windows)]
685                    {
686                        use std::os::windows::fs::MetadataExt;
687                        const FILE_ATTRIBUTE_HIDDEN: u32 = 0x2;
688                        metadata.is_some_and(|f| f.file_attributes() & FILE_ATTRIBUTE_HIDDEN != 0)
689                    }
690                };
691
692                let file = File {
693                    name,
694                    path,
695                    is_dir,
696                    is_hidden,
697                    file_type,
698                };
699                if !show_hidden && file.is_hidden {
700                    None
701                } else if let Some(filter) = &filter {
702                    filter(file)
703                } else {
704                    Some(file)
705                }
706            })
707            .partition(|file| file.is_dir);
708
709        dirs.sort_unstable_by(|f1, f2| f1.name.cmp(&f2.name));
710        none_dirs.sort_unstable_by(|f1, f2| f1.name.cmp(&f2.name));
711
712        let files = if let Some(parent) = working_dir.parent() {
713            let mut files = Vec::with_capacity(1 + dirs.len() + none_dirs.len());
714
715            let parent = File {
716                name: "../".to_owned(),
717                path: parent.to_path_buf(),
718                is_dir: true,
719                is_hidden: false,
720                file_type: None,
721            };
722            if let Some(filter) = &filter {
723                if let Some(parent) = filter(parent) {
724                    files.push(parent);
725                }
726            } else {
727                files.push(parent);
728            }
729
730            files.extend(dirs);
731            files.extend(none_dirs);
732
733            files
734        } else {
735            let mut files = Vec::with_capacity(dirs.len() + none_dirs.len());
736
737            files.extend(dirs);
738            files.extend(none_dirs);
739
740            files
741        };
742
743        Ok(files)
744    }
745}
746
747#[cfg(test)]
748mod tests {
749    use super::*;
750
751    use std::fs::{self, File};
752    use tempfile::TempDir;
753
754    /// Build this temporary file system:
755    /// ```plaintext
756    /// <unknow>
757    /// └ root
758    ///   ├── .git
759    ///   └── Documents
760    ///       ├── passport.png
761    ///       └── resume.pdf
762    /// ```
763    fn build_tmp_file_system() -> Result<TempDir> {
764        let root = TempDir::new()?;
765
766        let git_path = root.path().join(".git");
767        let documents_path = root.path().join("Documents");
768        let passport_path = root.path().join("Documents/passport.png");
769        let resume_path = root.path().join("Documents/resume.pdf");
770
771        fs::create_dir(git_path)?;
772        fs::create_dir(documents_path)?;
773        File::create(passport_path)?;
774        File::create(resume_path)?;
775
776        Ok(root)
777    }
778
779    #[test]
780    fn test_thread_safe() {
781        fn is_sync<T: Sync>() {}
782
783        fn is_send<T: Send>() {}
784
785        is_send::<FileExplorer>();
786        is_sync::<FileExplorer>();
787    }
788
789    #[test]
790    fn test_set_cwd_does_not_change_displayed_path_on_failure() -> Result<()> {
791        let tmp_dir = TempDir::new()?;
792        let does_not_exist_path = tmp_dir.path().join("does_not_exist");
793        assert!(!does_not_exist_path.exists());
794
795        let mut explorer = FileExplorer::new()?;
796        let previous_cwd = explorer.cwd().clone();
797
798        let result = explorer.set_cwd(does_not_exist_path);
799        assert!(result.is_err());
800        assert_eq!(&previous_cwd, explorer.cwd());
801
802        Ok(())
803    }
804
805    #[cfg(unix)]
806    #[test]
807    fn test_hidden_files_are_ignored() -> Result<()> {
808        let root = build_tmp_file_system()?;
809
810        let mut explorer = FileExplorerBuilder::build_with_working_dir(root.path())?;
811        assert_eq!(explorer.files().len(), 2);
812
813        explorer.set_show_hidden(true)?;
814        assert_eq!(explorer.files().len(), 3);
815
816        Ok(())
817    }
818
819    #[test]
820    fn test_apply_filter_hide_files() -> Result<()> {
821        let root = build_tmp_file_system()?;
822        let documents_path = root.path().join("Documents");
823
824        let mut explorer = FileExplorerBuilder::build_with_working_dir(documents_path)?;
825        assert_eq!(explorer.files().len(), 3);
826
827        explorer
828            .set_filter_map(|file| if file.is_dir { Some(file) } else { None })
829            .unwrap();
830        assert_eq!(explorer.files().len(), 1);
831
832        Ok(())
833    }
834
835    #[test]
836    fn test_removing_filter_show_files() -> Result<()> {
837        let root = build_tmp_file_system()?;
838        let documents_path = root.path().join("Documents");
839
840        let mut explorer = FileExplorerBuilder::build_with_working_dir(documents_path)?;
841        assert_eq!(explorer.files().len(), 3);
842
843        explorer
844            .set_filter_map(|file| if file.is_dir { Some(file) } else { None })
845            .unwrap();
846        assert_eq!(explorer.files().len(), 1);
847
848        explorer.remove_filter_map()?;
849        assert_eq!(explorer.files().len(), 3);
850
851        Ok(())
852    }
853
854    #[test]
855    fn test_filter_is_apply_when_changing_working_dir() -> Result<()> {
856        let root = build_tmp_file_system()?;
857        let documents_path = root.path().join("Documents");
858
859        let mut explorer = FileExplorerBuilder::build_with_working_dir(documents_path)?;
860        explorer
861            .set_filter_map(|file| {
862                let keep = !file.name.ends_with("png");
863                if keep { Some(file) } else { None }
864            })
865            .unwrap();
866        assert_eq!(explorer.files().len(), 2);
867
868        // Exit and re-entre Documents/
869        explorer.handle(Input::Left)?;
870        explorer.handle(Input::Down)?;
871        explorer.handle(Input::Right)?;
872
873        assert_eq!(explorer.files().len(), 2);
874
875        Ok(())
876    }
877
878    #[test]
879    fn test_filter_mutate_files() -> Result<()> {
880        let root = build_tmp_file_system()?;
881        let documents_path = root.path().join("Documents");
882
883        let mut explorer = FileExplorerBuilder::build_with_working_dir(documents_path)?;
884        explorer
885            .set_filter_map(|mut file| {
886                let is_png = file.name.ends_with("png");
887                if is_png {
888                    file.name = file.name.replace("png", "jpg");
889                }
890                Some(file)
891            })
892            .unwrap();
893        assert_eq!(explorer.files().len(), 3);
894
895        let names = ["../", "passport.jpg", "resume.pdf"];
896
897        for (file, name) in explorer.files().iter().zip(names.iter()) {
898            assert_eq!(&file.name, name)
899        }
900
901        Ok(())
902    }
903
904    #[test]
905    fn test_filter_operate_on_parent() -> Result<()> {
906        let root = build_tmp_file_system()?;
907        let documents_path = root.path().join("Documents");
908
909        let mut explorer = FileExplorerBuilder::build_with_working_dir(documents_path)?;
910        explorer
911            .set_filter_map(|file| if file.is_dir { None } else { Some(file) })
912            .unwrap();
913
914        assert_eq!(explorer.files().len(), 2);
915
916        explorer.remove_filter_map()?;
917        assert_eq!(explorer.files().len(), 3);
918
919        Ok(())
920    }
921}