bubbletea_widgets/
filepicker.rs

1//! File picker component for browsing and selecting files in terminal applications.
2//!
3//! This module provides a fully-functional file picker that allows users to navigate
4//! the file system, browse directories, and select files using keyboard navigation.
5//! It integrates seamlessly with bubbletea-rs applications and supports customizable
6//! styling and key bindings.
7//!
8//! # Features
9//!
10//! - **Directory Navigation**: Browse directories with vim-style key bindings
11//! - **File Selection**: Select files using Enter key
12//! - **Cross-platform**: Works on Windows, macOS, and Linux
13//! - **Hidden File Handling**: Automatically hides dotfiles and system hidden files
14//! - **Customizable Styling**: Configurable colors and styles for different file types
15//! - **Keyboard Navigation**: Full keyboard support with configurable key bindings
16//! - **Sorting**: Directories are automatically sorted before files alphabetically
17//!
18//! # Basic Usage
19//!
20//! ```rust
21//! use bubbletea_widgets::filepicker::Model;
22//! use bubbletea_rs::{Model as BubbleTeaModel, Msg, Cmd};
23//!
24//! // Create a new file picker
25//! let (mut filepicker, cmd) = Model::init();
26//!
27//! // In your application's update method
28//! fn handle_filepicker_msg(filepicker: &mut Model, msg: Msg) -> Option<Cmd> {
29//!     // Check if a file was selected
30//!     let (selected, path) = filepicker.did_select_file(&msg);
31//!     if selected {
32//!         if let Some(file_path) = path {
33//!             println!("Selected file: {:?}", file_path);
34//!         }
35//!     }
36//!     
37//!     // Update the filepicker
38//!     filepicker.update(msg)
39//! }
40//! ```
41//!
42//! # Customization
43//!
44//! ```rust
45//! use bubbletea_widgets::filepicker::{Model, Styles, FilepickerKeyMap};
46//! use lipgloss::{Style, Color};
47//!
48//! let mut filepicker = Model::new();
49//!
50//! // Customize styles
51//! filepicker.styles.cursor = Style::new().foreground(Color::from("cyan"));
52//! filepicker.styles.directory = Style::new().foreground(Color::from("blue")).bold(true);
53//! filepicker.styles.file = Style::new().foreground(Color::from("white"));
54//!
55//! // The keymap can also be customized if needed
56//! // filepicker.keymap = FilepickerKeyMap::default();
57//! ```
58//!
59//! # Key Bindings
60//!
61//! The default key bindings are:
62//!
63//! - `j`/`↓`: Move cursor down
64//! - `k`/`↑`: Move cursor up  
65//! - `l`/`→`/`Enter`: Open directory or select file
66//! - `h`/`←`/`Backspace`/`Esc`: Go back to parent directory
67//! - `PageUp`/`b`: Page up
68//! - `PageDown`/`f`: Page down
69
70use crate::key::{self, KeyMap};
71use bubbletea_rs::{Cmd, KeyMsg, Model as BubbleTeaModel, Msg};
72use lipgloss::{style::Style, Color};
73use std::path::{Path, PathBuf};
74
75/// Key bindings for filepicker navigation and interaction.
76///
77/// This struct defines all the keyboard shortcuts available in the file picker.
78/// Each binding supports multiple keys for the same action (e.g., both 'j' and '↓' for moving down).
79///
80/// # Examples
81///
82/// ```rust
83/// use bubbletea_widgets::filepicker::FilepickerKeyMap;
84/// use bubbletea_widgets::key::KeyMap;
85///
86/// let keymap = FilepickerKeyMap::default();
87/// let help_keys = keymap.short_help(); // Get keys for help display
88/// ```
89#[derive(Debug, Clone)]
90pub struct FilepickerKeyMap {
91    /// Key binding for moving the cursor down in the file list.
92    /// Default: 'j', '↓'
93    pub down: key::Binding,
94    /// Key binding for moving the cursor up in the file list.
95    /// Default: 'k', '↑'
96    pub up: key::Binding,
97    /// Key binding for navigating back to the parent directory.
98    /// Default: 'h', '←', 'Backspace', 'Esc'
99    pub back: key::Binding,
100    /// Key binding for opening directories or selecting files.
101    /// Default: 'l', '→', 'Enter'
102    pub open: key::Binding,
103    /// Key binding for selecting the current file (alternative to open).
104    /// Default: 'Enter'
105    pub select: key::Binding,
106    /// Key binding for scrolling up a page in the file list.
107    /// Default: 'PageUp', 'b'
108    pub page_up: key::Binding,
109    /// Key binding for scrolling down a page in the file list.
110    /// Default: 'PageDown', 'f'
111    pub page_down: key::Binding,
112}
113
114impl Default for FilepickerKeyMap {
115    fn default() -> Self {
116        use crossterm::event::KeyCode;
117
118        Self {
119            down: key::Binding::new(vec![KeyCode::Char('j'), KeyCode::Down])
120                .with_help("j/↓", "down"),
121            up: key::Binding::new(vec![KeyCode::Char('k'), KeyCode::Up]).with_help("k/↑", "up"),
122            back: key::Binding::new(vec![
123                KeyCode::Char('h'),
124                KeyCode::Backspace,
125                KeyCode::Left,
126                KeyCode::Esc,
127            ])
128            .with_help("h/←", "back"),
129            open: key::Binding::new(vec![KeyCode::Char('l'), KeyCode::Right, KeyCode::Enter])
130                .with_help("l/→", "open"),
131            select: key::Binding::new(vec![KeyCode::Enter]).with_help("enter", "select"),
132            page_up: key::Binding::new(vec![KeyCode::PageUp, KeyCode::Char('b')])
133                .with_help("pgup/b", "page up"),
134            page_down: key::Binding::new(vec![KeyCode::PageDown, KeyCode::Char('f')])
135                .with_help("pgdn/f", "page down"),
136        }
137    }
138}
139
140impl KeyMap for FilepickerKeyMap {
141    fn short_help(&self) -> Vec<&key::Binding> {
142        vec![&self.up, &self.down, &self.open, &self.back]
143    }
144
145    fn full_help(&self) -> Vec<Vec<&key::Binding>> {
146        vec![
147            vec![&self.up, &self.down],
148            vec![&self.open, &self.back, &self.select],
149            vec![&self.page_up, &self.page_down],
150        ]
151    }
152}
153
154/// Visual styling configuration for the file picker.
155///
156/// This struct allows customization of colors and styles for different elements
157/// of the file picker interface, including the cursor, directories, files, and selected items.
158///
159/// # Examples
160///
161/// ```rust
162/// use bubbletea_widgets::filepicker::Styles;
163/// use lipgloss::{Style, Color};
164///
165/// let mut styles = Styles::default();
166/// styles.cursor = Style::new().foreground(Color::from("cyan"));
167/// styles.directory = Style::new().foreground(Color::from("blue")).bold(true);
168/// ```
169#[derive(Debug, Clone)]
170pub struct Styles {
171    /// Style for the cursor indicator (usually "> ").
172    /// Default: foreground color 212 (pink/magenta)
173    pub cursor: Style,
174    /// Style for directory names in the file list.
175    /// Default: foreground color 99 (purple)
176    pub directory: Style,
177    /// Style for regular file names in the file list.
178    /// Default: no special styling (terminal default)
179    pub file: Style,
180    /// Style applied to the currently selected item (in addition to file/directory style).
181    /// Default: foreground color 212 (pink/magenta) and bold
182    pub selected: Style,
183}
184
185impl Default for Styles {
186    fn default() -> Self {
187        Self {
188            cursor: Style::new().foreground(Color::from("212")),
189            directory: Style::new().foreground(Color::from("99")),
190            file: Style::new(),
191            selected: Style::new().foreground(Color::from("212")).bold(true),
192        }
193    }
194}
195
196/// Represents a single file or directory entry in the file picker.
197///
198/// This struct contains all the information needed to display and interact with
199/// a file system entry, including its name, full path, and whether it's a directory.
200///
201/// # Examples
202///
203/// ```rust
204/// use bubbletea_widgets::filepicker::FileEntry;
205/// use std::path::PathBuf;
206///
207/// let entry = FileEntry {
208///     name: "example.txt".to_string(),
209///     path: PathBuf::from("/path/to/example.txt"),
210///     is_dir: false,
211/// };
212/// ```
213#[derive(Debug, Clone)]
214pub struct FileEntry {
215    /// The display name of the file or directory.
216    /// This is typically just the filename without the full path.
217    pub name: String,
218    /// The complete path to the file or directory.
219    pub path: PathBuf,
220    /// Whether this entry represents a directory (`true`) or a file (`false`).
221    pub is_dir: bool,
222}
223
224/// The main file picker model containing all state and configuration.
225///
226/// This struct represents the complete state of the file picker, including the current
227/// directory, file list, selection state, and styling configuration. It implements
228/// the BubbleTeaModel trait for integration with bubbletea-rs applications.
229///
230/// # Examples
231///
232/// ```rust
233/// use bubbletea_widgets::filepicker::Model;
234/// use bubbletea_rs::Model as BubbleTeaModel;
235///
236/// // Create a new file picker
237/// let mut picker = Model::new();
238///
239/// // Or use the BubbleTeaModel::init() method
240/// let (picker, cmd) = Model::init();
241/// ```
242///
243/// # State Management
244///
245/// The model maintains:
246/// - Current directory being browsed
247/// - List of files and directories in the current location
248/// - Currently selected item index
249/// - Last selected file path (if any)
250/// - Styling and key binding configuration
251#[derive(Debug, Clone)]
252pub struct Model {
253    /// The directory currently being browsed.
254    /// This path is updated when navigating into subdirectories or back to parent directories.
255    pub current_directory: PathBuf,
256    /// Key bindings configuration for navigation and interaction.
257    /// Can be customized to change keyboard shortcuts.
258    pub keymap: FilepickerKeyMap,
259    /// Visual styling configuration for different UI elements.
260    /// Can be customized to change colors and appearance.
261    pub styles: Styles,
262    files: Vec<FileEntry>,
263    selected: usize,
264    /// The path of the most recently selected file, if any.
265    /// This is set when a user selects a file (not a directory) and can be checked
266    /// using the `did_select_file()` method.
267    pub path: Option<PathBuf>,
268}
269
270impl Model {
271    /// Creates a new file picker model with default settings.
272    ///
273    /// The file picker starts in the current working directory (".") and uses
274    /// default key bindings and styles. The file list is initially empty and will
275    /// be populated when the model is initialized or when directories are navigated.
276    ///
277    /// # Returns
278    ///
279    /// A new `Model` instance with:
280    /// - Current directory set to "."
281    /// - Default key bindings
282    /// - Default styling
283    /// - Empty file list (call `read_dir()` or use `BubbleTeaModel::init()` to populate)
284    /// - No file selected
285    ///
286    /// # Examples
287    ///
288    /// ```rust
289    /// use bubbletea_widgets::filepicker::Model;
290    ///
291    /// let mut picker = Model::new();
292    /// assert_eq!(picker.current_directory.as_os_str(), ".");
293    /// assert!(picker.path.is_none());
294    /// ```
295    pub fn new() -> Self {
296        Self {
297            current_directory: PathBuf::from("."),
298            keymap: FilepickerKeyMap::default(),
299            styles: Styles::default(),
300            files: vec![],
301            selected: 0,
302            path: None,
303        }
304    }
305
306    /// Checks if a file was selected in response to the given message.
307    ///
308    /// This method examines the provided message to determine if it represents
309    /// a file selection event. It returns both a boolean indicating whether a
310    /// file was selected and the path of the selected file (if any).
311    ///
312    /// # Arguments
313    ///
314    /// * `msg` - The message to check for file selection events
315    ///
316    /// # Returns
317    ///
318    /// A tuple containing:
319    /// - `bool`: `true` if a file was selected, `false` otherwise
320    /// - `Option<PathBuf>`: The path of the selected file, or `None` if no file was selected
321    ///
322    /// # Examples
323    ///
324    /// ```rust
325    /// use bubbletea_widgets::filepicker::Model;
326    /// use bubbletea_rs::{KeyMsg, Msg};
327    /// use crossterm::event::{KeyCode, KeyModifiers};
328    ///
329    /// let mut picker = Model::new();
330    ///
331    /// // Simulate an Enter key press
332    /// let key_msg = KeyMsg {
333    ///     key: KeyCode::Enter,
334    ///     modifiers: KeyModifiers::NONE,
335    /// };
336    /// let msg: Msg = Box::new(key_msg);
337    ///
338    /// let (selected, path) = picker.did_select_file(&msg);
339    /// if selected {
340    ///     println!("File selected: {:?}", path);
341    /// }
342    /// ```
343    ///
344    /// # Note
345    ///
346    /// This method only returns `true` if:
347    /// 1. The message is a key press of the Enter key
348    /// 2. A file path is currently stored in `self.path` (i.e., a file was previously highlighted)
349    pub fn did_select_file(&self, msg: &Msg) -> (bool, Option<PathBuf>) {
350        if let Some(key_msg) = msg.downcast_ref::<KeyMsg>() {
351            if key_msg.key == crossterm::event::KeyCode::Enter && self.path.is_some() {
352                return (true, self.path.clone());
353            }
354        }
355        (false, None)
356    }
357
358    fn read_dir(&mut self) {
359        self.files.clear();
360        if let Ok(entries) = std::fs::read_dir(&self.current_directory) {
361            for entry in entries.flatten() {
362                let path = entry.path();
363                let name = path
364                    .file_name()
365                    .and_then(|n| n.to_str())
366                    .unwrap_or("?")
367                    .to_string();
368
369                // Skip hidden files (platform-aware)
370                if is_hidden(&path, &name) {
371                    continue;
372                }
373
374                let is_dir = path.is_dir();
375                self.files.push(FileEntry { name, path, is_dir });
376            }
377        }
378
379        // Sort directories first, then files
380        self.files
381            .sort_by(|a, b| b.is_dir.cmp(&a.is_dir).then_with(|| a.name.cmp(&b.name)));
382
383        self.selected = 0;
384    }
385}
386
387impl Default for Model {
388    fn default() -> Self {
389        Self::new()
390    }
391}
392
393/// Determines whether a file or directory should be considered hidden.
394///
395/// This function implements cross-platform hidden file detection:
396/// - On Windows: Checks the FILE_ATTRIBUTE_HIDDEN attribute
397/// - On Unix-like systems: Files/directories starting with '.' are considered hidden
398///
399/// # Arguments
400///
401/// * `path` - The full path to the file or directory
402/// * `name` - The filename/directory name (used as fallback on Windows)
403///
404/// # Returns
405///
406/// `true` if the file should be hidden, `false` otherwise
407#[inline]
408fn is_hidden(path: &Path, name: &str) -> bool {
409    #[cfg(target_os = "windows")]
410    {
411        use std::os::windows::fs::MetadataExt;
412        if let Ok(meta) = path.metadata() {
413            const FILE_ATTRIBUTE_HIDDEN: u32 = 0x2;
414            return (meta.file_attributes() & FILE_ATTRIBUTE_HIDDEN) != 0;
415        }
416        // Fallback: also consider dotfiles as hidden if attribute lookup fails
417        return name.starts_with('.');
418    }
419    #[cfg(not(target_os = "windows"))]
420    {
421        let _ = path; // silence unused on non-Windows
422        name.starts_with('.')
423    }
424}
425
426impl BubbleTeaModel for Model {
427    fn init() -> (Self, Option<Cmd>) {
428        let mut model = Self::new();
429        model.read_dir();
430        (model, None)
431    }
432
433    fn update(&mut self, msg: Msg) -> Option<Cmd> {
434        if let Some(key_msg) = msg.downcast_ref::<KeyMsg>() {
435            if self.keymap.down.matches(key_msg) {
436                if self.selected < self.files.len().saturating_sub(1) {
437                    self.selected += 1;
438                }
439            } else if self.keymap.up.matches(key_msg) {
440                self.selected = self.selected.saturating_sub(1);
441            } else if self.keymap.back.matches(key_msg) {
442                self.current_directory.pop();
443                self.read_dir();
444            } else if self.keymap.open.matches(key_msg) && !self.files.is_empty() {
445                let entry = &self.files[self.selected];
446                if entry.is_dir {
447                    self.current_directory = entry.path.clone();
448                    self.read_dir();
449                } else {
450                    self.path = Some(entry.path.clone());
451                }
452            }
453        }
454        None
455    }
456
457    fn view(&self) -> String {
458        if self.files.is_empty() {
459            return "No files found.".to_string();
460        }
461
462        let mut output = String::new();
463        for (i, entry) in self.files.iter().enumerate() {
464            if i == self.selected {
465                output.push_str(&self.styles.cursor.render("> "));
466                let style = if entry.is_dir {
467                    &self.styles.directory
468                } else {
469                    &self.styles.file
470                };
471                output.push_str(&self.styles.selected.render(&style.render(&entry.name)));
472            } else {
473                output.push_str("  ");
474                let style = if entry.is_dir {
475                    &self.styles.directory
476                } else {
477                    &self.styles.file
478                };
479                output.push_str(&style.render(&entry.name));
480            }
481            output.push('\n');
482        }
483
484        output
485    }
486}