1#[cfg(feature = "file-picker")]
27use gpui::prelude::*;
28#[cfg(feature = "file-picker")]
29use gpui::*;
30
31#[cfg(feature = "file-picker")]
32use crate::theme::{get_theme_or, Theme};
33use super::focus_navigation::{FocusNext, FocusPrev, EnabledCursorExt};
34#[cfg(feature = "file-picker")]
35use crate::utils::path::{parse_path, PathInfo};
36#[cfg(feature = "file-picker")]
37use crate::widgets::{TextInput, TextInputEvent, Tooltip};
38#[cfg(feature = "file-picker")]
39use super::path_display::PathDisplayInfo;
40
41#[cfg(feature = "file-picker")]
43actions!(
44 ccf_directory_picker,
45 [
46 BrowseDirectory,
47 ActivateButton,
48 ]
49);
50
51#[cfg(feature = "file-picker")]
58pub fn register_keybindings(cx: &mut App) {
59 #[cfg(target_os = "macos")]
61 cx.bind_keys([
62 KeyBinding::new("cmd-o", BrowseDirectory, Some("CcfDirectoryPicker")),
63 ]);
64
65 #[cfg(not(target_os = "macos"))]
66 cx.bind_keys([
67 KeyBinding::new("ctrl-o", BrowseDirectory, Some("CcfDirectoryPicker")),
68 ]);
69
70 cx.bind_keys([
72 KeyBinding::new("enter", ActivateButton, Some("CcfDirectoryPickerButton")),
73 KeyBinding::new("space", ActivateButton, Some("CcfDirectoryPickerButton")),
74 ]);
75}
76
77#[derive(Clone, Debug)]
79pub enum DirectoryPickerEvent {
80 Change(String),
82}
83
84#[derive(Clone, Debug, PartialEq, Eq)]
86pub enum DirectoryPickerValidation {
87 Empty,
89 Valid,
91 PathDoesNotExist,
93 IsFile,
95}
96
97pub use super::path_display::ValidationDisplay;
99
100pub fn validate_directory_path(path: &str) -> DirectoryPickerValidation {
105 use std::path::Path;
106
107 if path.is_empty() {
108 return DirectoryPickerValidation::Empty;
109 }
110
111 let path = Path::new(path);
112
113 if path.is_dir() {
114 DirectoryPickerValidation::Valid
115 } else if path.is_file() {
116 DirectoryPickerValidation::IsFile
117 } else {
118 DirectoryPickerValidation::PathDoesNotExist
119 }
120}
121
122
123#[cfg(feature = "file-picker")]
125pub struct DirectoryPicker {
126 value: String,
127 placeholder: Option<SharedString>,
128 focus_handle: FocusHandle,
129 edit_button_focus_handle: FocusHandle,
130 browse_button_focus_handle: FocusHandle,
131 is_editing: bool,
132 edit_state: Option<Entity<TextInput>>,
133 custom_theme: Option<Theme>,
134 pending_refocus: bool,
136 pending_focus: bool,
138 ignore_next_activation: bool,
140 browse_shortcut_enabled: bool,
142 validation_display: ValidationDisplay,
144 enabled: bool,
146}
147
148#[cfg(feature = "file-picker")]
149impl EventEmitter<DirectoryPickerEvent> for DirectoryPicker {}
150
151#[cfg(feature = "file-picker")]
152impl Focusable for DirectoryPicker {
153 fn focus_handle(&self, _cx: &App) -> FocusHandle {
154 self.focus_handle.clone()
155 }
156}
157
158#[cfg(feature = "file-picker")]
159impl DirectoryPicker {
160 pub fn new(cx: &mut Context<Self>) -> Self {
162 Self {
163 value: String::new(),
164 placeholder: None,
165 focus_handle: cx.focus_handle(),
166 edit_button_focus_handle: cx.focus_handle().tab_stop(true),
167 browse_button_focus_handle: cx.focus_handle().tab_stop(true),
168 is_editing: false,
169 edit_state: None,
170 custom_theme: None,
171 pending_refocus: false,
172 pending_focus: false,
173 ignore_next_activation: false,
174 browse_shortcut_enabled: true,
175 validation_display: ValidationDisplay::default(),
176 enabled: true,
177 }
178 }
179
180 #[must_use]
182 pub fn with_value(mut self, path: impl Into<String>) -> Self {
183 self.value = path.into();
184 self
185 }
186
187 #[must_use]
189 pub fn placeholder(mut self, text: impl Into<SharedString>) -> Self {
190 self.placeholder = Some(text.into());
191 self
192 }
193
194 #[must_use]
196 pub fn theme(mut self, theme: Theme) -> Self {
197 self.custom_theme = Some(theme);
198 self
199 }
200
201 #[must_use]
206 pub fn browse_shortcut(mut self, enabled: bool) -> Self {
207 self.browse_shortcut_enabled = enabled;
208 self
209 }
210
211 #[must_use]
216 pub fn validation_display(mut self, display: ValidationDisplay) -> Self {
217 self.validation_display = display;
218 self
219 }
220
221 #[must_use]
226 pub fn with_enabled(mut self, enabled: bool) -> Self {
227 self.enabled = enabled;
228 self
229 }
230
231 pub fn value(&self) -> &str {
233 &self.value
234 }
235
236 pub fn set_value(&mut self, path: &str, cx: &mut Context<Self>) {
238 if self.value != path {
239 self.value = path.to_string();
240 cx.emit(DirectoryPickerEvent::Change(self.value.clone()));
241 cx.notify();
242 }
243 }
244
245 pub fn focus_handle(&self) -> &FocusHandle {
247 &self.focus_handle
248 }
249
250 pub fn focus(&mut self, cx: &mut Context<Self>) {
255 self.pending_focus = true;
256 cx.notify();
257 }
258
259 pub fn validate(&self) -> DirectoryPickerValidation {
263 validate_directory_path(&self.value)
264 }
265
266 pub fn is_enabled(&self) -> bool {
268 self.enabled
269 }
270
271 pub fn set_enabled(&mut self, enabled: bool, cx: &mut Context<Self>) {
273 if self.enabled != enabled {
274 self.enabled = enabled;
275 cx.notify();
276 }
277 }
278
279 pub fn is_valid(&self) -> bool {
281 self.validate() == DirectoryPickerValidation::Valid
282 }
283
284 fn compute_path_display(&self, path_info: &PathInfo, theme: &Theme, validation_display: &ValidationDisplay) -> PathDisplayInfo {
285 let mut info = PathDisplayInfo::new();
286
287 if path_info.full_path.as_os_str().is_empty() {
288 return info;
289 }
290
291 let show_colors = matches!(validation_display, ValidationDisplay::Full | ValidationDisplay::ColorsOnly);
293 let show_message = matches!(validation_display, ValidationDisplay::Full | ValidationDisplay::MessageOnly);
294
295 let color_or_muted = |color: u32| -> u32 {
297 if show_colors { color } else { theme.text_muted }
298 };
299
300 if path_info.fully_exists() {
301 info.add_segment(&path_info.existing_canonical.to_string_lossy(), theme.text_muted);
302 } else {
303 info.add_segment(&path_info.existing_canonical.to_string_lossy(), theme.text_muted);
304 let non_existing = path_info.non_existing_suffix.to_string_lossy();
305 if !non_existing.is_empty() {
306 info.add_path_prefix(&non_existing, color_or_muted(theme.error));
307 if show_message {
308 info.set_explanation("path does not exist", theme.error);
309 }
310 }
311 }
312
313 info
314 }
315
316 fn start_editing(&mut self, window: &mut Window, cx: &mut Context<Self>) {
317 if !self.enabled {
318 return;
319 }
320 self.is_editing = true;
321
322 let value = self.value.clone();
323 let edit_state = cx.new(|cx| {
324 TextInput::new(cx)
325 .with_value(value)
326 .select_on_focus(true)
327 });
328
329 cx.subscribe(&edit_state, |this, edit_state, event: &TextInputEvent, cx| {
330 match event {
331 TextInputEvent::Enter | TextInputEvent::Blur => {
332 this.is_editing = false;
333 let text = edit_state.read(cx).content().to_string();
334 let path_info = parse_path(&text);
335 let new_value = path_info.full_path_string();
336 if this.value != new_value {
337 this.value = new_value;
338 cx.emit(DirectoryPickerEvent::Change(this.value.clone()));
339 }
340 cx.notify();
341 }
342 TextInputEvent::Escape => {
343 this.is_editing = false;
345 this.pending_refocus = true;
346 cx.notify();
347 }
348 _ => {}
349 }
350 }).detach();
351
352 self.edit_state = Some(edit_state.clone());
353 edit_state.read(cx).focus_handle().focus(window);
354 }
355
356 fn open_directory_dialog(&mut self, window: &mut Window, cx: &mut Context<Self>) {
357 if !self.enabled {
358 return;
359 }
360 let entity = cx.entity().clone();
361
362 let initial_dir = if !self.value.is_empty() {
363 let path = std::path::Path::new(&self.value);
364 if path.exists() {
365 Some(path.to_path_buf())
366 } else {
367 path.ancestors()
368 .find(|p| p.exists() && !p.as_os_str().is_empty())
369 .map(|p| p.to_path_buf())
370 }
371 } else {
372 None
373 }.or_else(|| std::env::current_dir().ok());
374
375 window.spawn(cx, async move |async_cx| {
376 let result = async_cx.background_executor().spawn(async move {
377 let mut dialog = rfd::AsyncFileDialog::new();
378
379 if let Some(dir) = initial_dir {
380 dialog = dialog.set_directory(&dir);
381 }
382
383 dialog.pick_folder().await
384 }).await;
385
386 if let Some(folder) = result {
387 let path = folder.path().to_string_lossy().to_string();
388 let _ = async_cx.update_entity(&entity, |this: &mut DirectoryPicker, cx| {
389 if this.value != path {
390 this.value = path;
391 cx.emit(DirectoryPickerEvent::Change(this.value.clone()));
392 }
393 cx.notify();
394 });
395 }
396 }).detach();
397 }
398}
399
400#[cfg(feature = "file-picker")]
401impl Render for DirectoryPicker {
402 fn render(&mut self, window: &mut Window, cx: &mut Context<'_, Self>) -> impl IntoElement {
403 let theme = get_theme_or(cx, self.custom_theme.as_ref());
404
405 if self.pending_refocus {
407 self.pending_refocus = false;
408 self.edit_button_focus_handle.focus(window);
409 }
410
411 if self.pending_focus {
413 self.pending_focus = false;
414 self.ignore_next_activation = true;
415 self.edit_button_focus_handle.focus(window);
416 }
417
418 if self.is_editing {
420 if let Some(edit_state) = &self.edit_state {
421 if !edit_state.read(cx).focus_handle().is_focused(window) {
422 self.is_editing = false;
423 let text = edit_state.read(cx).content().to_string();
424 let path_info = parse_path(&text);
425 self.value = path_info.full_path_string();
426 }
427 }
428 }
429
430 let path_info = if self.value.is_empty() {
431 PathInfo::empty()
432 } else {
433 parse_path(&self.value)
434 };
435
436 let path_display = self.compute_path_display(&path_info, &theme, &self.validation_display);
437
438 let dirname = if !self.value.is_empty() {
439 std::path::Path::new(&self.value)
440 .file_name()
441 .and_then(|n| n.to_str())
442 .map(|s| s.to_string())
443 } else {
444 None
445 };
446
447 let placeholder = self.placeholder.clone()
448 .unwrap_or_else(|| SharedString::from("Click to enter path, or drag & drop"));
449
450 let browse_shortcut_enabled = self.browse_shortcut_enabled;
451 let enabled = self.enabled;
452 let edit_button_focus_handle = self.edit_button_focus_handle.clone();
453 let edit_button_is_focused = edit_button_focus_handle.is_focused(window);
454 let browse_button_focus_handle = self.browse_button_focus_handle.clone();
455 let browse_button_is_focused = browse_button_focus_handle.is_focused(window);
456
457 div()
458 .id("ccf_directory_picker")
459 .key_context("CcfDirectoryPicker")
460 .flex()
461 .flex_row()
462 .w_full()
463 .when(enabled, |d| d.bg(rgb(theme.bg_input)))
464 .when(!enabled, |d| d.bg(rgb(theme.disabled_bg)))
465 .rounded_md()
466 .border_1()
467 .border_color(rgb(theme.border_default))
468 .when(browse_shortcut_enabled && enabled, |d| {
470 d.on_action(cx.listener(|picker, _: &BrowseDirectory, window, cx| {
471 picker.open_directory_dialog(window, cx);
472 }))
473 })
474 .when(enabled, |d| {
475 d.drag_over::<ExternalPaths>({
476 let bg_hover = theme.bg_input_hover;
477 let border = theme.border_focus;
478 move |d, _, _, _| {
479 d.bg(rgb(bg_hover))
480 .border_color(rgb(border))
481 }
482 })
483 })
484 .child(
485 div()
487 .id("ccf_directory_picker_field")
488 .flex()
489 .flex_col()
490 .flex_1()
491 .min_w_0()
492 .min_h(px(52.))
493 .px_3()
494 .py_2()
495 .when(enabled, |d| {
496 d.on_drop(cx.listener(|picker, paths: &ExternalPaths, _window, cx| {
497 if let Some(path) = paths.paths().first() {
498 let dir_path = if path.is_dir() {
499 path.to_string_lossy().to_string()
500 } else if path.is_file() {
501 path.parent()
502 .map(|p| p.to_string_lossy().to_string())
503 .unwrap_or_default()
504 } else {
505 String::new()
506 };
507
508 if !dir_path.is_empty() && picker.value != dir_path {
509 picker.value = dir_path;
510 cx.emit(DirectoryPickerEvent::Change(picker.value.clone()));
511 cx.notify();
512 }
513 }
514 }))
515 })
516 .when(!self.is_editing && self.value.is_empty() && enabled, |d| {
518 d.on_click(cx.listener(|picker, _event, window, cx| {
519 picker.start_editing(window, cx);
520 cx.notify();
521 }))
522 .cursor_pointer()
523 .hover(|d| d.bg(rgb(theme.bg_input_hover)))
524 .child(
525 div()
526 .text_sm()
527 .italic()
528 .text_color(rgb(theme.text_dimmed))
529 .child("No directory selected")
530 )
531 .child(
532 div()
533 .text_xs()
534 .italic()
535 .text_color(rgb(theme.text_dimmed))
536 .line_height(relative(1.4))
537 .child(placeholder.clone())
538 )
539 })
540 .when(!self.is_editing && self.value.is_empty() && !enabled, |d| {
542 d.cursor_default()
543 .child(
544 div()
545 .text_sm()
546 .italic()
547 .text_color(rgb(theme.disabled_text))
548 .child("No directory selected")
549 )
550 .child(
551 div()
552 .text_xs()
553 .italic()
554 .text_color(rgb(theme.disabled_text))
555 .line_height(relative(1.4))
556 .child(placeholder.clone())
557 )
558 })
559 .when(!self.is_editing && !self.value.is_empty() && enabled, |d| {
561 d.on_click(cx.listener(|picker, _event, window, cx| {
562 picker.start_editing(window, cx);
563 cx.notify();
564 }))
565 .cursor_pointer()
566 .hover(|d| d.bg(rgb(theme.bg_input_hover)))
567 .child(
568 div()
569 .text_sm()
570 .font_weight(FontWeight::SEMIBOLD)
571 .text_color(rgb(theme.text_label))
572 .child(dirname.clone().unwrap_or_default())
573 )
574 .when(!path_display.is_empty(), |d| {
575 d.child(
576 div()
577 .text_xs()
578 .min_w_0()
579 .line_height(relative(1.4))
580 .child(path_display.to_styled_text())
581 )
582 })
583 .when_some(path_display.explanation.clone(), |d, (msg, color)| {
584 d.child(
585 div()
586 .text_xs()
587 .italic()
588 .text_color(rgb(color))
589 .mt_1()
590 .child(msg)
591 )
592 })
593 })
594 .when(!self.is_editing && !self.value.is_empty() && !enabled, |d| {
596 d.cursor_default()
597 .child(
598 div()
599 .text_sm()
600 .font_weight(FontWeight::SEMIBOLD)
601 .text_color(rgb(theme.disabled_text))
602 .child(dirname.clone().unwrap_or_default())
603 )
604 .when(!path_display.is_empty(), |d| {
605 d.child(
606 div()
607 .text_xs()
608 .min_w_0()
609 .line_height(relative(1.4))
610 .text_color(rgb(theme.disabled_text))
611 .child(self.value.clone())
612 )
613 })
614 })
615 .when(self.is_editing && self.edit_state.is_some(), |d| {
617 let Some(edit_state) = self.edit_state.as_ref() else { return d };
618 let edit_text = edit_state.read(cx).content().to_string();
619 let edit_path_info = if edit_text.is_empty() {
620 PathInfo::empty()
621 } else {
622 parse_path(&edit_text)
623 };
624 let edit_display = self.compute_path_display(&edit_path_info, &theme, &self.validation_display);
625
626 d.child(
627 div()
628 .flex()
629 .flex_col()
630 .gap_1()
631 .child(edit_state.clone())
632 .child(
633 div()
634 .text_xs()
635 .min_w_0()
636 .line_height(relative(1.4))
637 .when(!edit_display.is_empty(), |d| {
638 d.child(edit_display.to_styled_text())
639 })
640 .when(edit_display.is_empty(), |d| {
641 d.text_color(rgb(theme.text_dimmed))
642 .child("(empty path)")
643 })
644 )
645 .when_some(edit_display.explanation.clone(), |d, (msg, color)| {
646 d.child(
647 div()
648 .text_xs()
649 .italic()
650 .text_color(rgb(color))
651 .child(msg)
652 )
653 })
654 )
655 })
656 )
657 .child(
658 div()
660 .flex()
661 .flex_col()
662 .border_l_1()
663 .border_color(rgb(theme.border_default))
664 .child(
665 div()
667 .id("ccf_directory_edit_button")
668 .flex_1()
669 .flex()
670 .items_center()
671 .justify_center()
672 .key_context("CcfDirectoryPickerButton")
673 .track_focus(&edit_button_focus_handle)
674 .tab_stop(enabled)
675 .px_2()
676 .when(enabled, |d| d.bg(rgb(theme.bg_input_hover)))
677 .when(!enabled, |d| d.bg(rgb(theme.disabled_bg)))
678 .border_1()
679 .when(enabled, |d| {
680 d.border_color(rgb(if edit_button_is_focused {
681 theme.border_focus
682 } else {
683 theme.bg_input_hover }))
685 })
686 .when(!enabled, |d| d.border_color(rgb(theme.disabled_bg)))
687 .cursor_for_enabled(enabled)
688 .when(enabled, |d| d.hover(|d| d.bg(rgb(theme.bg_hover))))
689 .when(enabled, |d| {
690 d.on_click(cx.listener(|picker, _event, window, cx| {
691 if picker.ignore_next_activation {
693 picker.ignore_next_activation = false;
694 return;
695 }
696 picker.start_editing(window, cx);
697 cx.notify();
698 }))
699 .on_action(cx.listener(|picker, _: &ActivateButton, window, cx| {
700 if picker.ignore_next_activation {
702 picker.ignore_next_activation = false;
703 return;
704 }
705 picker.start_editing(window, cx);
706 cx.notify();
707 }))
708 })
709 .on_action(cx.listener(|_this, _: &FocusNext, window, _cx| {
710 window.focus_next();
711 }))
712 .on_action(cx.listener(|_this, _: &FocusPrev, window, _cx| {
713 window.focus_prev();
714 }))
715 .on_key_down(cx.listener(|_picker, event: &KeyDownEvent, window, _cx| {
716 if event.keystroke.key == "tab" {
717 if event.keystroke.modifiers.shift {
718 window.focus_prev();
719 } else {
720 window.focus_next();
721 }
722 }
723 }))
724 .when(enabled, |d| {
725 d.tooltip(|_window, cx| cx.new(|_cx| Tooltip::new("Edit path")).into())
726 })
727 .child(
728 div()
729 .text_sm()
730 .when(enabled, |d| d.text_color(rgb(theme.text_label)))
731 .when(!enabled, |d| d.text_color(rgb(theme.disabled_text)))
732 .child("✎")
733 )
734 )
735 .child(
736 div()
738 .h(px(1.))
739 .bg(rgb(theme.border_default))
740 )
741 .child(
742 div()
744 .id("ccf_directory_browse_button")
745 .flex_1()
746 .flex()
747 .items_center()
748 .justify_center()
749 .key_context("CcfDirectoryPickerButton")
750 .track_focus(&browse_button_focus_handle)
751 .tab_stop(enabled)
752 .px_2()
753 .when(enabled, |d| d.bg(rgb(theme.bg_input_hover)))
754 .when(!enabled, |d| d.bg(rgb(theme.disabled_bg)))
755 .border_1()
756 .when(enabled, |d| {
757 d.border_color(rgb(if browse_button_is_focused {
758 theme.border_focus
759 } else {
760 theme.bg_input_hover }))
762 })
763 .when(!enabled, |d| d.border_color(rgb(theme.disabled_bg)))
764 .cursor_for_enabled(enabled)
765 .when(enabled, |d| d.hover(|d| d.bg(rgb(theme.bg_hover))))
766 .when(enabled, |d| {
767 d.on_click(cx.listener(|picker, _event, window, cx| {
768 picker.open_directory_dialog(window, cx);
769 }))
770 .on_action(cx.listener(|picker, _: &ActivateButton, window, cx| {
771 picker.open_directory_dialog(window, cx);
772 }))
773 })
774 .on_action(cx.listener(|_this, _: &FocusNext, window, _cx| {
775 window.focus_next();
776 }))
777 .on_action(cx.listener(|_this, _: &FocusPrev, window, _cx| {
778 window.focus_prev();
779 }))
780 .on_key_down(cx.listener(|_picker, event: &KeyDownEvent, window, _cx| {
781 if event.keystroke.key == "tab" {
782 if event.keystroke.modifiers.shift {
783 window.focus_prev();
784 } else {
785 window.focus_next();
786 }
787 }
788 }))
789 .when(enabled, |d| {
790 d.tooltip(|_window, cx| cx.new(|_cx| Tooltip::new("Select directory...")).into())
791 })
792 .child(
793 div()
794 .text_sm()
795 .when(enabled, |d| d.text_color(rgb(theme.text_label)))
796 .when(!enabled, |d| d.text_color(rgb(theme.disabled_text)))
797 .child("📂")
798 )
799 )
800 )
801 }
802}
803
804#[cfg(test)]
805mod tests {
806 use super::{validate_directory_path, DirectoryPickerValidation};
807 use std::fs::{self, File};
808 use std::io::Write;
809 use tempfile::TempDir;
810
811 fn setup_test_dir() -> TempDir {
812 let dir = TempDir::new().unwrap();
813 let file_path = dir.path().join("test_file.txt");
815 let mut file = File::create(&file_path).unwrap();
816 writeln!(file, "test content").unwrap();
817 fs::create_dir(dir.path().join("subdir")).unwrap();
819 dir
820 }
821
822 #[test]
823 fn test_validate_empty_path() {
824 assert_eq!(
825 validate_directory_path(""),
826 DirectoryPickerValidation::Empty
827 );
828 }
829
830 #[test]
831 fn test_validate_existing_directory() {
832 let dir = setup_test_dir();
833 let subdir_path = dir.path().join("subdir");
834
835 assert_eq!(
836 validate_directory_path(subdir_path.to_str().unwrap()),
837 DirectoryPickerValidation::Valid
838 );
839 }
840
841 #[test]
842 fn test_validate_root_directory() {
843 let dir = setup_test_dir();
844
845 assert_eq!(
846 validate_directory_path(dir.path().to_str().unwrap()),
847 DirectoryPickerValidation::Valid
848 );
849 }
850
851 #[test]
852 fn test_validate_non_existing_path() {
853 let dir = setup_test_dir();
854 let missing_path = dir.path().join("missing_dir");
855
856 assert_eq!(
857 validate_directory_path(missing_path.to_str().unwrap()),
858 DirectoryPickerValidation::PathDoesNotExist
859 );
860 }
861
862 #[test]
863 fn test_validate_file_instead_of_directory() {
864 let dir = setup_test_dir();
865 let file_path = dir.path().join("test_file.txt");
866
867 assert_eq!(
868 validate_directory_path(file_path.to_str().unwrap()),
869 DirectoryPickerValidation::IsFile
870 );
871 }
872}