Skip to main content

ccf_gpui_widgets/widgets/
file_picker.rs

1//! File picker widget
2//!
3//! A file selection widget with native file 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::{FilePicker, FileMode};
12//!
13//! let picker = cx.new(|cx| {
14//!     FilePicker::new(cx)
15//!         .mode(FileMode::Save)
16//!         .extensions(vec!["json".to_string(), "yaml".to_string()])
17//!         .placeholder("Select output file...")
18//! });
19//!
20//! // Subscribe to changes
21//! cx.subscribe(&picker, |this, _picker, event: &FilePickerEvent, cx| {
22//!     if let FilePickerEvent::Change(path) = event {
23//!         println!("Selected: {}", path);
24//!     }
25//! }).detach();
26//! ```
27
28#[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// Actions for keyboard handling
48#[cfg(feature = "file-picker")]
49actions!(
50    ccf_file_picker,
51    [
52        BrowseFile,
53        ActivateButton,
54    ]
55);
56
57/// Register key bindings for file picker
58///
59/// Call this once at application startup:
60/// ```ignore
61/// ccf_gpui_widgets::widgets::file_picker::register_keybindings(cx);
62/// ```
63#[cfg(feature = "file-picker")]
64pub fn register_keybindings(cx: &mut App) {
65    // Browse shortcut (Cmd+O / Ctrl+O)
66    #[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    // Button activation (Enter/Space when button is focused)
77    cx.bind_keys([
78        KeyBinding::new("enter", ActivateButton, Some("CcfFilePickerButton")),
79        KeyBinding::new("space", ActivateButton, Some("CcfFilePickerButton")),
80    ]);
81}
82
83/// File picker modes
84#[derive(Clone, PartialEq, Default)]
85pub enum FileMode {
86    /// Select existing files only (default)
87    #[default]
88    Open,
89    /// Select output file location (file may not exist)
90    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/// How to handle missing parent directories
105#[derive(Clone, PartialEq, Default)]
106pub enum MissingDirectories {
107    /// Show error if parent directory is missing (default)
108    #[default]
109    Error,
110    /// Allow missing directories (CLI handles it)
111    Okay,
112    /// Create missing parent directories on run
113    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/// Events emitted by FilePicker
129#[derive(Clone, Debug)]
130pub enum FilePickerEvent {
131    /// File path changed
132    Change(String),
133}
134
135/// Validation state for FilePicker
136#[derive(Clone, Debug, PartialEq, Eq)]
137pub enum FilePickerValidation {
138    /// No path entered
139    Empty,
140    /// Path exists and is a file (Open mode) or will be created (Save mode with existing parent)
141    Valid,
142    /// Open mode: path does not exist
143    PathDoesNotExist,
144    /// Path points to a directory instead of a file
145    IsDirectory,
146    /// Save mode: file exists and will be overwritten
147    WillOverwrite,
148    /// Save mode: parent directory does not exist (with MissingDirectories::Error)
149    ParentDoesNotExist,
150    /// Save mode: directories will be created (with MissingDirectories::Create or Okay)
151    WillCreatePath,
152}
153
154// Re-export ValidationDisplay from shared module
155pub use super::path_display::ValidationDisplay;
156
157/// Validate a file path for the given mode and missing directories policy
158///
159/// This is a standalone function that can be used without a FilePicker instance,
160/// useful for validation logic and testing.
161pub 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    // Check if path points to a directory (invalid for file picker)
175    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                // Check if parent directory exists
192                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/// File picker widget
211#[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    /// Whether to refocus self on next render (after ESC from TextInput)
225    pending_refocus: bool,
226    /// Whether to focus the edit button on next render (for programmatic focus)
227    pending_focus: bool,
228    /// Whether to ignore the next activation (to prevent keyup from triggering edit after focus)
229    ignore_next_activation: bool,
230    /// Whether Cmd+O / Ctrl+O shortcut is enabled
231    browse_shortcut_enabled: bool,
232    /// How validation feedback is displayed
233    validation_display: ValidationDisplay,
234    /// Whether the widget is enabled for interaction
235    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    /// Create a new file picker
251    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    /// Set initial value (builder pattern)
274    #[must_use]
275    pub fn with_value(mut self, path: impl Into<String>) -> Self {
276        self.value = path.into();
277        self
278    }
279
280    /// Set placeholder text (builder pattern)
281    #[must_use]
282    pub fn placeholder(mut self, text: impl Into<SharedString>) -> Self {
283        self.placeholder = Some(text.into());
284        self
285    }
286
287    /// Set file extensions filter (builder pattern)
288    #[must_use]
289    pub fn extensions(mut self, extensions: Vec<String>) -> Self {
290        self.extensions = extensions;
291        self
292    }
293
294    /// Set file mode (builder pattern)
295    #[must_use]
296    pub fn mode(mut self, mode: FileMode) -> Self {
297        self.mode = mode;
298        self
299    }
300
301    /// Set missing directories handling (builder pattern)
302    #[must_use]
303    pub fn missing_directories(mut self, handling: MissingDirectories) -> Self {
304        self.missing_directories = handling;
305        self
306    }
307
308    /// Set custom theme (builder pattern)
309    #[must_use]
310    pub fn theme(mut self, theme: Theme) -> Self {
311        self.custom_theme = Some(theme);
312        self
313    }
314
315    /// Enable or disable Cmd+O / Ctrl+O browse shortcut (builder pattern)
316    ///
317    /// The shortcut is enabled by default. Disable it if your application
318    /// uses Cmd+O for another purpose (e.g., opening files at the app level).
319    #[must_use]
320    pub fn browse_shortcut(mut self, enabled: bool) -> Self {
321        self.browse_shortcut_enabled = enabled;
322        self
323    }
324
325    /// Set how validation feedback is displayed (builder pattern)
326    ///
327    /// Controls whether path coloring and/or explanation messages are shown.
328    /// Default is `ValidationDisplay::Full` (show both).
329    #[must_use]
330    pub fn validation_display(mut self, display: ValidationDisplay) -> Self {
331        self.validation_display = display;
332        self
333    }
334
335    /// Set whether the widget is enabled (builder pattern)
336    ///
337    /// When disabled, the widget cannot be edited or browsed.
338    /// Default is `true`.
339    #[must_use]
340    pub fn with_enabled(mut self, enabled: bool) -> Self {
341        self.enabled = enabled;
342        self
343    }
344
345    /// Returns whether the widget is enabled
346    pub fn is_enabled(&self) -> bool {
347        self.enabled
348    }
349
350    /// Set whether the widget is enabled
351    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    /// Get the current file path
359    pub fn value(&self) -> &str {
360        &self.value
361    }
362
363    /// Set value programmatically
364    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    /// Get the focus handle
373    pub fn focus_handle(&self) -> &FocusHandle {
374        &self.focus_handle
375    }
376
377    /// Focus the edit button (the primary interactive element)
378    ///
379    /// Note: This is deferred to the next render to avoid triggering
380    /// a synthetic click from keyup events.
381    pub fn focus(&mut self, cx: &mut Context<Self>) {
382        self.pending_focus = true;
383        cx.notify();
384    }
385
386    /// Returns true if this file picker needs directory creation
387    pub fn needs_directory_creation(&self) -> bool {
388        self.mode == FileMode::Save && self.missing_directories == MissingDirectories::Create
389    }
390
391    /// Returns the parent directory path if it needs to be created
392    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    /// Validate the current path and return the validation state
408    ///
409    /// Use this to check if the path is valid before taking action.
410    pub fn validate(&self) -> FilePickerValidation {
411        validate_file_path(&self.value, &self.mode, &self.missing_directories)
412    }
413
414    /// Returns true if the current path is valid for the configured mode
415    ///
416    /// For Open mode: path must exist and be a file.
417    /// For Save mode: path must not be a directory, and either:
418    ///   - Parent exists (file will be created or overwritten), or
419    ///   - MissingDirectories is Create/Okay (path will be created)
420    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        // Check if colors and/or messages should be shown
439        let show_colors = matches!(validation_display, ValidationDisplay::Full | ValidationDisplay::ColorsOnly);
440        let show_message = matches!(validation_display, ValidationDisplay::Full | ValidationDisplay::MessageOnly);
441
442        // Helper to get the appropriate color (respects show_colors setting)
443        let color_or_muted = |color: u32| -> u32 {
444            if show_colors { color } else { theme.text_muted }
445        };
446
447        // Special case: path points to a directory instead of a file
448        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        // Subscribe to text input events
538        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                    // Cancel editing and refocus the picker
553                    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        // Focus the input
564        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        // Handle pending refocus (after ESC from TextInput)
626        if self.pending_refocus {
627            self.pending_refocus = false;
628            self.edit_button_focus_handle.focus(window);
629        }
630
631        // Handle pending focus (programmatic focus request)
632        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        // Handle focus lost during editing
639        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            // Handle Cmd+O / Ctrl+O to open file dialog (when enabled)
691            .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                // Path display area
708                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                    // Empty state (enabled)
732                    .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                    // Empty state (disabled)
756                    .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                    // Display mode (enabled)
775                    .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                    // Display mode (disabled)
810                    .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                    // Edit mode
831                    .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                // Icon buttons (Edit and Browse)
874                div()
875                    .flex()
876                    .flex_col()
877                    .border_l_1()
878                    .border_color(rgb(theme.border_default))
879                    .child(
880                        // Edit button
881                        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 // Invisible border when not focused
898                            } 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                                    // Skip if we just received programmatic focus
906                                    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                                // Skip if we just received programmatic focus
916                                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                        // Divider between buttons
951                        div()
952                            .h(px(1.))
953                            .bg(rgb(theme.border_default))
954                    )
955                    .child(
956                        // Browse button
957                        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 // Invisible border when not focused
974                            } 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        // Create a test file
1029        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        // Create a subdirectory
1033        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}