1mod 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
42const CONTEXT: &str = "GpuiCnFilePicker";
45
46actions!(
47 gpui_cn_file_picker,
48 [
49 SelectAllFiles
51 ]
52);
53
54const 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 cx.bind_keys([KeyBinding::new(
67 SELECT_ALL_KEY,
68 SelectAllFiles,
69 Some("GpuiCnFilePicker > Input"),
70 )]);
71}
72
73pub type FileLoaded = path_browser::Loaded<FileEntry>;
75
76pub type FileListing = path_browser::Listing<FileEntry>;
78
79#[derive(Clone, Debug, PartialEq, Eq)]
82#[non_exhaustive]
83pub struct FileFilter {
84 label: SharedString,
85 extensions: Vec<String>,
86}
87
88impl FileFilter {
89 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 pub fn label(&self) -> &SharedString {
110 &self.label
111 }
112
113 pub fn extensions(&self) -> &[String] {
115 &self.extensions
116 }
117
118 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#[derive(Clone, Debug, PartialEq, Eq)]
132#[non_exhaustive]
133pub enum FilePickerEvent {
134 Confirmed(Vec<SourcePath>),
136 Cancelled,
138 SelectionChanged,
140 FolderCreated(SourcePath),
144}
145
146pub 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 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
201impl 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 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 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 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 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 pub fn with_multiple(mut self, multiple: bool) -> Self {
290 self.multiple = multiple;
291 self
292 }
293
294 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 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 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 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 pub fn retry(&mut self, cx: &mut Context<Self>) {
351 self.browser.retry(cx);
352 self.sync_selection(cx);
353 }
354
355 pub fn failure(&self) -> Option<&crate::ListError> {
357 self.browser.failure()
358 }
359
360 pub fn allow_new_folder(mut self, allow: bool) -> Self {
365 self.browser.set_allow_new_folder_seed(allow);
366 self
367 }
368
369 pub fn allows_new_folder(&self) -> bool {
371 self.browser.allows_new_folder()
372 }
373
374 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 pub fn can_create_folder(&self) -> bool {
381 self.browser.can_create_folder()
382 }
383
384 pub fn is_naming_folder(&self) -> bool {
386 self.browser.new_folder.is_some()
387 }
388
389 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 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 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 pub fn input(&self) -> &Entity<gpui_kit::base::input::InputState> {
429 &self.browser.input
430 }
431
432 pub fn dir(&self) -> &SourcePath {
434 &self.browser.dir
435 }
436
437 pub fn query(&self) -> &str {
439 &self.browser.query
440 }
441
442 pub fn text(&self, cx: &App) -> SharedString {
444 self.browser.input.read(cx).value()
445 }
446
447 pub fn listing(&self) -> Option<&FileListing> {
449 self.browser.listing()
450 }
451
452 pub fn row_count(&self) -> usize {
454 self.browser.row_count()
455 }
456
457 pub fn highlighted(&self) -> Option<SharedString> {
459 self.browser
460 .entry_at(self.browser.highlighted?)
461 .map(|(entry, _)| entry.name().clone())
462 }
463
464 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 pub fn multiple(&self) -> bool {
474 self.multiple
475 }
476
477 pub fn filters(&self) -> &[FileFilter] {
479 &self.filters
480 }
481
482 pub fn active_filter_index(&self) -> usize {
484 self.active_filter
485 }
486
487 pub fn active_filter(&self) -> Option<&FileFilter> {
489 self.filters.get(self.active_filter)
490 }
491
492 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 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 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 pub fn show_hidden(&self) -> bool {
536 self.show_hidden
537 }
538
539 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 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 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 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 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 pub fn clear_selection(&mut self, cx: &mut Context<Self>) {
611 self.set_selection(HashSet::new(), None, cx);
612 }
613
614 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 pub fn cancel(&mut self, cx: &mut Context<Self>) {
627 self.browser.stop_requests(cx);
628 cx.emit(FilePickerEvent::Cancelled);
629 }
630
631 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 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 pub fn load_more(&mut self, cx: &mut Context<Self>) {
647 self.browser.load_more(cx);
648 }
649
650 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 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 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
755struct 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#[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 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 pub fn open(mut self, open: bool) -> Self {
850 self.open = open;
851 self
852 }
853
854 pub fn title(mut self, title: impl Into<SharedString>) -> Self {
857 self.title = Some(title.into());
858 self
859 }
860
861 pub fn confirm_label(mut self, label: impl Into<SharedString>) -> Self {
864 self.confirm_label = Some(label.into());
865 self
866 }
867
868 pub fn cancel_label(mut self, label: impl Into<SharedString>) -> Self {
870 self.cancel_label = label.into();
871 self
872 }
873
874 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
1182enum 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#[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
1293struct 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
1397fn 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
1415fn 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}