Skip to main content

ccf_gpui_widgets/widgets/
repeatable_file_picker.rs

1//! Repeatable file picker widget - allows selecting multiple files with add/remove buttons
2//!
3//! A widget that manages a list of file path inputs, allowing users to add and remove entries.
4//! Each entry is a full FilePicker widget supporting file browsing dialogs and drag-drop.
5//!
6//! # Example
7//!
8//! ```ignore
9//! use ccf_gpui_widgets::widgets::{
10//!     RepeatableFilePicker, RepeatableFilePickerEvent, FileMode, MissingDirectories
11//! };
12//!
13//! let picker = cx.new(|cx| {
14//!     RepeatableFilePicker::new(cx)
15//!         .with_values(vec!["/path/to/file1.txt".to_string()])
16//!         .mode(FileMode::Open)
17//!         .extensions(vec!["txt".to_string(), "md".to_string()])
18//!         .min_entries(1)
19//! });
20//!
21//! cx.subscribe(&picker, |this, _, event: &RepeatableFilePickerEvent, cx| {
22//!     match event {
23//!         RepeatableFilePickerEvent::Change(values) => {
24//!             println!("Files: {:?}", values);
25//!         }
26//!         RepeatableFilePickerEvent::EntryAdded(index) => {
27//!             println!("Added entry at index {}", index);
28//!         }
29//!         RepeatableFilePickerEvent::EntryRemoved(index) => {
30//!             println!("Removed entry at index {}", index);
31//!         }
32//!     }
33//! }).detach();
34//! ```
35
36use gpui::prelude::*;
37use gpui::*;
38use std::path::PathBuf;
39use crate::theme::{get_theme, Theme};
40use super::file_picker::{
41    FilePicker, FilePickerEvent, FileMode, MissingDirectories,
42    FilePickerValidation, ValidationDisplay,
43};
44use super::focus_navigation::{repeatable_add_button, repeatable_remove_button};
45
46/// Events emitted by RepeatableFilePicker
47#[derive(Debug, Clone)]
48pub enum RepeatableFilePickerEvent {
49    /// Values changed (includes all current values)
50    Change(Vec<String>),
51    /// A new entry was added at the given index
52    EntryAdded(usize),
53    /// An entry was removed from the given index
54    EntryRemoved(usize),
55}
56
57/// Repeatable file picker widget
58///
59/// Manages a dynamic list of FilePicker widgets with add/remove controls.
60pub struct RepeatableFilePicker {
61    // Configuration (applied to all entries)
62    placeholder: Option<SharedString>,
63    extensions: Vec<String>,
64    mode: FileMode,
65    missing_directories: MissingDirectories,
66    browse_shortcut_enabled: bool,
67    validation_display: ValidationDisplay,
68
69    /// Initial values to populate on first render
70    initial_values: Vec<String>,
71    /// Each entry is a FilePicker entity
72    entries: Vec<Entity<FilePicker>>,
73    /// Focus handles for remove buttons (one per entry)
74    remove_focus_handles: Vec<FocusHandle>,
75    /// Focus handle for the add button
76    add_focus_handle: FocusHandle,
77    /// Whether entries have been initialized
78    initialized: bool,
79    /// Minimum number of entries (cannot remove below this)
80    min_entries: usize,
81    custom_theme: Option<Theme>,
82    /// Whether the widget is enabled (interactive)
83    enabled: bool,
84    /// Prevents double-trigger when Space/Enter activates both on_action and on_click.
85    /// See focus_navigation.rs module comment for details. DO NOT REMOVE.
86    action_just_handled: bool,
87}
88
89impl RepeatableFilePicker {
90    /// Create a new repeatable file picker
91    pub fn new(cx: &mut Context<Self>) -> Self {
92        Self {
93            placeholder: None,
94            extensions: Vec::new(),
95            mode: FileMode::Open,
96            missing_directories: MissingDirectories::Error,
97            browse_shortcut_enabled: true,
98            validation_display: ValidationDisplay::default(),
99            initial_values: Vec::new(),
100            entries: Vec::new(),
101            remove_focus_handles: Vec::new(),
102            add_focus_handle: cx.focus_handle().tab_stop(true),
103            initialized: false,
104            min_entries: 1,
105            custom_theme: None,
106            enabled: true,
107            action_just_handled: false,
108        }
109    }
110
111    /// Set initial values
112    #[must_use]
113    pub fn with_values(mut self, values: Vec<String>) -> Self {
114        self.initial_values = values;
115        self
116    }
117
118    /// Set placeholder text
119    #[must_use]
120    pub fn placeholder(mut self, text: impl Into<SharedString>) -> Self {
121        self.placeholder = Some(text.into());
122        self
123    }
124
125    /// Set file extension filters (e.g., ["txt", "md"])
126    #[must_use]
127    pub fn extensions(mut self, exts: Vec<String>) -> Self {
128        self.extensions = exts;
129        self
130    }
131
132    /// Set file mode (Open or Save)
133    #[must_use]
134    pub fn mode(mut self, mode: FileMode) -> Self {
135        self.mode = mode;
136        self
137    }
138
139    /// Set how missing directories are handled
140    #[must_use]
141    pub fn missing_directories(mut self, handling: MissingDirectories) -> Self {
142        self.missing_directories = handling;
143        self
144    }
145
146    /// Enable or disable Cmd+O / Ctrl+O browse shortcut (builder pattern)
147    ///
148    /// The shortcut is enabled by default. Disable it if your application
149    /// uses Cmd+O for another purpose (e.g., opening files at the app level).
150    #[must_use]
151    pub fn browse_shortcut(mut self, enabled: bool) -> Self {
152        self.browse_shortcut_enabled = enabled;
153        self
154    }
155
156    /// Set how validation feedback is displayed (builder pattern)
157    ///
158    /// Controls whether path coloring and/or explanation messages are shown.
159    /// Default is `ValidationDisplay::Full` (show both).
160    #[must_use]
161    pub fn validation_display(mut self, display: ValidationDisplay) -> Self {
162        self.validation_display = display;
163        self
164    }
165
166    /// Set minimum number of entries (default: 1)
167    #[must_use]
168    pub fn min_entries(mut self, min: usize) -> Self {
169        self.min_entries = min.max(1);
170        self
171    }
172
173    /// Set a custom theme for this widget
174    #[must_use]
175    pub fn theme(mut self, theme: Theme) -> Self {
176        self.custom_theme = Some(theme);
177        self
178    }
179
180    /// Set enabled state (builder pattern)
181    #[must_use]
182    pub fn with_enabled(mut self, enabled: bool) -> Self {
183        self.enabled = enabled;
184        self
185    }
186
187    /// Check if the widget is enabled
188    pub fn is_enabled(&self) -> bool {
189        self.enabled
190    }
191
192    /// Set enabled state programmatically
193    pub fn set_enabled(&mut self, enabled: bool, cx: &mut Context<Self>) {
194        if self.enabled != enabled {
195            self.enabled = enabled;
196            // Update child entries
197            for entry in &self.entries {
198                entry.update(cx, |e, cx| e.set_enabled(enabled, cx));
199            }
200            cx.notify();
201        }
202    }
203
204    /// Get all non-empty values
205    pub fn values(&self, cx: &App) -> Vec<String> {
206        self.entries
207            .iter()
208            .map(|entry| entry.read(cx).value().to_string())
209            .filter(|s| !s.is_empty())
210            .collect()
211    }
212
213    /// Get access to the underlying FilePicker entries
214    pub fn entries(&self) -> &[Entity<FilePicker>] {
215        &self.entries
216    }
217
218    /// Validate all entries and return their validation states
219    pub fn validate_all(&self, cx: &App) -> Vec<FilePickerValidation> {
220        self.entries
221            .iter()
222            .map(|entry| entry.read(cx).validate())
223            .collect()
224    }
225
226    /// Returns true if all non-empty entries are valid
227    pub fn is_all_valid(&self, cx: &App) -> bool {
228        self.entries
229            .iter()
230            .all(|entry| {
231                let picker = entry.read(cx);
232                picker.value().is_empty() || picker.is_valid()
233            })
234    }
235
236    /// Returns all directories that need to be created (for Save mode with MissingDirectories::Create)
237    pub fn directories_to_create(&self, cx: &App) -> Vec<PathBuf> {
238        self.entries
239            .iter()
240            .filter_map(|entry| entry.read(cx).directory_to_create())
241            .collect()
242    }
243
244    /// Create a new FilePicker entry with current configuration
245    fn create_entry(&self, value: Option<&str>, cx: &mut Context<Self>) -> Entity<FilePicker> {
246        let placeholder = self.placeholder.clone();
247        let extensions = self.extensions.clone();
248        let mode = self.mode.clone();
249        let missing_directories = self.missing_directories.clone();
250        let browse_shortcut_enabled = self.browse_shortcut_enabled;
251        let validation_display = self.validation_display.clone();
252        let theme = self.custom_theme;
253        let enabled = self.enabled;
254
255        let picker = cx.new(|cx| {
256            let mut p = FilePicker::new(cx)
257                .mode(mode)
258                .extensions(extensions)
259                .missing_directories(missing_directories)
260                .browse_shortcut(browse_shortcut_enabled)
261                .validation_display(validation_display)
262                .with_enabled(enabled);
263
264            if let Some(ph) = placeholder {
265                p = p.placeholder(ph);
266            }
267            if let Some(th) = theme {
268                p = p.theme(th);
269            }
270            p
271        });
272
273        if let Some(v) = value.filter(|s| !s.is_empty()) {
274            picker.update(cx, |p, cx| {
275                p.set_value(v, cx);
276            });
277        }
278
279        // Subscribe to changes to emit our own change events
280        cx.subscribe(&picker, |this, _picker, event: &FilePickerEvent, cx| {
281            let FilePickerEvent::Change(_) = event;
282            let values = this.values(cx);
283            cx.emit(RepeatableFilePickerEvent::Change(values));
284        }).detach();
285
286        picker
287    }
288
289    /// Initialize entries from initial values (called during first render)
290    fn initialize_entries(&mut self, cx: &mut Context<Self>) {
291        if self.initialized {
292            return;
293        }
294        self.initialized = true;
295
296        let values = std::mem::take(&mut self.initial_values);
297        let values = if values.is_empty() {
298            vec![String::new(); self.min_entries]
299        } else if values.len() < self.min_entries {
300            let mut v = values;
301            v.resize(self.min_entries, String::new());
302            v
303        } else {
304            values
305        };
306
307        for value in values {
308            let entry = self.create_entry(Some(&value), cx);
309            self.entries.push(entry);
310            self.remove_focus_handles.push(cx.focus_handle().tab_stop(true));
311        }
312    }
313
314    fn add_entry(&mut self, cx: &mut Context<Self>) {
315        let index = self.entries.len();
316        let entry = self.create_entry(None, cx);
317        self.entries.push(entry);
318        self.remove_focus_handles.push(cx.focus_handle().tab_stop(true));
319        cx.emit(RepeatableFilePickerEvent::EntryAdded(index));
320        cx.emit(RepeatableFilePickerEvent::Change(self.values(cx)));
321        cx.notify();
322    }
323
324    fn remove_entry(&mut self, index: usize, window: &mut Window, cx: &mut Context<Self>) {
325        if self.entries.len() > self.min_entries && index < self.entries.len() {
326            // Check if the remove button being pressed has focus
327            let had_focus = self.remove_focus_handles[index].is_focused(window);
328
329            self.entries.remove(index);
330            self.remove_focus_handles.remove(index);
331
332            // Move focus if the removed button had focus
333            if had_focus {
334                if self.entries.len() <= self.min_entries {
335                    // No more "-" buttons visible, focus the first entry's picker
336                    self.entries[0].update(cx, |picker, cx| picker.focus(cx));
337                } else if index > 0 {
338                    // Focus the previous entry's "-" button
339                    self.remove_focus_handles[index - 1].focus(window);
340                } else {
341                    // Removed first entry, focus the new first entry's "-" button
342                    self.remove_focus_handles[0].focus(window);
343                }
344            }
345
346            cx.emit(RepeatableFilePickerEvent::EntryRemoved(index));
347            cx.emit(RepeatableFilePickerEvent::Change(self.values(cx)));
348            cx.notify();
349        }
350    }
351
352    fn get_theme(&self, cx: &App) -> Theme {
353        self.custom_theme.unwrap_or_else(|| get_theme(cx))
354    }
355}
356
357impl EventEmitter<RepeatableFilePickerEvent> for RepeatableFilePicker {}
358
359impl Render for RepeatableFilePicker {
360    fn render(&mut self, window: &mut Window, cx: &mut Context<'_, Self>) -> impl IntoElement {
361        // Initialize entries on first render
362        self.initialize_entries(cx);
363
364        let theme = self.get_theme(cx);
365        let entries_count = self.entries.len();
366        let can_remove = entries_count > self.min_entries;
367        let add_focused = self.add_focus_handle.is_focused(window);
368        let enabled = self.enabled;
369
370        // Collect entries with their remove button focus handles
371        let entry_data: Vec<_> = self.entries.iter()
372            .zip(self.remove_focus_handles.iter())
373            .enumerate()
374            .map(|(index, (entry, focus_handle))| {
375                let is_focused = focus_handle.is_focused(window);
376                (index, entry.clone(), focus_handle.clone(), is_focused)
377            })
378            .collect();
379
380        div()
381            .flex()
382            .flex_col()
383            .gap_2()
384            .child(
385                // Entries list
386                div()
387                    .flex()
388                    .flex_col()
389                    .gap_2()
390                    .children(entry_data.into_iter().map(|(index, entry, focus_handle, is_focused)| {
391                        let remove_button = repeatable_remove_button(
392                            format!("file_remove_{}", index),
393                            &focus_handle,
394                            &theme,
395                            enabled,
396                            is_focused,
397                            // on_action: set flag, then perform action
398                            move |this: &mut Self, window, cx| {
399                                this.action_just_handled = true;
400                                this.remove_entry(index, window, cx);
401                            },
402                            // on_click: skip if action just handled, otherwise perform action
403                            move |this: &mut Self, window, cx| {
404                                if this.action_just_handled {
405                                    this.action_just_handled = false;
406                                    return;
407                                }
408                                this.remove_entry(index, window, cx);
409                            },
410                            cx,
411                        );
412
413                        div()
414                            .flex()
415                            .flex_row()
416                            .items_center()
417                            .gap_2()
418                            .child(
419                                // FilePicker widget
420                                div()
421                                    .flex_1()
422                                    .child(entry)
423                            )
424                            .when(can_remove, |d| d.child(remove_button))
425                    }))
426            )
427            .child(
428                // Add button row
429                div()
430                    .flex()
431                    .flex_row()
432                    .child(
433                        repeatable_add_button(
434                            "repeatable_file_add_button",
435                            &self.add_focus_handle,
436                            &theme,
437                            enabled,
438                            add_focused,
439                            // on_action: set flag, then perform action
440                            |this: &mut Self, _window, cx| {
441                                this.action_just_handled = true;
442                                this.add_entry(cx);
443                            },
444                            // on_click: skip if action just handled, otherwise perform action
445                            |this: &mut Self, _window, cx| {
446                                if this.action_just_handled {
447                                    this.action_just_handled = false;
448                                    return;
449                                }
450                                this.add_entry(cx);
451                            },
452                            cx,
453                        )
454                    )
455            )
456    }
457}