Skip to main content

gpui_cn/file_picker/
mod.rs

1//! A dialog to choose files on any file system: the path is typed text,
2//! and the entries of its directory are listed, filtered fuzzily by what
3//! follows the last separator.
4//!
5//! [`FilePickerState`] holds the path, the listings, the filtered rows,
6//! and the selection; [`FilePicker`] draws them in a [`Dialog`]. It shares
7//! its browsing with the folder picker. Nothing waits on the UI thread: a
8//! [`FileSource`] returns a task for each page of a listing, and the
9//! filter runs on a background task that the next keystroke cancels.
10
11mod source;
12
13use std::{collections::HashSet, rc::Rc};
14
15use gpui_kit::{
16    App, AppContext as _, Context, ElementId, Entity, EventEmitter, FocusHandle, Focusable,
17    InteractiveElement as _, IntoElement, KeyBinding, Modifiers, ParentElement as _, RenderOnce,
18    Role, SharedString, StatefulInteractiveElement as _, Styled as _, Subscription, Task, Window,
19    actions,
20    assets::IconName,
21    base::{
22        Align, Disableable as _, TestSupportExt as _,
23        actions::{Confirm, SelectDown, SelectUp},
24        h_flex,
25    },
26    div,
27    prelude::FluentBuilder as _,
28};
29
30pub use source::{FileEntry, FileKind, FilePage, FileSource, LocalFiles, MemoryFiles};
31
32use crate::{
33    ActiveTheme as _, Button, ButtonSize, Dialog, DropdownMenu, Icon, MenuEntry, MenuEvent,
34    MenuItem, MenuState, ScrollArea, Switch, Theme,
35    menu::MenuLook,
36    path_browser::{
37        self, Body, Browser, CreateFolderError, Host, ListError, NewFolder, Page, PageToken,
38        PathStyle, Source, SourcePath, Submit, view,
39    },
40};
41
42/// The key context of the picker, which takes Up, Down, and Enter the
43/// path field passes on.
44const CONTEXT: &str = "GpuiCnFilePicker";
45
46actions!(
47    gpui_cn_file_picker,
48    [
49        /// Selects every file listed, when the picker takes several.
50        SelectAllFiles
51    ]
52);
53
54/// Cmd+A on macOS, Ctrl+A elsewhere.
55const SELECT_ALL_KEY: &str = if cfg!(target_os = "macos") {
56    "cmd-a"
57} else {
58    "ctrl-a"
59};
60
61pub(crate) fn init(cx: &mut App) {
62    path_browser::bind_keys(CONTEXT, cx);
63    // The path field binds this key to select its text. A binding for the
64    // field inside the picker is as deep and comes later, so it runs
65    // first, and it hands the key on to the field when it does not take it.
66    cx.bind_keys([KeyBinding::new(
67        SELECT_ALL_KEY,
68        SelectAllFiles,
69        Some("GpuiCnFilePicker > Input"),
70    )]);
71}
72
73/// The files of a directory loaded so far, and whether more follow.
74pub type FileLoaded = path_browser::Loaded<FileEntry>;
75
76/// What a [`FilePickerState`] knows about one directory.
77pub type FileListing = path_browser::Listing<FileEntry>;
78
79/// Which files a picker shows. Folders always show, so the user can go
80/// through them to the files.
81#[derive(Clone, Debug, PartialEq, Eq)]
82#[non_exhaustive]
83pub struct FileFilter {
84    label: SharedString,
85    extensions: Vec<String>,
86}
87
88impl FileFilter {
89    /// A filter that shows the files with one of `extensions`, named
90    /// `label` in the dialog. An extension is written without its dot,
91    /// and case does not matter. No extensions shows every file.
92    pub fn new(
93        label: impl Into<SharedString>,
94        extensions: impl IntoIterator<Item = impl Into<String>>,
95    ) -> Self {
96        Self {
97            label: label.into(),
98            extensions: extensions
99                .into_iter()
100                .map(|extension| {
101                    let extension: String = extension.into();
102                    extension.trim_start_matches('.').to_lowercase()
103                })
104                .collect(),
105        }
106    }
107
108    /// The name the dialog shows.
109    pub fn label(&self) -> &SharedString {
110        &self.label
111    }
112
113    /// The extensions, without a dot, in lower case.
114    pub fn extensions(&self) -> &[String] {
115        &self.extensions
116    }
117
118    /// Whether the filter shows `entry`: every folder, and the files with
119    /// one of its extensions.
120    pub fn matches(&self, entry: &FileEntry) -> bool {
121        if entry.is_folder() || self.extensions.is_empty() {
122            return true;
123        }
124        entry
125            .extension()
126            .is_some_and(|extension| self.extensions.contains(&extension))
127    }
128}
129
130/// What a [`FilePickerState`] reports.
131#[derive(Clone, Debug, PartialEq, Eq)]
132#[non_exhaustive]
133pub enum FilePickerEvent {
134    /// The files were chosen, in the order the list shows them.
135    Confirmed(Vec<SourcePath>),
136    /// The picker was cancelled.
137    Cancelled,
138    /// The user selected or deselected files.
139    SelectionChanged,
140    /// A folder was made in the directory that is listed, from the "New
141    /// folder" row or [`FilePickerState::create_folder`]. The listing is
142    /// read again, and the picker puts the highlight on the new folder.
143    FolderCreated(SourcePath),
144}
145
146/// The path text, the listings, the filtered rows, and the selection of a
147/// [`FilePicker`].
148///
149/// The text is a path. Everything up to and including its last separator
150/// is the directory that is listed; what follows is a fuzzy query over
151/// that directory's entries. Typing a separator after a query goes inside
152/// the folder it names, and a leading `~` stands for the source's home.
153/// Folders come first. A double click or Enter goes inside a folder, and
154/// the Up button goes to the parent.
155///
156/// A click selects a file. In a picker that takes
157/// [`multiple`](Self::multiple) files, Cmd (Ctrl elsewhere) toggles a
158/// file, Shift selects the range from the last file clicked, and Cmd+A
159/// selects every file listed while the path has no query. Enter or a double
160/// click on a file chooses it, and Cmd+Enter (Ctrl+Enter elsewhere)
161/// chooses the selection. The selection belongs to the directory that is
162/// listed, and only files that show count: a file the filter or the query
163/// hides is not chosen.
164pub struct FilePickerState {
165    browser: Browser<FileEntry>,
166    multiple: bool,
167    filters: Vec<FileFilter>,
168    active_filter: usize,
169    menu: Entity<MenuState>,
170    show_hidden: bool,
171    selected: HashSet<SharedString>,
172    selected_visit: u64,
173    /// The selection last reported, so a change is reported once.
174    reported: Vec<SourcePath>,
175    anchor: Option<SharedString>,
176    _subscription: Subscription,
177    _menu_events: Subscription,
178}
179
180impl Host<FileEntry> for FilePickerState {
181    fn browser(&self) -> &Browser<FileEntry> {
182        &self.browser
183    }
184
185    fn browser_mut(&mut self) -> &mut Browser<FileEntry> {
186        &mut self.browser
187    }
188
189    fn browser_changed(&mut self, cx: &mut Context<Self>) {
190        self.sync_selection(cx);
191    }
192
193    fn folder_created(&mut self, path: SourcePath, window: &mut Window, cx: &mut Context<Self>) {
194        self.browser.focus_path(window, cx);
195        cx.emit(FilePickerEvent::FolderCreated(path));
196    }
197}
198
199impl EventEmitter<FilePickerEvent> for FilePickerState {}
200
201/// The path field's focus, which holds the keyboard for the picker.
202impl Focusable for FilePickerState {
203    fn focus_handle(&self, cx: &App) -> FocusHandle {
204        self.browser.input.read(cx).focus_handle(cx)
205    }
206}
207
208impl FilePickerState {
209    /// A picker on the local file system, at the user's home folder (the
210    /// root when there is none). It takes one file, and hides hidden
211    /// entries. Name another start with [`with_initial`](Self::with_initial).
212    pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
213        let browser = Browser::new(Rc::new(Adapter(Rc::new(LocalFiles))), window, cx);
214        let subscription = browser.watch(window, cx);
215        let start = browser.default_start();
216        let menu = cx.new(MenuState::new);
217        let menu_events = cx.subscribe(&menu, |this, _, event: &MenuEvent, cx| {
218            if let MenuEvent::Activated(key) = event
219                && let Ok(index) = key.parse::<usize>()
220            {
221                this.set_active_filter(index, cx);
222            }
223        });
224        let mut state = Self {
225            browser,
226            multiple: false,
227            filters: Vec::new(),
228            active_filter: 0,
229            menu,
230            show_hidden: false,
231            selected: HashSet::new(),
232            selected_visit: 0,
233            reported: Vec::new(),
234            anchor: None,
235            _subscription: subscription,
236            _menu_events: menu_events,
237        };
238        state.apply_visible(cx);
239        state.browser.set_text(&start, window, cx);
240        state
241    }
242
243    /// Lists entries from `source` instead of the local file system. The
244    /// listings cached so far are dropped and the current directory is
245    /// listed again. The picker starts at the new source's home, unless
246    /// [`with_initial`](Self::with_initial) named a start, and at the root
247    /// when the source has no home. See [`FileSource`] for how to write
248    /// one.
249    pub fn with_source(
250        mut self,
251        source: impl FileSource,
252        window: &mut Window,
253        cx: &mut Context<Self>,
254    ) -> Self {
255        self.set_source(source, window, cx);
256        self
257    }
258
259    /// Lists from `source` from now on, as [`with_source`](Self::with_source)
260    /// does for a picker being built: the listings cached so far are
261    /// dropped, the requests in flight are cancelled, and the picker goes
262    /// to the new source's home, or keeps the path it was given with
263    /// `with_initial`.
264    pub fn set_source(
265        &mut self,
266        source: impl FileSource,
267        window: &mut Window,
268        cx: &mut Context<Self>,
269    ) {
270        self.browser
271            .set_source(Rc::new(Adapter(Rc::new(source))), window, cx);
272        cx.notify();
273    }
274
275    /// Starts in `path`, which is listed at once. A path that starts with
276    /// `~` is in the source's home. This wins over the source's home as
277    /// the start.
278    pub fn with_initial(
279        mut self,
280        path: impl AsRef<str>,
281        window: &mut Window,
282        cx: &mut Context<Self>,
283    ) -> Self {
284        self.browser.start_in(path.as_ref(), window, cx);
285        self
286    }
287
288    /// Whether the picker takes more than one file. The default is one.
289    pub fn with_multiple(mut self, multiple: bool) -> Self {
290        self.multiple = multiple;
291        self
292    }
293
294    /// The file types the user can switch between, such as "All files"
295    /// and "Rust files". The first is active. A filter with no extensions
296    /// shows every file. With one filter the dialog names it, and with
297    /// several it offers a menu. Folders always show.
298    pub fn with_filters(mut self, filters: Vec<FileFilter>, cx: &mut Context<Self>) -> Self {
299        self.filters = filters;
300        self.active_filter = 0;
301        self.apply_visible(cx);
302        self
303    }
304
305    /// Makes the filter at `index` the active one. An index out of range
306    /// changes nothing.
307    pub fn set_active_filter(&mut self, index: usize, cx: &mut Context<Self>) {
308        if index < self.filters.len() && index != self.active_filter {
309            self.active_filter = index;
310            self.apply_visible(cx);
311        }
312    }
313
314    /// Shows or hides the hidden entries. They are hidden by default.
315    pub fn set_show_hidden(&mut self, show: bool, cx: &mut Context<Self>) {
316        if self.show_hidden != show {
317            self.show_hidden = show;
318            self.apply_visible(cx);
319        }
320    }
321
322    fn apply_visible(&mut self, cx: &mut Context<Self>) {
323        let filter = self.active_filter().cloned();
324        let show_hidden = self.show_hidden;
325        self.browser.set_visible(
326            move |entry| {
327                (show_hidden || !entry.hidden())
328                    && filter.as_ref().is_none_or(|filter| filter.matches(entry))
329            },
330            cx,
331        );
332        self.sync_selection(cx);
333    }
334
335    /// Sets what the "Sign in" button does. A source that needs the user to
336    /// log in fails a listing with
337    /// [`ListError::AuthRequired`](crate::ListError::AuthRequired), and the
338    /// list then shows the source's message with a "Sign in" button that
339    /// calls `handler`. The picker holds no login code: the application
340    /// signs the user in, then calls [`retry`](Self::retry) to list again.
341    /// Without a handler the button is not shown.
342    pub fn on_auth_required(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
343        self.browser.set_auth_handler(Rc::new(handler));
344        self
345    }
346
347    /// Lists the directory again: the spinner shows and the source is
348    /// asked for the first page. Call it after the user signed in, or let
349    /// the "Retry" button call it.
350    pub fn retry(&mut self, cx: &mut Context<Self>) {
351        self.browser.retry(cx);
352        self.sync_selection(cx);
353    }
354
355    /// Why the directory could not be listed, when it could not.
356    pub fn failure(&self) -> Option<&crate::ListError> {
357        self.browser.failure()
358    }
359
360    /// Offers a "New folder" button, which opens a row at the top of the
361    /// list to name a folder in the directory that is listed. It is off
362    /// by default, and shows only when the source
363    /// [can make folders](FileSource::can_create_folders).
364    pub fn allow_new_folder(mut self, allow: bool) -> Self {
365        self.browser.set_allow_new_folder_seed(allow);
366        self
367    }
368
369    /// Whether the picker is set to offer a "New folder" button.
370    pub fn allows_new_folder(&self) -> bool {
371        self.browser.allows_new_folder()
372    }
373
374    /// Offers or withdraws the "New folder" button.
375    pub fn set_allow_new_folder(&mut self, allow: bool, cx: &mut Context<Self>) {
376        self.browser.set_allow_new_folder(allow, cx);
377    }
378
379    /// Whether the button shows: it is on and the source can make folders.
380    pub fn can_create_folder(&self) -> bool {
381        self.browser.can_create_folder()
382    }
383
384    /// Whether the row that names a new folder is open.
385    pub fn is_naming_folder(&self) -> bool {
386        self.browser.new_folder.is_some()
387    }
388
389    /// The name typed in the row that names a new folder, or `None` while
390    /// the row is closed.
391    pub fn new_folder_name(&self, cx: &App) -> Option<SharedString> {
392        self.browser
393            .new_folder
394            .as_ref()
395            .map(|row| row.input.read(cx).value())
396    }
397
398    /// Opens the row that names a new folder, with "Untitled folder"
399    /// selected. Nothing is made until the name is confirmed with Enter.
400    /// It does nothing while the directory loads, or when the source
401    /// cannot make folders.
402    pub fn begin_new_folder(&mut self, window: &mut Window, cx: &mut Context<Self>) {
403        self.browser.begin_default_new_folder(window, cx);
404    }
405
406    /// Makes the folder `name` in the directory that is listed, the way
407    /// the row does: the row opens with `name`, or takes it when it is
408    /// already open, the name is checked, the source is asked, and the
409    /// result shows in the row. The picker reports
410    /// [`FilePickerEvent::FolderCreated`] when the folder exists.
411    ///
412    /// Fails with [`CreateFolderError::NotReady`] while the directory
413    /// loads or a folder is being made, [`CreateFolderError::Unsupported`]
414    /// when new folders are off or the source cannot make them, and
415    /// [`CreateFolderError::InvalidName`] or
416    /// [`CreateFolderError::Exists`] for a name that is refused before the
417    /// source is asked.
418    pub fn create_folder(
419        &mut self,
420        name: &str,
421        window: &mut Window,
422        cx: &mut Context<Self>,
423    ) -> Result<(), CreateFolderError> {
424        self.browser.create_folder(name, window, cx)
425    }
426
427    /// The path field's state.
428    pub fn input(&self) -> &Entity<gpui_kit::base::input::InputState> {
429        &self.browser.input
430    }
431
432    /// The directory that is listed.
433    pub fn dir(&self) -> &SourcePath {
434        &self.browser.dir
435    }
436
437    /// The query: the text after the last separator.
438    pub fn query(&self) -> &str {
439        &self.browser.query
440    }
441
442    /// The path text, as the field shows it.
443    pub fn text(&self, cx: &App) -> SharedString {
444        self.browser.input.read(cx).value()
445    }
446
447    /// What is known about the directory that is listed.
448    pub fn listing(&self) -> Option<&FileListing> {
449        self.browser.listing()
450    }
451
452    /// How many entries the list shows, not counting the "Load more" row.
453    pub fn row_count(&self) -> usize {
454        self.browser.row_count()
455    }
456
457    /// The name of the entry the keyboard is on.
458    pub fn highlighted(&self) -> Option<SharedString> {
459        self.browser
460            .entry_at(self.browser.highlighted?)
461            .map(|(entry, _)| entry.name().clone())
462    }
463
464    /// The names the list shows, in order.
465    pub fn names(&self) -> Vec<SharedString> {
466        (0..self.browser.row_count())
467            .filter_map(|row| self.browser.entry_at(row))
468            .map(|(entry, _)| entry.name().clone())
469            .collect()
470    }
471
472    /// Whether the picker takes more than one file.
473    pub fn multiple(&self) -> bool {
474        self.multiple
475    }
476
477    /// The filters the user can switch between.
478    pub fn filters(&self) -> &[FileFilter] {
479        &self.filters
480    }
481
482    /// The index of the active filter.
483    pub fn active_filter_index(&self) -> usize {
484        self.active_filter
485    }
486
487    /// The active filter, when there are any.
488    pub fn active_filter(&self) -> Option<&FileFilter> {
489        self.filters.get(self.active_filter)
490    }
491
492    /// How many files of the listed directory the active filter hides,
493    /// not counting hidden files.
494    pub fn filtered_out(&self) -> usize {
495        let Some(filter) = self.active_filter() else {
496            return 0;
497        };
498        self.browser.loaded().map_or(0, |loaded| {
499            loaded
500                .entries()
501                .iter()
502                .filter(|entry| !entry.is_folder() && (self.show_hidden || !entry.hidden()))
503                .filter(|entry| !filter.matches(entry))
504                .count()
505        })
506    }
507
508    /// How many hidden files of the listed directory the switch hides.
509    pub fn hidden_files(&self) -> usize {
510        if self.show_hidden {
511            return 0;
512        }
513        self.browser.loaded().map_or(0, |loaded| {
514            loaded
515                .entries()
516                .iter()
517                .filter(|entry| !entry.is_folder() && entry.hidden())
518                .count()
519        })
520    }
521
522    /// How many files the listed directory holds, whatever the filter and
523    /// the switch hide.
524    pub fn file_count(&self) -> usize {
525        self.browser.loaded().map_or(0, |loaded| {
526            loaded
527                .entries()
528                .iter()
529                .filter(|entry| !entry.is_folder())
530                .count()
531        })
532    }
533
534    /// Whether hidden entries show.
535    pub fn show_hidden(&self) -> bool {
536        self.show_hidden
537    }
538
539    /// The files selected and showing, in the order of the list.
540    pub fn selection(&self) -> Vec<SourcePath> {
541        if self.selected_visit != self.browser.visit() {
542            return Vec::new();
543        }
544        (0..self.browser.row_count())
545            .filter_map(|row| self.browser.entry_at(row))
546            .filter(|(entry, _)| !entry.is_folder() && self.selected.contains(entry.name()))
547            .map(|(entry, _)| self.browser.dir.join(entry.name().as_str()))
548            .collect()
549    }
550
551    fn is_selected(&self, name: &SharedString) -> bool {
552        self.selected_visit == self.browser.visit() && self.selected.contains(name)
553    }
554
555    /// Starts a selection for this visit to the listed directory, dropping
556    /// the one of a visit the picker has left, even when it came back.
557    fn retarget(&mut self) {
558        if self.selected_visit != self.browser.visit() {
559            self.selected.clear();
560            self.anchor = None;
561            self.selected_visit = self.browser.visit();
562        }
563    }
564
565    fn set_selection(
566        &mut self,
567        names: HashSet<SharedString>,
568        anchor: Option<SharedString>,
569        cx: &mut Context<Self>,
570    ) {
571        self.sync_selection(cx);
572        self.retarget();
573        self.selected = names;
574        self.anchor = anchor;
575        self.sync_selection(cx);
576        cx.notify();
577    }
578
579    /// Reports a change of the selection that was not made by a click:
580    /// leaving the folder, a filter, the hidden switch, or a listing that
581    /// no longer holds a selected file.
582    fn sync_selection(&mut self, cx: &mut Context<Self>) {
583        let now = self.selection();
584        if now != self.reported {
585            self.reported = now;
586            cx.emit(FilePickerEvent::SelectionChanged);
587        }
588    }
589
590    /// The rows that hold files, as (row, name).
591    fn file_rows(&self) -> Vec<(usize, SharedString)> {
592        (0..self.browser.row_count())
593            .filter_map(|row| {
594                let (entry, _) = self.browser.entry_at(row)?;
595                (!entry.is_folder()).then(|| (row, entry.name().clone()))
596            })
597            .collect()
598    }
599
600    /// Selects every file that shows. A picker for one file does nothing.
601    pub fn select_all(&mut self, cx: &mut Context<Self>) {
602        if !self.multiple {
603            return;
604        }
605        let names = self.file_rows().into_iter().map(|(_, name)| name).collect();
606        self.set_selection(names, None, cx);
607    }
608
609    /// Selects nothing.
610    pub fn clear_selection(&mut self, cx: &mut Context<Self>) {
611        self.set_selection(HashSet::new(), None, cx);
612    }
613
614    /// Chooses the selection, if it is not empty.
615    pub fn confirm(&mut self, cx: &mut Context<Self>) {
616        let paths = self.selection();
617        if !paths.is_empty() {
618            self.browser.stop_requests(cx);
619            cx.emit(FilePickerEvent::Confirmed(paths));
620        }
621    }
622
623    /// Cancels the picker and every request to the source that is still
624    /// running: their answers never arrive. A directory that was still
625    /// loading is listed again when the picker shows.
626    pub fn cancel(&mut self, cx: &mut Context<Self>) {
627        self.browser.stop_requests(cx);
628        cx.emit(FilePickerEvent::Cancelled);
629    }
630
631    /// Goes to the parent of the listed directory. The root is its own
632    /// parent.
633    pub fn go_up(&mut self, window: &mut Window, cx: &mut Context<Self>) {
634        self.browser.go_up(window, cx);
635        self.sync_selection(cx);
636    }
637
638    /// Goes inside the folder `name` of the listed directory.
639    pub fn descend(&mut self, name: &str, window: &mut Window, cx: &mut Context<Self>) {
640        self.browser.descend(name, window, cx);
641        self.sync_selection(cx);
642    }
643
644    /// Asks the source for the next page of the listed directory, when it
645    /// has one and no request is running. After a failure it asks again.
646    pub fn load_more(&mut self, cx: &mut Context<Self>) {
647        self.browser.load_more(cx);
648    }
649
650    /// Select-all from the keyboard: only for a picker of several files
651    /// while the path has no query, so that Cmd+A still selects the text
652    /// of a query. Tells whether it took the key.
653    fn select_all_key(&mut self, cx: &mut Context<Self>) -> bool {
654        let takes =
655            self.multiple && self.browser.query.is_empty() && self.browser.new_folder.is_none();
656        if takes {
657            self.select_all(cx);
658        }
659        takes
660    }
661
662    /// Enter: goes inside the highlighted folder, loads more on the
663    /// "Load more" row, or chooses the highlighted file. A file that is
664    /// not selected becomes the selection first, so Enter opens what the
665    /// keyboard is on. With no highlight it chooses the selection.
666    fn enter(&mut self, window: &mut Window, cx: &mut Context<Self>) {
667        let Some(row) = self.browser.highlighted else {
668            self.confirm(cx);
669            return;
670        };
671        if row == self.browser.row_count() && self.browser.has_more_row() {
672            self.browser.load_more(cx);
673            return;
674        }
675        let Some((entry, _)) = self.browser.entry_at(row) else {
676            return;
677        };
678        let name = entry.name().clone();
679        if entry.is_folder() {
680            self.browser.descend(&name, window, cx);
681            self.sync_selection(cx);
682            return;
683        }
684        if !self.is_selected(&name) {
685            self.set_selection(HashSet::from([name.clone()]), Some(name), cx);
686        }
687        self.confirm(cx);
688    }
689
690    /// A click on the row `row`. A folder opens on a double click, or on
691    /// a single one when `tap_opens` says the pointer is a finger. A file
692    /// is selected: alone, toggled with the secondary modifier, or as a
693    /// range with Shift. A double click chooses it.
694    fn click_row(
695        &mut self,
696        row: usize,
697        modifiers: Modifiers,
698        clicks: usize,
699        tap_opens: bool,
700        window: &mut Window,
701        cx: &mut Context<Self>,
702    ) {
703        self.browser.highlighted = Some(row);
704        if row == self.browser.row_count() && self.browser.has_more_row() {
705            self.browser.load_more(cx);
706            return;
707        }
708        let Some((entry, _)) = self.browser.entry_at(row) else {
709            return;
710        };
711        let name = entry.name().clone();
712        if entry.is_folder() {
713            if clicks >= 2 || tap_opens {
714                self.browser.descend(&name, window, cx);
715                self.sync_selection(cx);
716            } else {
717                cx.notify();
718            }
719            return;
720        }
721        self.retarget();
722        let mut names = self.selected.clone();
723        let mut anchor = Some(name.clone());
724        if self.multiple && modifiers.secondary(cx) {
725            if !names.remove(&name) {
726                names.insert(name.clone());
727            }
728        } else if self.multiple && modifiers.shift {
729            let files = self.file_rows();
730            let from = self
731                .anchor
732                .as_ref()
733                .and_then(|anchor| files.iter().position(|(_, file)| file == anchor));
734            let to = files.iter().position(|(_, file)| *file == name);
735            names = match (from, to) {
736                (Some(from), Some(to)) => files[from.min(to)..=from.max(to)]
737                    .iter()
738                    .map(|(_, file)| file.clone())
739                    .collect(),
740                _ => HashSet::from([name.clone()]),
741            };
742            if from.is_some() {
743                anchor = self.anchor.clone();
744            }
745        } else {
746            names = HashSet::from([name.clone()]);
747        }
748        self.set_selection(names, anchor, cx);
749        if clicks >= 2 && !modifiers.modified() {
750            self.confirm(cx);
751        }
752    }
753}
754
755/// A file source seen as the browser's source.
756struct Adapter(Rc<dyn FileSource>);
757
758impl Source<FileEntry> for Adapter {
759    fn list(
760        &self,
761        dir: &SourcePath,
762        page: Option<PageToken>,
763        cx: &mut App,
764    ) -> Task<Result<Page<FileEntry>, ListError>> {
765        let task = self.0.list(dir, page, cx);
766        cx.spawn(async move |_| {
767            let page = task.await?;
768            Ok(Page {
769                entries: page.entries,
770                next: page.next,
771            })
772        })
773    }
774
775    fn home(&self) -> Option<SourcePath> {
776        self.0.home()
777    }
778
779    fn style(&self) -> PathStyle {
780        self.0.path_style()
781    }
782
783    fn env(&self, name: &str) -> Option<String> {
784        self.0.env(name)
785    }
786
787    fn can_create_folders(&self) -> bool {
788        self.0.can_create_folders()
789    }
790
791    fn create_folder(
792        &self,
793        parent: &SourcePath,
794        name: &str,
795        cx: &mut App,
796    ) -> Task<Result<SourcePath, CreateFolderError>> {
797        self.0.create_folder(parent, name, cx)
798    }
799}
800
801type CloseHandler = Rc<dyn Fn(&mut Window, &mut App)>;
802type OpenChange = Rc<dyn Fn(bool, &mut Window, &mut App)>;
803
804/// A dialog to choose files on a [`FilePickerState`]: an Up button and the
805/// path field, a row with the filter and the "Show hidden" switch, a
806/// bordered list of the entries, and Cancel and "Open file" in the footer.
807///
808/// ```
809/// use gpui_cn::{FilePicker, FilePickerState};
810/// use gpui_kit::{Entity, IntoElement};
811///
812/// fn picker(state: &Entity<FilePickerState>, open: bool) -> impl IntoElement {
813///     FilePicker::new("open-file", state)
814///         .open(open)
815///         .title("Open a Rust file")
816/// }
817/// ```
818///
819/// The application owns the open state: [`open`](Self::open) sets it, and
820/// it closes the picker when the state reports
821/// [`FilePickerEvent::Confirmed`] or [`FilePickerEvent::Cancelled`]. The
822/// id must be stable across frames.
823#[derive(IntoElement)]
824pub struct FilePicker {
825    id: ElementId,
826    state: Entity<FilePickerState>,
827    open: bool,
828    title: Option<SharedString>,
829    confirm_label: Option<SharedString>,
830    cancel_label: SharedString,
831    on_open_change: Option<OpenChange>,
832}
833
834impl FilePicker {
835    /// A closed picker on `state`, with a stable id.
836    pub fn new(id: impl Into<ElementId>, state: &Entity<FilePickerState>) -> Self {
837        Self {
838            id: id.into(),
839            state: state.clone(),
840            open: false,
841            title: None,
842            confirm_label: None,
843            cancel_label: "Cancel".into(),
844            on_open_change: None,
845        }
846    }
847
848    /// Shows or hides the picker.
849    pub fn open(mut self, open: bool) -> Self {
850        self.open = open;
851        self
852    }
853
854    /// The title. The default is "Choose a file", or "Choose files" for a
855    /// picker of several.
856    pub fn title(mut self, title: impl Into<SharedString>) -> Self {
857        self.title = Some(title.into());
858        self
859    }
860
861    /// The label of the button that chooses. The default is "Open file",
862    /// or "Open files" for a picker of several.
863    pub fn confirm_label(mut self, label: impl Into<SharedString>) -> Self {
864        self.confirm_label = Some(label.into());
865        self
866    }
867
868    /// The label of the button that cancels. The default is "Cancel".
869    pub fn cancel_label(mut self, label: impl Into<SharedString>) -> Self {
870        self.cancel_label = label.into();
871        self
872    }
873
874    /// Called when Escape, a press on the backdrop, the close button, or
875    /// Cancel asks to close the picker, after the state reports
876    /// [`FilePickerEvent::Cancelled`].
877    pub fn on_open_change(
878        mut self,
879        handler: impl Fn(bool, &mut Window, &mut App) + 'static,
880    ) -> Self {
881        self.on_open_change = Some(Rc::new(handler));
882        self
883    }
884}
885
886impl RenderOnce for FilePicker {
887    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
888        let theme = cx.theme();
889        let look = MenuLook::of(theme, window.rem_size());
890        let list_height = theme.metrics.folder_list_height;
891        let link = theme.link;
892        let pointer_cursors = Theme::global(cx).pointer_cursors;
893        let state = self.state;
894        let child = |name: &'static str| ElementId::NamedChild(self.id.clone().into(), name.into());
895        if self.open {
896            let state = state.clone();
897            window.defer(cx, move |_, cx| {
898                state.update(cx, |state, cx| state.browser.ensure_listed(cx));
899            });
900        }
901        let (failure, can_sign_in) = {
902            let browser = &state.read(cx).browser;
903            (browser.failure().cloned(), browser.auth_handler().is_some())
904        };
905        let (input, list, body_kind, selected, multiple, filters, menu, show_hidden, hint) = {
906            let state = state.read(cx);
907            let hint = if state.browser.loaded().is_none() {
908                Hint::None
909            } else if state.file_count() == 0 {
910                Hint::NoFiles
911            } else if state.filtered_out() > 0 {
912                Hint::Filtered {
913                    count: state.filtered_out(),
914                    label: state
915                        .active_filter()
916                        .map(|filter| filter.label.clone())
917                        .unwrap_or_default(),
918                    can_reset: state.active_filter > 0,
919                }
920            } else if state.hidden_files() > 0 {
921                Hint::Hidden(state.hidden_files())
922            } else {
923                Hint::None
924            };
925            (
926                state.browser.input.clone(),
927                state.browser.list.clone(),
928                state.browser.body(),
929                state.selection(),
930                state.multiple,
931                state
932                    .filters
933                    .iter()
934                    .map(|filter| filter.label.clone())
935                    .collect::<Vec<_>>(),
936                state.menu.clone(),
937                state.show_hidden,
938                hint,
939            )
940        };
941        let has_selection = !selected.is_empty();
942        let focus = input.read(cx).focus_handle(cx);
943
944        let close: CloseHandler = {
945            let state = state.clone();
946            let handler = self.on_open_change;
947            Rc::new(move |window, cx| {
948                state.update(cx, |state, cx| state.cancel(cx));
949                if let Some(handler) = &handler {
950                    handler(false, window, cx);
951                }
952            })
953        };
954
955        let body = match body_kind {
956            Body::Loading => view::loading(child("loading"), &look),
957            Body::Failed => match failure {
958                Some(error) => view::failure::<FileEntry, FilePickerState>(
959                    &self.id,
960                    &look,
961                    &state,
962                    &error,
963                    can_sign_in,
964                ),
965                None => div().into_any_element(),
966            },
967            Body::Empty => view::message(child("message"), &look, view::EMPTY_FILES),
968            Body::NoMatch => view::message(child("message"), &look, view::NO_FILE_MATCH),
969            Body::List => {
970                let rows = Rows {
971                    id: self.id.clone(),
972                    state: state.clone(),
973                    look: look.clone(),
974                    pointer_cursors,
975                };
976                let scrolled = state.clone();
977                ScrollArea::list(
978                    child("rows"),
979                    &list,
980                    look.row_height,
981                    move |row, window, cx| rows.render(row, window, cx),
982                )
983                .on_scroll(move |_, cx| {
984                    scrolled.update(cx, |state, cx| state.browser_mut().rows_scrolled(cx));
985                })
986                .size_full()
987                .into_any_element()
988            }
989        };
990
991        let up = {
992            let state = state.clone();
993            Button::new(child("up"))
994                .ghost()
995                .icon(Icon::from(IconName::ArrowUp))
996                .accessibility_label("Parent folder")
997                .on_click(move |_, window, cx| {
998                    state.update(cx, |state, cx| state.go_up(window, cx));
999                })
1000        };
1001        let new_folder = state.read(cx).can_create_folder().then(|| {
1002            let ready = state.read(cx).browser.new_folder_ready();
1003            let state = state.clone();
1004            Button::new(child("new-folder-button"))
1005                .ghost()
1006                .icon(Icon::from(IconName::Plus))
1007                .label("New folder")
1008                .disabled(!ready)
1009                .on_click(move |_, window, cx| {
1010                    state.update(cx, |state, cx| state.begin_new_folder(window, cx));
1011                })
1012        });
1013        let options = h_flex()
1014            .w_full()
1015            .items_center()
1016            .justify_between()
1017            .gap_2()
1018            .px(look.row_padding)
1019            .text_color(look.muted_foreground)
1020            .child(h_flex().gap_2().items_center().children(new_folder).child(
1021                if filters.len() > 1 {
1022                    let active_label = {
1023                        let state = state.read(cx);
1024                        state
1025                            .active_filter()
1026                            .map(|filter| filter.label.clone())
1027                            .unwrap_or_default()
1028                    };
1029                    let items = state.clone();
1030                    DropdownMenu::new(child("filter-menu"), &menu)
1031                        .align(Align::Start)
1032                        .trigger(
1033                            Button::new(child("filter-trigger"))
1034                                .ghost()
1035                                .size(ButtonSize::Sm)
1036                                .accessibility_label(active_label.clone())
1037                                .child(active_label)
1038                                .trailing_icon(Icon::from(IconName::ChevronDown).size_3()),
1039                        )
1040                        .items(move |_, cx| {
1041                            let state = items.read(cx);
1042                            state
1043                                .filters
1044                                .iter()
1045                                .enumerate()
1046                                .map(|(index, filter)| {
1047                                    MenuEntry::from(
1048                                        MenuItem::new(index.to_string(), filter.label.clone())
1049                                            .checked(index == state.active_filter),
1050                                    )
1051                                })
1052                                .collect()
1053                        })
1054                        .into_any_element()
1055                } else {
1056                    div()
1057                        .id(child("filter"))
1058                        .test_support()
1059                        .min_w_0()
1060                        .overflow_hidden()
1061                        .whitespace_nowrap()
1062                        .children(filters.first().cloned())
1063                        .into_any_element()
1064                },
1065            ))
1066            .child(
1067                h_flex()
1068                    .gap_2()
1069                    .items_center()
1070                    .child({
1071                        let state = state.clone();
1072                        Switch::new(child("hidden"))
1073                            .checked(show_hidden)
1074                            .accessibility_label("Show hidden")
1075                            .on_change(move |value, _, cx| {
1076                                let value = *value;
1077                                state.update(cx, |state, cx| state.set_show_hidden(value, cx));
1078                            })
1079                    })
1080                    .child("Show hidden"),
1081            )
1082            .into_any_element();
1083        let top = {
1084            let read = state.read(cx);
1085            read.browser.new_folder.as_ref().map(|row| {
1086                view::new_folder_row::<FileEntry, FilePickerState>(&self.id, &look, &state, row, cx)
1087            })
1088        };
1089        let (up_key, down_key, enter_key, all_key) =
1090            (state.clone(), state.clone(), state.clone(), state.clone());
1091        let content = view::stack()
1092            .key_context(CONTEXT)
1093            .on_action(move |_: &SelectAllFiles, _, cx| {
1094                if !all_key.update(cx, |state, cx| state.select_all_key(cx)) {
1095                    cx.propagate();
1096                }
1097            })
1098            .on_action(move |_: &SelectUp, _, cx| {
1099                up_key.update(cx, |state, cx| state.browser.move_highlight(-1, cx));
1100            })
1101            .on_action(move |_: &SelectDown, _, cx| {
1102                down_key.update(cx, |state, cx| state.browser.move_highlight(1, cx));
1103            })
1104            .on_action({
1105                let state = state.clone();
1106                move |_: &Submit, _, cx| state.update(cx, |state, cx| state.confirm(cx))
1107            })
1108            .on_action({
1109                let state = state.clone();
1110                move |_: &NewFolder, window, cx| {
1111                    state.update(cx, |state, cx| state.begin_new_folder(window, cx));
1112                }
1113            })
1114            .on_action(move |_: &Confirm, window, cx| {
1115                enter_key.update(cx, |state, cx| state.enter(window, cx));
1116            })
1117            .child(
1118                h_flex()
1119                    .gap_2()
1120                    .items_center()
1121                    .child(up)
1122                    .child(div().flex_1().child(crate::Input::new(&input))),
1123            )
1124            .child(view::slot(&look, Some(options)))
1125            .child(view::list_box(child("list"), &look, list_height, top, body))
1126            .child(status_row(
1127                child("status"),
1128                &look,
1129                link,
1130                pointer_cursors,
1131                &state,
1132                multiple,
1133                &selected,
1134                hint,
1135            ));
1136
1137        let title = self.title.unwrap_or_else(|| {
1138            if multiple {
1139                "Choose files"
1140            } else {
1141                "Choose a file"
1142            }
1143            .into()
1144        });
1145        let confirm_label = self.confirm_label.unwrap_or_else(|| {
1146            match (multiple, selected.len()) {
1147                (true, 0) => "Open files".to_string(),
1148                (true, 1) => "Open 1 file".to_string(),
1149                (true, count) => format!("Open {count} files"),
1150                (false, _) => "Open file".to_string(),
1151            }
1152            .into()
1153        });
1154        let footer = h_flex()
1155            .gap_2()
1156            .child({
1157                let close = close.clone();
1158                Button::new(child("cancel"))
1159                    .ghost()
1160                    .label(self.cancel_label)
1161                    .on_click(move |_, window, cx| close(window, cx))
1162            })
1163            .child({
1164                let state = state.clone();
1165                Button::new(child("open"))
1166                    .primary()
1167                    .label(confirm_label)
1168                    .disabled(!has_selection)
1169                    .on_click(move |_, _, cx| state.update(cx, |state, cx| state.confirm(cx)))
1170            });
1171
1172        Dialog::new(self.id.clone())
1173            .open(self.open)
1174            .title(title)
1175            .track_focus(&focus)
1176            .on_open_change(move |_, window, cx| close(window, cx))
1177            .child(content)
1178            .footer(footer)
1179    }
1180}
1181
1182/// Why the list may show fewer files than the folder holds.
1183enum Hint {
1184    None,
1185    NoFiles,
1186    Filtered {
1187        count: usize,
1188        label: SharedString,
1189        can_reset: bool,
1190    },
1191    Hidden(usize),
1192}
1193
1194fn files_text(count: usize) -> String {
1195    crate::plural::counted(count, "file")
1196}
1197
1198/// The line under the list: what is selected, and why files may be
1199/// missing, with the action that shows them. Its slot is always there, so
1200/// the dialog keeps its height.
1201#[allow(clippy::too_many_arguments)]
1202fn status_row(
1203    id: ElementId,
1204    look: &MenuLook,
1205    link: gpui_kit::Hsla,
1206    pointer_cursors: bool,
1207    state: &Entity<FilePickerState>,
1208    multiple: bool,
1209    selected: &[SourcePath],
1210    hint: Hint,
1211) -> gpui_kit::AnyElement {
1212    let selection = match selected {
1213        [] if multiple => "No files selected".to_string(),
1214        [] => "No file selected".to_string(),
1215        [one] => format!("{} selected", one.file_name().unwrap_or_default()),
1216        many => format!("{} selected", files_text(many.len())),
1217    };
1218    let action = |name: &'static str,
1219                  label: &'static str,
1220                  run: fn(&mut FilePickerState, &mut Context<FilePickerState>)| {
1221        let state = state.clone();
1222        div()
1223            .id(ElementId::NamedChild(id.clone().into(), name.into()))
1224            .test_support()
1225            .role(Role::Button)
1226            .flex_shrink_0()
1227            .text_color(link)
1228            .when(pointer_cursors, |this| this.cursor_pointer())
1229            .child(label)
1230            .on_click(move |_, _, cx| state.update(cx, run))
1231            .into_any_element()
1232    };
1233    let note = |text: String| {
1234        div()
1235            .min_w_0()
1236            .overflow_hidden()
1237            .whitespace_nowrap()
1238            .child(text)
1239    };
1240    let hint = match hint {
1241        Hint::None => None,
1242        Hint::NoFiles => Some(h_flex().child(note("No files here.".into()))),
1243        Hint::Filtered {
1244            count,
1245            label,
1246            can_reset,
1247        } => Some(
1248            h_flex()
1249                .gap_1()
1250                .child(note(format!(
1251                    "{} hidden by the {label} filter.",
1252                    files_text(count)
1253                )))
1254                .children(can_reset.then(|| {
1255                    action("show-all", "Show all", |state, cx| {
1256                        state.set_active_filter(0, cx)
1257                    })
1258                })),
1259        ),
1260        Hint::Hidden(count) => Some(
1261            h_flex()
1262                .gap_1()
1263                .child(note(format!("{} hidden.", files_text(count))))
1264                .child(action("show-hidden", "Show hidden", |state, cx| {
1265                    state.set_show_hidden(true, cx)
1266                })),
1267        ),
1268    };
1269    h_flex()
1270        .id(id.clone())
1271        .test_support()
1272        .h(look.row_height)
1273        .flex_shrink_0()
1274        .px(look.row_padding)
1275        .gap_3()
1276        .items_center()
1277        .justify_between()
1278        .overflow_hidden()
1279        .text_color(look.muted_foreground)
1280        .child(
1281            div()
1282                .id(ElementId::NamedChild(id.into(), "selection".into()))
1283                .test_support()
1284                .min_w_0()
1285                .overflow_hidden()
1286                .whitespace_nowrap()
1287                .child(selection),
1288        )
1289        .children(hint.map(|hint| hint.min_w_0().overflow_hidden()))
1290        .into_any_element()
1291}
1292
1293/// What a row is drawn from, owned by the list's row builder, which
1294/// outlives the render that made it.
1295struct Rows {
1296    id: ElementId,
1297    state: Entity<FilePickerState>,
1298    look: MenuLook,
1299    pointer_cursors: bool,
1300}
1301
1302impl Rows {
1303    fn render(&self, row: usize, _: &mut Window, cx: &mut App) -> gpui_kit::AnyElement {
1304        let look = &self.look;
1305        let (name, ranges, highlighted, selected, folder, size, icon) = {
1306            let state = self.state.read(cx);
1307            let browser = &state.browser;
1308            if row == browser.row_count() && browser.has_more_row() {
1309                return view::more_row::<FileEntry, FilePickerState>(
1310                    &self.id,
1311                    look,
1312                    &self.state,
1313                    view::MoreRow {
1314                        state: browser.more_state(),
1315                        error: browser.more_error().cloned(),
1316                        can_sign_in: browser.auth_handler().is_some(),
1317                    },
1318                    browser.highlighted == Some(row),
1319                    row,
1320                    self.pointer_cursors,
1321                );
1322            }
1323            let Some((entry, ranges)) = browser.entry_at(row) else {
1324                return div().into_any_element();
1325            };
1326            (
1327                entry.name().clone(),
1328                ranges.to_vec(),
1329                browser.highlighted == Some(row),
1330                state.is_selected(entry.name()),
1331                entry.is_folder(),
1332                entry.size(),
1333                icon_of(entry),
1334            )
1335        };
1336        let label = crate::menu::matched_text(name.clone(), &ranges, look);
1337        let state = self.state.clone();
1338        let hover = self.state.clone();
1339        let touch = look.touch;
1340        view::row_frame(
1341            ElementId::NamedChild(
1342                ElementId::NamedChild(self.id.clone().into(), "entry".into()).into(),
1343                name,
1344            ),
1345            look,
1346            Role::ListBoxOption,
1347            highlighted,
1348            selected && !folder,
1349            self.pointer_cursors,
1350        )
1351        .child(
1352            Icon::from(icon)
1353                .size_4()
1354                .flex_shrink_0()
1355                .text_color(look.muted_foreground),
1356        )
1357        .child(
1358            div()
1359                .flex_1()
1360                .min_w_0()
1361                .overflow_hidden()
1362                .whitespace_nowrap()
1363                .child(label),
1364        )
1365        .when_some(size, |this, size| {
1366            this.child(
1367                div()
1368                    .flex_shrink_0()
1369                    .text_color(look.muted_foreground)
1370                    .child(size_text(size)),
1371            )
1372        })
1373        .when(!folder, |this| {
1374            this.child(div().w_4().flex_shrink_0().when(selected, |this| {
1375                this.child(
1376                    Icon::from(IconName::Check)
1377                        .size_4()
1378                        .text_color(look.foreground),
1379                )
1380            }))
1381        })
1382        .when(!look.touch, |this| {
1383            this.on_mouse_move(move |_, _, cx| {
1384                view::highlight_on_hover::<FileEntry, FilePickerState>(&hover, row, cx)
1385            })
1386        })
1387        .on_click(move |event, window, cx| {
1388            let (modifiers, clicks) = (event.modifiers(), event.click_count());
1389            state.update(cx, |state, cx| {
1390                state.click_row(row, modifiers, clicks, touch, window, cx)
1391            });
1392        })
1393        .into_any_element()
1394    }
1395}
1396
1397/// The icon of a row: a folder, a text-like file, or any other file.
1398fn icon_of(entry: &FileEntry) -> IconName {
1399    const TEXT: &[&str] = &[
1400        "txt", "md", "rs", "toml", "json", "yaml", "yml", "js", "ts", "py", "go", "c", "h", "cpp",
1401        "css", "html", "sh", "lock", "csv", "log",
1402    ];
1403    if entry.is_folder() {
1404        IconName::Folder
1405    } else if entry
1406        .extension()
1407        .is_some_and(|extension| TEXT.contains(&extension.as_str()))
1408    {
1409        IconName::FileText
1410    } else {
1411        IconName::File
1412    }
1413}
1414
1415/// A size as a row shows it: bytes below a thousand, then kB, MB, and GB,
1416/// with one decimal below ten.
1417fn size_text(bytes: u64) -> String {
1418    const UNITS: [&str; 4] = ["kB", "MB", "GB", "TB"];
1419    if bytes < 1000 {
1420        return format!("{bytes} B");
1421    }
1422    let mut value = bytes as f64 / 1000.;
1423    let mut unit = 0;
1424    while value >= 1000. && unit + 1 < UNITS.len() {
1425        value /= 1000.;
1426        unit += 1;
1427    }
1428    if value < 10. {
1429        format!("{value:.1} {}", UNITS[unit])
1430    } else {
1431        format!("{value:.0} {}", UNITS[unit])
1432    }
1433}
1434
1435#[cfg(test)]
1436mod tests {
1437    use super::*;
1438
1439    #[test]
1440    fn sizes_read_in_decimal_units() {
1441        assert_eq!(size_text(0), "0 B");
1442        assert_eq!(size_text(999), "999 B");
1443        assert_eq!(size_text(1500), "1.5 kB");
1444        assert_eq!(size_text(48_000), "48 kB");
1445        assert_eq!(size_text(2_400_000), "2.4 MB");
1446    }
1447
1448    #[test]
1449    fn a_filter_keeps_folders_and_matches_extensions_without_case() {
1450        let filter = FileFilter::new("Rust", [".RS", "toml"]);
1451        assert!(filter.matches(&FileEntry::folder("src")));
1452        assert!(filter.matches(&FileEntry::file("Main.rs")));
1453        assert!(filter.matches(&FileEntry::file("Cargo.toml")));
1454        assert!(!filter.matches(&FileEntry::file("notes.txt")));
1455        assert!(!filter.matches(&FileEntry::file("Makefile")));
1456        assert!(FileFilter::new("All", Vec::<String>::new()).matches(&FileEntry::file("a.b")));
1457    }
1458}