1#[cfg(feature = "file-picker")]
29use gpui::prelude::*;
30#[cfg(feature = "file-picker")]
31use gpui::*;
32
33#[cfg(feature = "file-picker")]
34use crate::theme::{get_theme_or, Theme};
35use super::focus_navigation::{FocusNext, FocusPrev, EnabledCursorExt};
36#[cfg(feature = "file-picker")]
37use crate::utils::path::{parse_path, PathInfo};
38#[cfg(feature = "file-picker")]
39use crate::widgets::{TextInput, TextInputEvent, Tooltip};
40#[cfg(feature = "file-picker")]
41use std::path::Path;
42#[cfg(feature = "file-picker")]
43use super::path_display::PathDisplayInfo;
44
45use std::str::FromStr;
46
47#[cfg(feature = "file-picker")]
49actions!(
50 ccf_file_picker,
51 [
52 BrowseFile,
53 ActivateButton,
54 ]
55);
56
57#[cfg(feature = "file-picker")]
64pub fn register_keybindings(cx: &mut App) {
65 #[cfg(target_os = "macos")]
67 cx.bind_keys([
68 KeyBinding::new("cmd-o", BrowseFile, Some("CcfFilePicker")),
69 ]);
70
71 #[cfg(not(target_os = "macos"))]
72 cx.bind_keys([
73 KeyBinding::new("ctrl-o", BrowseFile, Some("CcfFilePicker")),
74 ]);
75
76 cx.bind_keys([
78 KeyBinding::new("enter", ActivateButton, Some("CcfFilePickerButton")),
79 KeyBinding::new("space", ActivateButton, Some("CcfFilePickerButton")),
80 ]);
81}
82
83#[derive(Clone, PartialEq, Default)]
85pub enum FileMode {
86 #[default]
88 Open,
89 Save,
91}
92
93impl FromStr for FileMode {
94 type Err = std::convert::Infallible;
95
96 fn from_str(s: &str) -> Result<Self, Self::Err> {
97 Ok(match s.to_lowercase().as_str() {
98 "save" => FileMode::Save,
99 _ => FileMode::Open,
100 })
101 }
102}
103
104#[derive(Clone, PartialEq, Default)]
106pub enum MissingDirectories {
107 #[default]
109 Error,
110 Okay,
112 Create,
114}
115
116impl FromStr for MissingDirectories {
117 type Err = std::convert::Infallible;
118
119 fn from_str(s: &str) -> Result<Self, Self::Err> {
120 Ok(match s.to_lowercase().as_str() {
121 "okay" => MissingDirectories::Okay,
122 "create" => MissingDirectories::Create,
123 _ => MissingDirectories::Error,
124 })
125 }
126}
127
128#[derive(Clone, Debug)]
130pub enum FilePickerEvent {
131 Change(String),
133}
134
135#[derive(Clone, Debug, PartialEq, Eq)]
137pub enum FilePickerValidation {
138 Empty,
140 Valid,
142 PathDoesNotExist,
144 IsDirectory,
146 WillOverwrite,
148 ParentDoesNotExist,
150 WillCreatePath,
152}
153
154pub use super::path_display::ValidationDisplay;
156
157pub fn validate_file_path(
162 path: &str,
163 mode: &FileMode,
164 missing_directories: &MissingDirectories,
165) -> FilePickerValidation {
166 use std::path::Path;
167
168 if path.is_empty() {
169 return FilePickerValidation::Empty;
170 }
171
172 let path = Path::new(path);
173
174 if path.is_dir() {
176 return FilePickerValidation::IsDirectory;
177 }
178
179 match mode {
180 FileMode::Open => {
181 if path.is_file() {
182 FilePickerValidation::Valid
183 } else {
184 FilePickerValidation::PathDoesNotExist
185 }
186 }
187 FileMode::Save => {
188 if path.is_file() {
189 FilePickerValidation::WillOverwrite
190 } else {
191 let parent_exists = path.parent().is_some_and(|p| p.exists() || p.as_os_str().is_empty());
193
194 if parent_exists {
195 FilePickerValidation::Valid
196 } else {
197 match missing_directories {
198 MissingDirectories::Error => FilePickerValidation::ParentDoesNotExist,
199 MissingDirectories::Create | MissingDirectories::Okay => {
200 FilePickerValidation::WillCreatePath
201 }
202 }
203 }
204 }
205 }
206 }
207}
208
209
210#[cfg(feature = "file-picker")]
212pub struct FilePicker {
213 value: String,
214 placeholder: Option<SharedString>,
215 extensions: Vec<String>,
216 mode: FileMode,
217 missing_directories: MissingDirectories,
218 focus_handle: FocusHandle,
219 edit_button_focus_handle: FocusHandle,
220 browse_button_focus_handle: FocusHandle,
221 is_editing: bool,
222 edit_state: Option<Entity<TextInput>>,
223 custom_theme: Option<Theme>,
224 pending_refocus: bool,
226 pending_focus: bool,
228 ignore_next_activation: bool,
230 browse_shortcut_enabled: bool,
232 validation_display: ValidationDisplay,
234 enabled: bool,
236}
237
238#[cfg(feature = "file-picker")]
239impl EventEmitter<FilePickerEvent> for FilePicker {}
240
241#[cfg(feature = "file-picker")]
242impl Focusable for FilePicker {
243 fn focus_handle(&self, _cx: &App) -> FocusHandle {
244 self.focus_handle.clone()
245 }
246}
247
248#[cfg(feature = "file-picker")]
249impl FilePicker {
250 pub fn new(cx: &mut Context<Self>) -> Self {
252 Self {
253 value: String::new(),
254 placeholder: None,
255 extensions: Vec::new(),
256 mode: FileMode::Open,
257 missing_directories: MissingDirectories::Error,
258 focus_handle: cx.focus_handle(),
259 edit_button_focus_handle: cx.focus_handle().tab_stop(true),
260 browse_button_focus_handle: cx.focus_handle().tab_stop(true),
261 is_editing: false,
262 edit_state: None,
263 custom_theme: None,
264 pending_refocus: false,
265 pending_focus: false,
266 ignore_next_activation: false,
267 browse_shortcut_enabled: true,
268 validation_display: ValidationDisplay::default(),
269 enabled: true,
270 }
271 }
272
273 #[must_use]
275 pub fn with_value(mut self, path: impl Into<String>) -> Self {
276 self.value = path.into();
277 self
278 }
279
280 #[must_use]
282 pub fn placeholder(mut self, text: impl Into<SharedString>) -> Self {
283 self.placeholder = Some(text.into());
284 self
285 }
286
287 #[must_use]
289 pub fn extensions(mut self, extensions: Vec<String>) -> Self {
290 self.extensions = extensions;
291 self
292 }
293
294 #[must_use]
296 pub fn mode(mut self, mode: FileMode) -> Self {
297 self.mode = mode;
298 self
299 }
300
301 #[must_use]
303 pub fn missing_directories(mut self, handling: MissingDirectories) -> Self {
304 self.missing_directories = handling;
305 self
306 }
307
308 #[must_use]
310 pub fn theme(mut self, theme: Theme) -> Self {
311 self.custom_theme = Some(theme);
312 self
313 }
314
315 #[must_use]
320 pub fn browse_shortcut(mut self, enabled: bool) -> Self {
321 self.browse_shortcut_enabled = enabled;
322 self
323 }
324
325 #[must_use]
330 pub fn validation_display(mut self, display: ValidationDisplay) -> Self {
331 self.validation_display = display;
332 self
333 }
334
335 #[must_use]
340 pub fn with_enabled(mut self, enabled: bool) -> Self {
341 self.enabled = enabled;
342 self
343 }
344
345 pub fn is_enabled(&self) -> bool {
347 self.enabled
348 }
349
350 pub fn set_enabled(&mut self, enabled: bool, cx: &mut Context<Self>) {
352 if self.enabled != enabled {
353 self.enabled = enabled;
354 cx.notify();
355 }
356 }
357
358 pub fn value(&self) -> &str {
360 &self.value
361 }
362
363 pub fn set_value(&mut self, path: &str, cx: &mut Context<Self>) {
365 if self.value != path {
366 self.value = path.to_string();
367 cx.emit(FilePickerEvent::Change(self.value.clone()));
368 cx.notify();
369 }
370 }
371
372 pub fn focus_handle(&self) -> &FocusHandle {
374 &self.focus_handle
375 }
376
377 pub fn focus(&mut self, cx: &mut Context<Self>) {
382 self.pending_focus = true;
383 cx.notify();
384 }
385
386 pub fn needs_directory_creation(&self) -> bool {
388 self.mode == FileMode::Save && self.missing_directories == MissingDirectories::Create
389 }
390
391 pub fn directory_to_create(&self) -> Option<std::path::PathBuf> {
393 if !self.needs_directory_creation() || self.value.is_empty() {
394 return None;
395 }
396
397 let path = Path::new(&self.value);
398 let parent = path.parent()?;
399
400 if !parent.as_os_str().is_empty() && !parent.exists() {
401 Some(parent.to_path_buf())
402 } else {
403 None
404 }
405 }
406
407 pub fn validate(&self) -> FilePickerValidation {
411 validate_file_path(&self.value, &self.mode, &self.missing_directories)
412 }
413
414 pub fn is_valid(&self) -> bool {
421 matches!(
422 self.validate(),
423 FilePickerValidation::Valid
424 | FilePickerValidation::WillOverwrite
425 | FilePickerValidation::WillCreatePath
426 )
427 }
428
429 fn compute_path_display(&self, path_info: &PathInfo, theme: &Theme, validation_display: &ValidationDisplay) -> PathDisplayInfo {
430 let mut info = PathDisplayInfo::new();
431
432 if path_info.full_path.as_os_str().is_empty() {
433 return info;
434 }
435
436 let full_path = &path_info.full_path;
437
438 let show_colors = matches!(validation_display, ValidationDisplay::Full | ValidationDisplay::ColorsOnly);
440 let show_message = matches!(validation_display, ValidationDisplay::Full | ValidationDisplay::MessageOnly);
441
442 let color_or_muted = |color: u32| -> u32 {
444 if show_colors { color } else { theme.text_muted }
445 };
446
447 if full_path.is_dir() {
449 if let Some(parent) = full_path.parent() {
450 info.add_segment(&parent.to_string_lossy(), theme.text_muted);
451 }
452 if let Some(dirname) = full_path.file_name() {
453 info.add_path_prefix(&dirname.to_string_lossy(), color_or_muted(theme.warning));
454 }
455 if show_message {
456 info.set_explanation("file expected, but path is a directory", theme.warning);
457 }
458 return info;
459 }
460
461 let file_exists = full_path.is_file();
462
463 match &self.mode {
464 FileMode::Open => {
465 if path_info.fully_exists() {
466 info.add_segment(&path_info.existing_canonical.to_string_lossy(), theme.text_muted);
467 } else {
468 info.add_segment(&path_info.existing_canonical.to_string_lossy(), theme.text_muted);
469 let non_existing = path_info.non_existing_suffix.to_string_lossy();
470 if !non_existing.is_empty() {
471 info.add_path_prefix(&non_existing, color_or_muted(theme.error));
472 if show_message {
473 info.set_explanation("path does not exist", theme.error);
474 }
475 }
476 }
477 }
478 FileMode::Save => {
479 if file_exists {
480 if let Some(parent) = full_path.parent() {
481 info.add_segment(&parent.to_string_lossy(), theme.text_muted);
482 }
483 if let Some(filename) = full_path.file_name() {
484 info.add_path_prefix(&filename.to_string_lossy(), color_or_muted(theme.warning));
485 }
486 if show_message {
487 info.set_explanation("file exists and will be overwritten", theme.warning);
488 }
489 } else {
490 let parent_exists = full_path.parent().is_some_and(|p| p.exists());
491
492 if parent_exists {
493 if let Some(parent) = full_path.parent() {
494 info.add_segment(&parent.to_string_lossy(), theme.text_muted);
495 }
496 if let Some(filename) = full_path.file_name() {
497 info.add_path_prefix(&filename.to_string_lossy(), color_or_muted(theme.success));
498 }
499 if show_message {
500 info.set_explanation("file will be created", theme.success);
501 }
502 } else {
503 info.add_segment(&path_info.existing_canonical.to_string_lossy(), theme.text_muted);
504 let non_existing = path_info.non_existing_suffix.to_string_lossy();
505 if !non_existing.is_empty() {
506 let (color, msg) = match &self.missing_directories {
507 MissingDirectories::Create => (theme.success, "path will be created"),
508 MissingDirectories::Okay => (theme.success, "path will be created by CLI"),
509 MissingDirectories::Error => (theme.error, "path does not exist"),
510 };
511 info.add_path_prefix(&non_existing, color_or_muted(color));
512 if show_message {
513 info.set_explanation(msg, color);
514 }
515 }
516 }
517 }
518 }
519 }
520
521 info
522 }
523
524 fn start_editing(&mut self, window: &mut Window, cx: &mut Context<Self>) {
525 if !self.enabled {
526 return;
527 }
528 self.is_editing = true;
529
530 let value = self.value.clone();
531 let edit_state = cx.new(|cx| {
532 TextInput::new(cx)
533 .with_value(value)
534 .select_on_focus(true)
535 });
536
537 cx.subscribe(&edit_state, |this, edit_state, event: &TextInputEvent, cx| {
539 match event {
540 TextInputEvent::Enter | TextInputEvent::Blur => {
541 this.is_editing = false;
542 let text = edit_state.read(cx).content().to_string();
543 let path_info = parse_path(&text);
544 let new_value = path_info.full_path_string();
545 if this.value != new_value {
546 this.value = new_value;
547 cx.emit(FilePickerEvent::Change(this.value.clone()));
548 }
549 cx.notify();
550 }
551 TextInputEvent::Escape => {
552 this.is_editing = false;
554 this.pending_refocus = true;
555 cx.notify();
556 }
557 _ => {}
558 }
559 }).detach();
560
561 self.edit_state = Some(edit_state.clone());
562
563 edit_state.read(cx).focus_handle().focus(window);
565 }
566
567 fn open_file_dialog(&mut self, window: &mut Window, cx: &mut Context<Self>) {
568 if !self.enabled {
569 return;
570 }
571 let extensions = self.extensions.clone();
572 let entity = cx.entity().clone();
573 let is_save_mode = self.mode == FileMode::Save;
574
575 let initial_dir = if !self.value.is_empty() {
576 let path = Path::new(&self.value);
577 let parent = if path.is_dir() {
578 Some(path.to_path_buf())
579 } else {
580 path.parent().map(|p| p.to_path_buf())
581 };
582 parent.filter(|p| p.exists())
583 } else {
584 None
585 }.or_else(|| std::env::current_dir().ok());
586
587 window.spawn(cx, async move |async_cx| {
588 let result = async_cx.background_executor().spawn(async move {
589 let mut dialog = rfd::AsyncFileDialog::new();
590
591 if let Some(dir) = initial_dir {
592 dialog = dialog.set_directory(&dir);
593 }
594
595 if !extensions.is_empty() {
596 let ext_refs: Vec<&str> = extensions.iter().map(|s| s.as_str()).collect();
597 dialog = dialog.add_filter("Files", &ext_refs);
598 }
599
600 if is_save_mode {
601 dialog.save_file().await.map(|f| f.path().to_path_buf())
602 } else {
603 dialog.pick_file().await.map(|f| f.path().to_path_buf())
604 }
605 }).await;
606
607 if let Some(path) = result {
608 let path_str = path.to_string_lossy().to_string();
609 let _ = async_cx.update_entity(&entity, |this: &mut FilePicker, cx| {
610 if this.value != path_str {
611 this.value = path_str;
612 cx.emit(FilePickerEvent::Change(this.value.clone()));
613 }
614 cx.notify();
615 });
616 }
617 }).detach();
618 }
619}
620
621#[cfg(feature = "file-picker")]
622impl Render for FilePicker {
623 fn render(&mut self, window: &mut Window, cx: &mut Context<'_, Self>) -> impl IntoElement {
624 let theme = get_theme_or(cx, self.custom_theme.as_ref());
625 if self.pending_refocus {
627 self.pending_refocus = false;
628 self.edit_button_focus_handle.focus(window);
629 }
630
631 if self.pending_focus {
633 self.pending_focus = false;
634 self.ignore_next_activation = true;
635 self.edit_button_focus_handle.focus(window);
636 }
637
638 if self.is_editing {
640 if let Some(edit_state) = &self.edit_state {
641 if !edit_state.read(cx).focus_handle().is_focused(window) {
642 self.is_editing = false;
643 let text = edit_state.read(cx).content().to_string();
644 let path_info = parse_path(&text);
645 self.value = path_info.full_path_string();
646 }
647 }
648 }
649
650 let path_info = if self.value.is_empty() {
651 PathInfo::empty()
652 } else {
653 parse_path(&self.value)
654 };
655
656 let path_display = self.compute_path_display(&path_info, &theme, &self.validation_display);
657
658 let basename = if !self.value.is_empty() {
659 Path::new(&self.value)
660 .file_name()
661 .and_then(|n| n.to_str())
662 .map(|s| s.to_string())
663 } else {
664 None
665 };
666
667 let placeholder = self.placeholder.clone()
668 .unwrap_or_else(|| SharedString::from("Click to enter path, or drag & drop"));
669
670 let browse_shortcut_enabled = self.browse_shortcut_enabled;
671 let enabled = self.enabled;
672
673 let is_save_mode = self.mode == FileMode::Save;
674 let edit_button_focus_handle = self.edit_button_focus_handle.clone();
675 let edit_button_is_focused = edit_button_focus_handle.is_focused(window);
676 let browse_button_focus_handle = self.browse_button_focus_handle.clone();
677 let browse_button_is_focused = browse_button_focus_handle.is_focused(window);
678
679 div()
680 .id("ccf_file_picker")
681 .key_context("CcfFilePicker")
682 .flex()
683 .flex_row()
684 .w_full()
685 .when(enabled, |d| d.bg(rgb(theme.bg_input)))
686 .when(!enabled, |d| d.bg(rgb(theme.disabled_bg)))
687 .rounded_md()
688 .border_1()
689 .border_color(rgb(theme.border_default))
690 .when(browse_shortcut_enabled && enabled, |d| {
692 d.on_action(cx.listener(|picker, _: &BrowseFile, window, cx| {
693 picker.open_file_dialog(window, cx);
694 }))
695 })
696 .when(enabled, |d| {
697 d.drag_over::<ExternalPaths>({
698 let bg_hover = theme.bg_input_hover;
699 let border = theme.border_focus;
700 move |d, _, _, _| {
701 d.bg(rgb(bg_hover))
702 .border_color(rgb(border))
703 }
704 })
705 })
706 .child(
707 div()
709 .id("ccf_file_picker_field")
710 .flex()
711 .flex_col()
712 .flex_1()
713 .min_w_0()
714 .min_h(px(52.))
715 .px_3()
716 .py_2()
717 .when(enabled, |d| {
718 d.on_drop(cx.listener(|picker, paths: &ExternalPaths, _window, cx| {
719 if let Some(path) = paths.paths().first() {
720 if path.is_file() {
721 let path_str = path.to_string_lossy().to_string();
722 if picker.value != path_str {
723 picker.value = path_str;
724 cx.emit(FilePickerEvent::Change(picker.value.clone()));
725 }
726 cx.notify();
727 }
728 }
729 }))
730 })
731 .when(!self.is_editing && self.value.is_empty() && enabled, |d| {
733 d.on_click(cx.listener(|picker, _event, window, cx| {
734 picker.start_editing(window, cx);
735 cx.notify();
736 }))
737 .cursor_pointer()
738 .hover(|d| d.bg(rgb(theme.bg_input_hover)))
739 .child(
740 div()
741 .text_sm()
742 .italic()
743 .text_color(rgb(theme.text_dimmed))
744 .child("No file selected")
745 )
746 .child(
747 div()
748 .text_xs()
749 .italic()
750 .text_color(rgb(theme.text_dimmed))
751 .line_height(relative(1.4))
752 .child(placeholder.clone())
753 )
754 })
755 .when(!self.is_editing && self.value.is_empty() && !enabled, |d| {
757 d.cursor_default()
758 .child(
759 div()
760 .text_sm()
761 .italic()
762 .text_color(rgb(theme.disabled_text))
763 .child("No file selected")
764 )
765 .child(
766 div()
767 .text_xs()
768 .italic()
769 .text_color(rgb(theme.disabled_text))
770 .line_height(relative(1.4))
771 .child(placeholder.clone())
772 )
773 })
774 .when(!self.is_editing && !self.value.is_empty() && enabled, |d| {
776 d.on_click(cx.listener(|picker, _event, window, cx| {
777 picker.start_editing(window, cx);
778 cx.notify();
779 }))
780 .cursor_pointer()
781 .hover(|d| d.bg(rgb(theme.bg_input_hover)))
782 .child(
783 div()
784 .text_sm()
785 .font_weight(FontWeight::SEMIBOLD)
786 .text_color(rgb(theme.text_label))
787 .child(basename.clone().unwrap_or_default())
788 )
789 .when(!path_display.is_empty(), |d| {
790 d.child(
791 div()
792 .text_xs()
793 .min_w_0()
794 .line_height(relative(1.4))
795 .child(path_display.to_styled_text())
796 )
797 })
798 .when_some(path_display.explanation.clone(), |d, (msg, color)| {
799 d.child(
800 div()
801 .text_xs()
802 .italic()
803 .text_color(rgb(color))
804 .mt_1()
805 .child(msg)
806 )
807 })
808 })
809 .when(!self.is_editing && !self.value.is_empty() && !enabled, |d| {
811 d.cursor_default()
812 .child(
813 div()
814 .text_sm()
815 .font_weight(FontWeight::SEMIBOLD)
816 .text_color(rgb(theme.disabled_text))
817 .child(basename.clone().unwrap_or_default())
818 )
819 .when(!path_display.is_empty(), |d| {
820 d.child(
821 div()
822 .text_xs()
823 .min_w_0()
824 .text_color(rgb(theme.disabled_text))
825 .line_height(relative(1.4))
826 .child(path_display.full_text.clone())
827 )
828 })
829 })
830 .when(self.is_editing && self.edit_state.is_some(), |d| {
832 let Some(edit_state) = self.edit_state.as_ref() else { return d };
833 let edit_text = edit_state.read(cx).content().to_string();
834 let edit_path_info = if edit_text.is_empty() {
835 PathInfo::empty()
836 } else {
837 parse_path(&edit_text)
838 };
839 let edit_display = self.compute_path_display(&edit_path_info, &theme, &self.validation_display);
840
841 d.child(
842 div()
843 .flex()
844 .flex_col()
845 .gap_1()
846 .child(edit_state.clone())
847 .child(
848 div()
849 .text_xs()
850 .min_w_0()
851 .line_height(relative(1.4))
852 .when(!edit_display.is_empty(), |d| {
853 d.child(edit_display.to_styled_text())
854 })
855 .when(edit_display.is_empty(), |d| {
856 d.text_color(rgb(theme.text_dimmed))
857 .child("(empty path)")
858 })
859 )
860 .when_some(edit_display.explanation.clone(), |d, (msg, color)| {
861 d.child(
862 div()
863 .text_xs()
864 .italic()
865 .text_color(rgb(color))
866 .child(msg)
867 )
868 })
869 )
870 })
871 )
872 .child(
873 div()
875 .flex()
876 .flex_col()
877 .border_l_1()
878 .border_color(rgb(theme.border_default))
879 .child(
880 div()
882 .id("ccf_file_edit_button")
883 .flex_1()
884 .flex()
885 .items_center()
886 .justify_center()
887 .key_context("CcfFilePickerButton")
888 .track_focus(&edit_button_focus_handle)
889 .tab_stop(enabled)
890 .px_2()
891 .when(enabled, |d| d.bg(rgb(theme.bg_input_hover)))
892 .when(!enabled, |d| d.bg(rgb(theme.disabled_bg)))
893 .border_1()
894 .border_color(rgb(if edit_button_is_focused && enabled {
895 theme.border_focus
896 } else if enabled {
897 theme.bg_input_hover } else {
899 theme.disabled_bg
900 }))
901 .cursor_for_enabled(enabled)
902 .when(enabled, |d| d.hover(|d| d.bg(rgb(theme.bg_hover))))
903 .when(enabled, |d| {
904 d.on_click(cx.listener(|picker, _event, window, cx| {
905 if picker.ignore_next_activation {
907 picker.ignore_next_activation = false;
908 return;
909 }
910 picker.start_editing(window, cx);
911 cx.notify();
912 }))
913 })
914 .on_action(cx.listener(|picker, _: &ActivateButton, window, cx| {
915 if picker.ignore_next_activation {
917 picker.ignore_next_activation = false;
918 return;
919 }
920 picker.start_editing(window, cx);
921 cx.notify();
922 }))
923 .on_action(cx.listener(|_this, _: &FocusNext, window, _cx| {
924 window.focus_next();
925 }))
926 .on_action(cx.listener(|_this, _: &FocusPrev, window, _cx| {
927 window.focus_prev();
928 }))
929 .on_key_down(cx.listener(|_picker, event: &KeyDownEvent, window, _cx| {
930 if event.keystroke.key == "tab" {
931 if event.keystroke.modifiers.shift {
932 window.focus_prev();
933 } else {
934 window.focus_next();
935 }
936 }
937 }))
938 .when(enabled, |d| {
939 d.tooltip(|_window, cx| cx.new(|_cx| Tooltip::new("Edit path")).into())
940 })
941 .child(
942 div()
943 .text_sm()
944 .when(enabled, |d| d.text_color(rgb(theme.text_label)))
945 .when(!enabled, |d| d.text_color(rgb(theme.disabled_text)))
946 .child("✎")
947 )
948 )
949 .child(
950 div()
952 .h(px(1.))
953 .bg(rgb(theme.border_default))
954 )
955 .child(
956 div()
958 .id("ccf_file_browse_button")
959 .flex_1()
960 .flex()
961 .items_center()
962 .justify_center()
963 .key_context("CcfFilePickerButton")
964 .track_focus(&browse_button_focus_handle)
965 .tab_stop(enabled)
966 .px_2()
967 .when(enabled, |d| d.bg(rgb(theme.bg_input_hover)))
968 .when(!enabled, |d| d.bg(rgb(theme.disabled_bg)))
969 .border_1()
970 .border_color(rgb(if browse_button_is_focused && enabled {
971 theme.border_focus
972 } else if enabled {
973 theme.bg_input_hover } else {
975 theme.disabled_bg
976 }))
977 .cursor_for_enabled(enabled)
978 .when(enabled, |d| d.hover(|d| d.bg(rgb(theme.bg_hover))))
979 .when(enabled, |d| {
980 d.on_click(cx.listener(|picker, _event, window, cx| {
981 picker.open_file_dialog(window, cx);
982 }))
983 })
984 .on_action(cx.listener(|picker, _: &ActivateButton, window, cx| {
985 picker.open_file_dialog(window, cx);
986 }))
987 .on_action(cx.listener(|_this, _: &FocusNext, window, _cx| {
988 window.focus_next();
989 }))
990 .on_action(cx.listener(|_this, _: &FocusPrev, window, _cx| {
991 window.focus_prev();
992 }))
993 .on_key_down(cx.listener(|_picker, event: &KeyDownEvent, window, _cx| {
994 if event.keystroke.key == "tab" {
995 if event.keystroke.modifiers.shift {
996 window.focus_prev();
997 } else {
998 window.focus_next();
999 }
1000 }
1001 }))
1002 .when(enabled, |d| {
1003 d.tooltip(move |_window, cx| {
1004 cx.new(|_cx| Tooltip::new(if is_save_mode { "Save as..." } else { "Select file..." })).into()
1005 })
1006 })
1007 .child(
1008 div()
1009 .text_sm()
1010 .when(enabled, |d| d.text_color(rgb(theme.text_label)))
1011 .when(!enabled, |d| d.text_color(rgb(theme.disabled_text)))
1012 .child(if is_save_mode { "💾" } else { "📂" })
1013 )
1014 )
1015 )
1016 }
1017}
1018
1019#[cfg(test)]
1020mod tests {
1021 use super::{validate_file_path, FilePickerValidation, FileMode, MissingDirectories};
1022 use std::fs::{self, File};
1023 use std::io::Write;
1024 use tempfile::TempDir;
1025
1026 fn setup_test_dir() -> TempDir {
1027 let dir = TempDir::new().unwrap();
1028 let file_path = dir.path().join("existing_file.txt");
1030 let mut file = File::create(&file_path).unwrap();
1031 writeln!(file, "test content").unwrap();
1032 fs::create_dir(dir.path().join("subdir")).unwrap();
1034 dir
1035 }
1036
1037 #[test]
1038 fn test_validate_empty_path() {
1039 assert_eq!(
1040 validate_file_path("", &FileMode::Open, &MissingDirectories::Error),
1041 FilePickerValidation::Empty
1042 );
1043 assert_eq!(
1044 validate_file_path("", &FileMode::Save, &MissingDirectories::Error),
1045 FilePickerValidation::Empty
1046 );
1047 }
1048
1049 #[test]
1050 fn test_validate_open_existing_file() {
1051 let dir = setup_test_dir();
1052 let file_path = dir.path().join("existing_file.txt");
1053
1054 assert_eq!(
1055 validate_file_path(file_path.to_str().unwrap(), &FileMode::Open, &MissingDirectories::Error),
1056 FilePickerValidation::Valid
1057 );
1058 }
1059
1060 #[test]
1061 fn test_validate_open_non_existing_file() {
1062 let dir = setup_test_dir();
1063 let file_path = dir.path().join("non_existing.txt");
1064
1065 assert_eq!(
1066 validate_file_path(file_path.to_str().unwrap(), &FileMode::Open, &MissingDirectories::Error),
1067 FilePickerValidation::PathDoesNotExist
1068 );
1069 }
1070
1071 #[test]
1072 fn test_validate_open_directory() {
1073 let dir = setup_test_dir();
1074 let subdir_path = dir.path().join("subdir");
1075
1076 assert_eq!(
1077 validate_file_path(subdir_path.to_str().unwrap(), &FileMode::Open, &MissingDirectories::Error),
1078 FilePickerValidation::IsDirectory
1079 );
1080 }
1081
1082 #[test]
1083 fn test_validate_save_existing_file() {
1084 let dir = setup_test_dir();
1085 let file_path = dir.path().join("existing_file.txt");
1086
1087 assert_eq!(
1088 validate_file_path(file_path.to_str().unwrap(), &FileMode::Save, &MissingDirectories::Error),
1089 FilePickerValidation::WillOverwrite
1090 );
1091 }
1092
1093 #[test]
1094 fn test_validate_save_new_file_parent_exists() {
1095 let dir = setup_test_dir();
1096 let file_path = dir.path().join("new_file.txt");
1097
1098 assert_eq!(
1099 validate_file_path(file_path.to_str().unwrap(), &FileMode::Save, &MissingDirectories::Error),
1100 FilePickerValidation::Valid
1101 );
1102 }
1103
1104 #[test]
1105 fn test_validate_save_parent_missing_error() {
1106 let dir = setup_test_dir();
1107 let file_path = dir.path().join("missing_dir/new_file.txt");
1108
1109 assert_eq!(
1110 validate_file_path(file_path.to_str().unwrap(), &FileMode::Save, &MissingDirectories::Error),
1111 FilePickerValidation::ParentDoesNotExist
1112 );
1113 }
1114
1115 #[test]
1116 fn test_validate_save_parent_missing_create() {
1117 let dir = setup_test_dir();
1118 let file_path = dir.path().join("missing_dir/new_file.txt");
1119
1120 assert_eq!(
1121 validate_file_path(file_path.to_str().unwrap(), &FileMode::Save, &MissingDirectories::Create),
1122 FilePickerValidation::WillCreatePath
1123 );
1124 }
1125
1126 #[test]
1127 fn test_validate_save_parent_missing_okay() {
1128 let dir = setup_test_dir();
1129 let file_path = dir.path().join("missing_dir/new_file.txt");
1130
1131 assert_eq!(
1132 validate_file_path(file_path.to_str().unwrap(), &FileMode::Save, &MissingDirectories::Okay),
1133 FilePickerValidation::WillCreatePath
1134 );
1135 }
1136
1137 #[test]
1138 fn test_validate_save_directory() {
1139 let dir = setup_test_dir();
1140 let subdir_path = dir.path().join("subdir");
1141
1142 assert_eq!(
1143 validate_file_path(subdir_path.to_str().unwrap(), &FileMode::Save, &MissingDirectories::Error),
1144 FilePickerValidation::IsDirectory
1145 );
1146 }
1147}