1mod source;
12
13use std::rc::Rc;
14
15use gpui_kit::{
16 App, Context, ElementId, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement as _,
17 IntoElement, ParentElement as _, RenderOnce, Role, SharedString,
18 StatefulInteractiveElement as _, Styled as _, Subscription, Window,
19 assets::IconName,
20 base::{
21 Disableable as _, TestSupportExt as _,
22 actions::{Confirm, SelectDown, SelectUp},
23 h_flex,
24 },
25 div,
26 prelude::FluentBuilder as _,
27};
28
29pub use crate::path_browser::{MoreState, PageToken};
30pub use source::{FolderEntry, FolderPage, FolderSource, LocalFolders, MemoryFolders};
31
32use crate::{
33 ActiveTheme as _, Button, Dialog, Icon, ScrollArea, Theme,
34 menu::MenuLook,
35 path_browser::{
36 self, Body, Browser, CreateFolderError, Host, NewFolder, SourcePath, Submit, view,
37 },
38};
39
40const CONTEXT: &str = "GpuiCnFolderPicker";
43
44const DEFAULT_TITLE: &str = "Choose a folder";
45
46pub(crate) fn init(cx: &mut App) {
47 path_browser::bind_keys(CONTEXT, cx);
48}
49
50pub type Loaded = path_browser::Loaded<FolderEntry>;
52
53pub type Listing = path_browser::Listing<FolderEntry>;
55
56#[derive(Clone, Debug, PartialEq, Eq)]
58#[non_exhaustive]
59pub enum FolderPickerEvent {
60 Chosen(SourcePath),
63 Cancelled,
65 FolderCreated(SourcePath),
69}
70
71pub struct FolderPickerState {
90 browser: Browser<FolderEntry>,
91 _subscription: Subscription,
92}
93
94impl Host<FolderEntry> for FolderPickerState {
95 fn browser(&self) -> &Browser<FolderEntry> {
96 &self.browser
97 }
98
99 fn browser_mut(&mut self) -> &mut Browser<FolderEntry> {
100 &mut self.browser
101 }
102
103 fn folder_created(&mut self, path: SourcePath, window: &mut Window, cx: &mut Context<Self>) {
104 if path.parent().as_ref() == Some(&self.browser.dir) {
105 let text = format!(
106 "{}{}",
107 self.browser.dir_text,
108 path.file_name().unwrap_or_default()
109 );
110 self.browser.set_text(&text, window, cx);
111 self.browser.focus_path(window, cx);
112 }
113 cx.emit(FolderPickerEvent::FolderCreated(path));
114 }
115}
116
117impl EventEmitter<FolderPickerEvent> for FolderPickerState {}
118
119impl Focusable for FolderPickerState {
121 fn focus_handle(&self, cx: &App) -> FocusHandle {
122 self.browser.input.read(cx).focus_handle(cx)
123 }
124}
125
126impl FolderPickerState {
127 pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
131 let mut browser = Browser::new(Rc::new(source::Adapter(Rc::new(LocalFolders))), window, cx);
132 let subscription = browser.watch(window, cx);
133 let start = browser.default_start();
134 browser.set_text(&start, window, cx);
135 Self {
136 browser,
137 _subscription: subscription,
138 }
139 }
140
141 pub fn with_source(
148 mut self,
149 source: impl FolderSource,
150 window: &mut Window,
151 cx: &mut Context<Self>,
152 ) -> Self {
153 self.set_source(source, window, cx);
154 self
155 }
156
157 pub fn set_source(
163 &mut self,
164 source: impl FolderSource,
165 window: &mut Window,
166 cx: &mut Context<Self>,
167 ) {
168 self.browser
169 .set_source(Rc::new(source::Adapter(Rc::new(source))), window, cx);
170 cx.notify();
171 }
172
173 pub fn with_initial(
177 mut self,
178 path: impl AsRef<str>,
179 window: &mut Window,
180 cx: &mut Context<Self>,
181 ) -> Self {
182 self.browser.start_in(path.as_ref(), window, cx);
183 self
184 }
185
186 pub fn on_auth_required(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
194 self.browser.set_auth_handler(Rc::new(handler));
195 self
196 }
197
198 pub fn retry(&mut self, cx: &mut Context<Self>) {
202 self.browser.retry(cx);
203 }
204
205 pub fn failure(&self) -> Option<&crate::ListError> {
207 self.browser.failure()
208 }
209
210 pub fn allow_new_folder(mut self, allow: bool) -> Self {
216 self.browser.set_allow_new_folder_seed(allow);
217 self
218 }
219
220 pub fn allows_new_folder(&self) -> bool {
222 self.browser.allows_new_folder()
223 }
224
225 pub fn set_allow_new_folder(&mut self, allow: bool, cx: &mut Context<Self>) {
227 self.browser.set_allow_new_folder(allow, cx);
228 }
229
230 pub fn can_create_folder(&self) -> bool {
232 self.browser.can_create_folder()
233 }
234
235 pub fn is_naming_folder(&self) -> bool {
237 self.browser.new_folder.is_some()
238 }
239
240 pub fn new_folder_name(&self, cx: &App) -> Option<SharedString> {
243 self.browser
244 .new_folder
245 .as_ref()
246 .map(|row| row.input.read(cx).value())
247 }
248
249 pub fn begin_new_folder(&mut self, window: &mut Window, cx: &mut Context<Self>) {
254 self.browser.begin_default_new_folder(window, cx);
255 }
256
257 pub fn create_folder(
270 &mut self,
271 name: &str,
272 window: &mut Window,
273 cx: &mut Context<Self>,
274 ) -> Result<(), CreateFolderError> {
275 self.browser.create_folder(name, window, cx)
276 }
277
278 pub fn input(&self) -> &Entity<gpui_kit::base::input::InputState> {
280 &self.browser.input
281 }
282
283 pub fn dir(&self) -> &SourcePath {
285 &self.browser.dir
286 }
287
288 pub fn query(&self) -> &str {
290 &self.browser.query
291 }
292
293 pub fn text(&self, cx: &App) -> SharedString {
295 self.browser.input.read(cx).value()
296 }
297
298 pub fn listing(&self) -> Option<&Listing> {
300 self.browser.listing()
301 }
302
303 pub fn row_count(&self) -> usize {
306 self.browser.row_count()
307 }
308
309 pub fn highlighted(&self) -> Option<SharedString> {
311 self.browser
312 .entry_at(self.browser.highlighted?)
313 .map(|(entry, _)| entry.name().clone())
314 }
315
316 pub fn selected(&self) -> Option<SourcePath> {
321 let loaded = self.browser.loaded()?;
322 let dir = self.browser.dir.clone();
323 if self.browser.query.is_empty() {
324 Some(dir)
325 } else {
326 let entry = loaded.entry_named(&self.browser.query)?;
327 Some(dir.join(entry.name()))
328 }
329 }
330
331 pub fn choose(&mut self, cx: &mut Context<Self>) {
333 if let Some(path) = self.selected() {
334 self.browser.stop_requests(cx);
335 cx.emit(FolderPickerEvent::Chosen(path));
336 }
337 }
338
339 pub fn cancel(&mut self, cx: &mut Context<Self>) {
343 self.browser.stop_requests(cx);
344 cx.emit(FolderPickerEvent::Cancelled);
345 }
346
347 pub fn go_up(&mut self, window: &mut Window, cx: &mut Context<Self>) {
350 self.browser.go_up(window, cx);
351 }
352
353 pub fn descend(&mut self, name: &str, window: &mut Window, cx: &mut Context<Self>) {
355 self.browser.descend(name, window, cx);
356 }
357
358 pub fn load_more(&mut self, cx: &mut Context<Self>) {
361 self.browser.load_more(cx);
362 }
363
364 fn confirm(&mut self, window: &mut Window, cx: &mut Context<Self>) {
367 if let Some(row) = self.browser.highlighted {
368 self.enter_row(row, window, cx);
369 }
370 }
371
372 fn enter_row(&mut self, row: usize, window: &mut Window, cx: &mut Context<Self>) {
375 if row == self.browser.row_count() && self.browser.has_more_row() {
376 self.browser.load_more(cx);
377 } else if let Some(name) = self
378 .browser
379 .entry_at(row)
380 .map(|(entry, _)| entry.name().clone())
381 {
382 self.browser.descend(&name, window, cx);
383 }
384 }
385}
386
387type CloseHandler = Rc<dyn Fn(&mut Window, &mut App)>;
388type OpenChange = Rc<dyn Fn(bool, &mut Window, &mut App)>;
389
390#[derive(IntoElement)]
411pub struct FolderPicker {
412 id: ElementId,
413 state: Entity<FolderPickerState>,
414 open: bool,
415 title: SharedString,
416 on_open_change: Option<OpenChange>,
417}
418
419impl FolderPicker {
420 pub fn new(id: impl Into<ElementId>, state: &Entity<FolderPickerState>) -> Self {
422 Self {
423 id: id.into(),
424 state: state.clone(),
425 open: false,
426 title: DEFAULT_TITLE.into(),
427 on_open_change: None,
428 }
429 }
430
431 pub fn open(mut self, open: bool) -> Self {
433 self.open = open;
434 self
435 }
436
437 pub fn title(mut self, title: impl Into<SharedString>) -> Self {
439 self.title = title.into();
440 self
441 }
442
443 pub fn on_open_change(
447 mut self,
448 handler: impl Fn(bool, &mut Window, &mut App) + 'static,
449 ) -> Self {
450 self.on_open_change = Some(Rc::new(handler));
451 self
452 }
453}
454
455impl RenderOnce for FolderPicker {
456 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
457 let theme = cx.theme();
458 let look = MenuLook::of(theme, window.rem_size());
459 let list_height = theme.metrics.folder_list_height;
460 let pointer_cursors = Theme::global(cx).pointer_cursors;
461 let state = self.state;
462 let child = |name: &'static str| ElementId::NamedChild(self.id.clone().into(), name.into());
463 if self.open {
464 let state = state.clone();
465 window.defer(cx, move |_, cx| {
466 state.update(cx, |state, cx| state.browser.ensure_listed(cx));
467 });
468 }
469 let (failure, can_sign_in) = {
470 let browser = &state.read(cx).browser;
471 (browser.failure().cloned(), browser.auth_handler().is_some())
472 };
473 let (input, list, body_kind, selected, query_empty, has_entries) = {
474 let state = state.read(cx);
475 let has_entries = state
476 .browser
477 .loaded()
478 .is_some_and(|loaded| !loaded.entries().is_empty());
479 (
480 state.browser.input.clone(),
481 state.browser.list.clone(),
482 state.browser.body(),
483 state.selected(),
484 state.browser.query.is_empty(),
485 has_entries,
486 )
487 };
488 let focus = input.read(cx).focus_handle(cx);
489
490 let close: CloseHandler = {
491 let state = state.clone();
492 let handler = self.on_open_change;
493 Rc::new(move |window, cx| {
494 state.update(cx, |state, cx| state.cancel(cx));
495 if let Some(handler) = &handler {
496 handler(false, window, cx);
497 }
498 })
499 };
500
501 let body = match body_kind {
502 Body::Loading => view::loading(child("loading"), &look),
503 Body::Failed => match failure {
504 Some(error) => view::failure::<FolderEntry, FolderPickerState>(
505 &self.id,
506 &look,
507 &state,
508 &error,
509 can_sign_in,
510 ),
511 None => div().into_any_element(),
512 },
513 Body::Empty => view::message(child("message"), &look, view::EMPTY_FOLDERS),
514 Body::NoMatch => view::message(child("message"), &look, view::NO_MATCH),
515 Body::List => {
516 let rows = Rows {
517 id: self.id.clone(),
518 state: state.clone(),
519 look: look.clone(),
520 pointer_cursors,
521 };
522 let scrolled = state.clone();
523 ScrollArea::list(
524 child("rows"),
525 &list,
526 look.row_height,
527 move |row, window, cx| rows.render(row, window, cx),
528 )
529 .on_scroll(move |_, cx| {
530 scrolled.update(cx, |state, cx| state.browser_mut().rows_scrolled(cx));
531 })
532 .size_full()
533 .into_any_element()
534 }
535 };
536
537 let up = {
538 let state = state.clone();
539 Button::new(child("up"))
540 .ghost()
541 .icon(Icon::from(IconName::ArrowUp))
542 .accessibility_label("Parent folder")
543 .on_click(move |_, window, cx| {
544 state.update(cx, |state, cx| state.go_up(window, cx));
545 })
546 };
547 let current = (query_empty && has_entries).then(|| {
548 let state = state.clone();
549 h_flex()
550 .id(child("current"))
551 .test_support()
552 .role(Role::Button)
553 .size_full()
554 .px(look.row_padding)
555 .items_center()
556 .text_color(look.muted_foreground)
557 .when(pointer_cursors, |this| this.cursor_pointer())
558 .child("Select current folder")
559 .on_click(move |_, _, cx| state.update(cx, |state, cx| state.choose(cx)))
560 .into_any_element()
561 });
562 let top = {
563 let read = state.read(cx);
564 read.browser.new_folder.as_ref().map(|row| {
565 view::new_folder_row::<FolderEntry, FolderPickerState>(
566 &self.id, &look, &state, row, cx,
567 )
568 })
569 };
570 let new_folder = state.read(cx).can_create_folder().then(|| {
571 let ready = state.read(cx).browser.new_folder_ready();
572 let state = state.clone();
573 Button::new(child("new-folder-button"))
574 .ghost()
575 .icon(Icon::from(IconName::Plus))
576 .label("New folder")
577 .disabled(!ready)
578 .on_click(move |_, window, cx| {
579 state.update(cx, |state, cx| state.begin_new_folder(window, cx));
580 })
581 });
582 let (up_key, down_key, confirm_key) = (state.clone(), state.clone(), state.clone());
583 let content = view::stack()
584 .key_context(CONTEXT)
585 .on_action(move |_: &SelectUp, _, cx| {
586 up_key.update(cx, |state, cx| state.browser.move_highlight(-1, cx));
587 })
588 .on_action(move |_: &SelectDown, _, cx| {
589 down_key.update(cx, |state, cx| state.browser.move_highlight(1, cx));
590 })
591 .on_action({
592 let state = state.clone();
593 move |_: &Submit, _, cx| state.update(cx, |state, cx| state.choose(cx))
594 })
595 .on_action({
596 let state = state.clone();
597 move |_: &NewFolder, window, cx| {
598 state.update(cx, |state, cx| state.begin_new_folder(window, cx));
599 }
600 })
601 .on_action(move |_: &Confirm, window, cx| {
602 confirm_key.update(cx, |state, cx| state.confirm(window, cx));
603 })
604 .child(
605 h_flex()
606 .gap_2()
607 .items_center()
608 .child(up)
609 .child(div().flex_1().child(crate::Input::new(&input))),
610 )
611 .child(view::slot(&look, current))
612 .child(view::list_box(child("list"), &look, list_height, top, body));
613
614 let footer = h_flex()
615 .w_full()
616 .justify_between()
617 .child(div().children(new_folder))
618 .child(
619 h_flex()
620 .gap_2()
621 .child({
622 let close = close.clone();
623 Button::new(child("cancel"))
624 .ghost()
625 .label("Cancel")
626 .on_click(move |_, window, cx| close(window, cx))
627 })
628 .child({
629 let state = state.clone();
630 Button::new(child("use"))
631 .primary()
632 .label("Use folder")
633 .disabled(selected.is_none())
634 .on_click(move |_, _, cx| {
635 state.update(cx, |state, cx| state.choose(cx))
636 })
637 }),
638 );
639
640 Dialog::new(self.id.clone())
641 .open(self.open)
642 .title(self.title)
643 .track_focus(&focus)
644 .on_open_change(move |_, window, cx| close(window, cx))
645 .child(content)
646 .footer(footer)
647 }
648}
649
650struct Rows {
653 id: ElementId,
654 state: Entity<FolderPickerState>,
655 look: MenuLook,
656 pointer_cursors: bool,
657}
658
659impl Rows {
660 fn render(&self, row: usize, _: &mut Window, cx: &mut App) -> gpui_kit::AnyElement {
661 let look = &self.look;
662 let (name, ranges, highlighted) = {
663 let state = self.state.read(cx);
664 let browser = &state.browser;
665 if row == browser.row_count() && browser.has_more_row() {
666 return view::more_row::<FolderEntry, FolderPickerState>(
667 &self.id,
668 look,
669 &self.state,
670 view::MoreRow {
671 state: browser.more_state(),
672 error: browser.more_error().cloned(),
673 can_sign_in: browser.auth_handler().is_some(),
674 },
675 browser.highlighted == Some(row),
676 row,
677 self.pointer_cursors,
678 );
679 }
680 let Some((entry, ranges)) = browser.entry_at(row) else {
681 return div().into_any_element();
682 };
683 (
684 entry.name().clone(),
685 ranges.to_vec(),
686 browser.highlighted == Some(row),
687 )
688 };
689 let label = crate::menu::matched_text(name.clone(), &ranges, look);
690 let state = self.state.clone();
691 let hover = self.state.clone();
692 view::row_frame(
693 ElementId::NamedChild(
694 ElementId::NamedChild(self.id.clone().into(), "entry".into()).into(),
695 name,
696 ),
697 look,
698 Role::ListBoxOption,
699 highlighted,
700 false,
701 self.pointer_cursors,
702 )
703 .child(
704 Icon::from(IconName::Folder)
705 .size_4()
706 .flex_shrink_0()
707 .text_color(look.muted_foreground),
708 )
709 .child(
710 div()
711 .flex_1()
712 .min_w_0()
713 .overflow_hidden()
714 .whitespace_nowrap()
715 .child(label),
716 )
717 .when(!look.touch, |this| {
718 this.on_mouse_move(move |_, _, cx| {
719 view::highlight_on_hover::<FolderEntry, FolderPickerState>(&hover, row, cx)
720 })
721 })
722 .on_click(move |_, window, cx| {
723 state.update(cx, |state, cx| state.enter_row(row, window, cx));
724 })
725 .into_any_element()
726 }
727}