Skip to main content

ccf_gpui_widgets/widgets/
directory_picker.rs

1//! Directory picker widget
2//!
3//! A directory selection widget with native folder dialog support, drag-and-drop,
4//! and color-coded path display showing existing vs non-existing portions.
5//!
6//! Requires the `file-picker` feature.
7//!
8//! # Example
9//!
10//! ```ignore
11//! use ccf_gpui_widgets::widgets::DirectoryPicker;
12//!
13//! let picker = cx.new(|cx| {
14//!     DirectoryPicker::new(cx)
15//!         .placeholder("Select output directory...")
16//! });
17//!
18//! // Subscribe to changes
19//! cx.subscribe(&picker, |this, _picker, event: &DirectoryPickerEvent, cx| {
20//!     if let DirectoryPickerEvent::Change(path) = event {
21//!         println!("Selected: {}", path);
22//!     }
23//! }).detach();
24//! ```
25
26#[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// Actions for keyboard handling
42#[cfg(feature = "file-picker")]
43actions!(
44    ccf_directory_picker,
45    [
46        BrowseDirectory,
47        ActivateButton,
48    ]
49);
50
51/// Register key bindings for directory picker
52///
53/// Call this once at application startup:
54/// ```ignore
55/// ccf_gpui_widgets::widgets::directory_picker::register_keybindings(cx);
56/// ```
57#[cfg(feature = "file-picker")]
58pub fn register_keybindings(cx: &mut App) {
59    // Browse shortcut (Cmd+O / Ctrl+O)
60    #[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    // Button activation (Enter/Space when button is focused)
71    cx.bind_keys([
72        KeyBinding::new("enter", ActivateButton, Some("CcfDirectoryPickerButton")),
73        KeyBinding::new("space", ActivateButton, Some("CcfDirectoryPickerButton")),
74    ]);
75}
76
77/// Events emitted by DirectoryPicker
78#[derive(Clone, Debug)]
79pub enum DirectoryPickerEvent {
80    /// Directory path changed
81    Change(String),
82}
83
84/// Validation state for DirectoryPicker
85#[derive(Clone, Debug, PartialEq, Eq)]
86pub enum DirectoryPickerValidation {
87    /// No path entered
88    Empty,
89    /// Path exists and is a directory
90    Valid,
91    /// Path does not exist
92    PathDoesNotExist,
93    /// Path points to a file instead of a directory
94    IsFile,
95}
96
97// Re-export ValidationDisplay from shared module
98pub use super::path_display::ValidationDisplay;
99
100/// Validate a directory path
101///
102/// This is a standalone function that can be used without a DirectoryPicker instance,
103/// useful for validation logic and testing.
104pub 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/// Directory picker widget
124#[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    /// Whether to refocus self on next render (after ESC from TextInput)
135    pending_refocus: bool,
136    /// Whether to focus the edit button on next render (for programmatic focus)
137    pending_focus: bool,
138    /// Whether to ignore the next activation (to prevent keyup from triggering edit after focus)
139    ignore_next_activation: bool,
140    /// Whether Cmd+O / Ctrl+O shortcut is enabled
141    browse_shortcut_enabled: bool,
142    /// How validation feedback is displayed
143    validation_display: ValidationDisplay,
144    /// Whether the widget is enabled
145    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    /// Create a new directory picker
161    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    /// Set initial value (builder pattern)
181    #[must_use]
182    pub fn with_value(mut self, path: impl Into<String>) -> Self {
183        self.value = path.into();
184        self
185    }
186
187    /// Set placeholder text (builder pattern)
188    #[must_use]
189    pub fn placeholder(mut self, text: impl Into<SharedString>) -> Self {
190        self.placeholder = Some(text.into());
191        self
192    }
193
194    /// Set custom theme (builder pattern)
195    #[must_use]
196    pub fn theme(mut self, theme: Theme) -> Self {
197        self.custom_theme = Some(theme);
198        self
199    }
200
201    /// Enable or disable Cmd+O / Ctrl+O browse shortcut (builder pattern)
202    ///
203    /// The shortcut is enabled by default. Disable it if your application
204    /// uses Cmd+O for another purpose (e.g., opening files at the app level).
205    #[must_use]
206    pub fn browse_shortcut(mut self, enabled: bool) -> Self {
207        self.browse_shortcut_enabled = enabled;
208        self
209    }
210
211    /// Set how validation feedback is displayed (builder pattern)
212    ///
213    /// Controls whether path coloring and/or explanation messages are shown.
214    /// Default is `ValidationDisplay::Full` (show both).
215    #[must_use]
216    pub fn validation_display(mut self, display: ValidationDisplay) -> Self {
217        self.validation_display = display;
218        self
219    }
220
221    /// Set whether the widget is enabled (builder pattern)
222    ///
223    /// When disabled, the widget cannot be edited or used to browse for directories.
224    /// Default is `true`.
225    #[must_use]
226    pub fn with_enabled(mut self, enabled: bool) -> Self {
227        self.enabled = enabled;
228        self
229    }
230
231    /// Get the current directory path
232    pub fn value(&self) -> &str {
233        &self.value
234    }
235
236    /// Set value programmatically
237    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    /// Get the focus handle
246    pub fn focus_handle(&self) -> &FocusHandle {
247        &self.focus_handle
248    }
249
250    /// Focus the edit button (the primary interactive element)
251    ///
252    /// Note: This is deferred to the next render to avoid triggering
253    /// a synthetic click from keyup events.
254    pub fn focus(&mut self, cx: &mut Context<Self>) {
255        self.pending_focus = true;
256        cx.notify();
257    }
258
259    /// Validate the current path and return the validation state
260    ///
261    /// Use this to check if the path is valid before taking action.
262    pub fn validate(&self) -> DirectoryPickerValidation {
263        validate_directory_path(&self.value)
264    }
265
266    /// Returns whether the widget is enabled
267    pub fn is_enabled(&self) -> bool {
268        self.enabled
269    }
270
271    /// Set whether the widget is enabled
272    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    /// Returns true if the current path is valid (exists and is a directory)
280    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        // Check if colors and/or messages should be shown
292        let show_colors = matches!(validation_display, ValidationDisplay::Full | ValidationDisplay::ColorsOnly);
293        let show_message = matches!(validation_display, ValidationDisplay::Full | ValidationDisplay::MessageOnly);
294
295        // Helper to get the appropriate color (respects show_colors setting)
296        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                    // Cancel editing and refocus the picker
344                    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        // Handle pending refocus (after ESC from TextInput)
406        if self.pending_refocus {
407            self.pending_refocus = false;
408            self.edit_button_focus_handle.focus(window);
409        }
410
411        // Handle pending focus (programmatic focus request)
412        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        // Handle focus lost during editing
419        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            // Handle Cmd+O / Ctrl+O to open directory dialog (when enabled)
469            .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                // Path display area
486                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                    // Empty state (enabled)
517                    .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                    // Empty state (disabled)
541                    .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                    // Display mode (enabled)
560                    .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                    // Display mode (disabled)
595                    .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                    // Edit mode
616                    .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                // Icon buttons (Edit and Browse)
659                div()
660                    .flex()
661                    .flex_col()
662                    .border_l_1()
663                    .border_color(rgb(theme.border_default))
664                    .child(
665                        // Edit button
666                        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 // Invisible border when not focused
684                                }))
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                                    // Skip if we just received programmatic focus
692                                    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                                    // Skip if we just received programmatic focus
701                                    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                        // Divider between buttons
737                        div()
738                            .h(px(1.))
739                            .bg(rgb(theme.border_default))
740                    )
741                    .child(
742                        // Browse button
743                        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 // Invisible border when not focused
761                                }))
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        // Create a test file
814        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        // Create a subdirectory
818        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}