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}