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**: Cross-platform hidden file detection (Windows attributes + dotfiles on Unix)
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//!         println!("Selected file: {:?}", path);
33//!     }
34//!     
35//!     // Update the filepicker
36//!     filepicker.update(msg)
37//! }
38//! ```
39//!
40//! # Customization
41//!
42//! ```rust
43//! use bubbletea_widgets::filepicker::{Model, Styles, FilepickerKeyMap};
44//! use lipgloss_extras::prelude::*;
45//!
46//! let mut filepicker = Model::new();
47//!
48//! // Customize styles
49//! filepicker.styles.cursor = Style::new().foreground(Color::from("cyan"));
50//! filepicker.styles.directory = Style::new().foreground(Color::from("blue")).bold(true);
51//! filepicker.styles.file = Style::new().foreground(Color::from("white"));
52//!
53//! // The keymap can also be customized if needed
54//! // filepicker.keymap = FilepickerKeyMap::default();
55//! ```
56//!
57//! # Key Bindings
58//!
59//! The default key bindings are:
60//!
61//! - `j`/`↓`: Move cursor down
62//! - `k`/`↑`: Move cursor up  
63//! - `l`/`→`/`Enter`: Open directory or select file
64//! - `h`/`←`/`Backspace`/`Esc`: Go back to parent directory
65//! - `PageUp`/`b`: Page up
66//! - `PageDown`/`f`: Page down
67
68use crate::key::{self, KeyMap};
69use bubbletea_rs::{Cmd, KeyMsg, Model as BubbleTeaModel, Msg};
70use lipgloss_extras::prelude::*;
71use std::path::{Path, PathBuf};
72use std::sync::atomic::{AtomicI64, Ordering};
73
74/// Global counter for generating unique filepicker instance IDs.
75static LAST_ID: AtomicI64 = AtomicI64::new(0);
76
77/// Generates a unique ID for filepicker instances.
78///
79/// This function provides thread-safe ID generation for distinguishing
80/// between multiple filepicker instances in the same application.
81///
82/// # Returns
83///
84/// A unique i64 identifier
85fn next_id() -> i64 {
86    LAST_ID.fetch_add(1, Ordering::SeqCst) + 1
87}
88
89/// Message type for handling errors during file system operations.
90///
91/// This message is sent when file system operations like reading directories fail.
92/// It contains the error description that can be displayed to the user.
93///
94/// # Examples
95///
96/// ```rust
97/// use bubbletea_widgets::filepicker::ErrorMsg;
98///
99/// let error_msg = ErrorMsg {
100///     err: "Permission denied".to_string(),
101/// };
102/// ```
103#[derive(Debug, Clone)]
104pub struct ErrorMsg {
105    /// The error message string.
106    pub err: String,
107}
108
109/// Message type for handling successful directory reads.
110///
111/// This message is sent when a directory is successfully read and contains
112/// the file entries found in that directory. The ID is used to match responses
113/// to the correct filepicker instance in applications with multiple pickers.
114///
115/// # Examples
116///
117/// ```rust
118/// use bubbletea_widgets::filepicker::{ReadDirMsg, FileEntry};
119/// use std::path::PathBuf;
120///
121/// let msg = ReadDirMsg {
122///     id: 1,
123///     entries: vec![
124///         FileEntry {
125///             name: "file.txt".to_string(),
126///             path: PathBuf::from("./file.txt"),
127///             is_dir: false,
128///             is_symlink: false,
129///             size: 1024,
130///             mode: 0o644,
131///             symlink_target: None,
132///         }
133///     ],
134/// };
135/// ```
136#[derive(Debug, Clone)]
137pub struct ReadDirMsg {
138    /// The ID of the filepicker instance that requested this read.
139    pub id: i64,
140    /// The file entries found in the directory.
141    pub entries: Vec<FileEntry>,
142}
143
144const MARGIN_BOTTOM: usize = 5;
145const FILE_SIZE_WIDTH: usize = 7;
146
147#[allow(dead_code)]
148const PADDING_LEFT: usize = 2;
149
150/// A simple stack implementation for managing navigation history.
151///
152/// This stack is used internally to remember cursor positions and viewport
153/// state when navigating into subdirectories, allowing the filepicker to
154/// restore the previous selection when navigating back.
155///
156/// # Examples
157///
158/// ```rust
159/// // Note: This is a private struct, shown for documentation purposes
160/// // let mut stack = Stack::new();
161/// // stack.push(5);
162/// // assert_eq!(stack.pop(), Some(5));
163/// ```
164#[derive(Debug, Clone, Default)]
165struct Stack {
166    items: Vec<usize>,
167}
168
169impl Stack {
170    fn new() -> Self {
171        Self { items: Vec::new() }
172    }
173
174    fn push(&mut self, item: usize) {
175        self.items.push(item);
176    }
177
178    fn pop(&mut self) -> Option<usize> {
179        self.items.pop()
180    }
181
182    #[allow(dead_code)]
183    fn len(&self) -> usize {
184        self.items.len()
185    }
186
187    fn is_empty(&self) -> bool {
188        self.items.is_empty()
189    }
190}
191
192/// Key bindings for filepicker navigation and interaction.
193///
194/// This struct defines all the keyboard shortcuts available in the file picker.
195/// Each binding supports multiple keys for the same action (e.g., both 'j' and '↓' for moving down).
196///
197/// # Examples
198///
199/// ```rust
200/// use bubbletea_widgets::filepicker::FilepickerKeyMap;
201/// use bubbletea_widgets::key::KeyMap;
202///
203/// let keymap = FilepickerKeyMap::default();
204/// let help_keys = keymap.short_help(); // Get keys for help display
205/// ```
206#[derive(Debug, Clone)]
207pub struct FilepickerKeyMap {
208    /// Key binding for jumping to the top of the file list.
209    /// Default: 'g'
210    pub go_to_top: key::Binding,
211    /// Key binding for jumping to the last item in the file list.
212    /// Default: 'G'
213    pub go_to_last: key::Binding,
214    /// Key binding for moving the cursor down in the file list.
215    /// Default: 'j', '↓'
216    pub down: key::Binding,
217    /// Key binding for moving the cursor up in the file list.
218    /// Default: 'k', '↑'
219    pub up: key::Binding,
220    /// Key binding for scrolling up a page in the file list.
221    /// Default: 'PageUp', 'K'
222    pub page_up: key::Binding,
223    /// Key binding for scrolling down a page in the file list.
224    /// Default: 'PageDown', 'J'
225    pub page_down: key::Binding,
226    /// Key binding for navigating back to the parent directory.
227    /// Default: 'h', '←', 'Backspace', 'Esc'
228    pub back: key::Binding,
229    /// Key binding for opening directories or selecting files.
230    /// Default: 'l', '→', 'Enter'
231    pub open: key::Binding,
232    /// Key binding for selecting the current file (alternative to open).
233    /// Default: 'Enter'
234    pub select: key::Binding,
235}
236
237impl Default for FilepickerKeyMap {
238    fn default() -> Self {
239        use crossterm::event::KeyCode;
240
241        Self {
242            go_to_top: key::Binding::new(vec![KeyCode::Char('g')]).with_help("g", "first"),
243            go_to_last: key::Binding::new(vec![KeyCode::Char('G')]).with_help("G", "last"),
244            down: key::Binding::new(vec![KeyCode::Char('j'), KeyCode::Down])
245                .with_help("j/↓", "down"),
246            up: key::Binding::new(vec![KeyCode::Char('k'), KeyCode::Up]).with_help("k/↑", "up"),
247            page_up: key::Binding::new(vec![KeyCode::PageUp, KeyCode::Char('K')])
248                .with_help("pgup/K", "page up"),
249            page_down: key::Binding::new(vec![KeyCode::PageDown, KeyCode::Char('J')])
250                .with_help("pgdn/J", "page down"),
251            back: key::Binding::new(vec![
252                KeyCode::Char('h'),
253                KeyCode::Backspace,
254                KeyCode::Left,
255                KeyCode::Esc,
256            ])
257            .with_help("h/←", "back"),
258            open: key::Binding::new(vec![KeyCode::Char('l'), KeyCode::Right, KeyCode::Enter])
259                .with_help("l/→", "open"),
260            select: key::Binding::new(vec![KeyCode::Enter]).with_help("enter", "select"),
261        }
262    }
263}
264
265impl KeyMap for FilepickerKeyMap {
266    fn short_help(&self) -> Vec<&key::Binding> {
267        vec![&self.up, &self.down, &self.open, &self.back]
268    }
269
270    fn full_help(&self) -> Vec<Vec<&key::Binding>> {
271        vec![
272            vec![&self.go_to_top, &self.go_to_last],
273            vec![&self.up, &self.down],
274            vec![&self.page_up, &self.page_down],
275            vec![&self.open, &self.back, &self.select],
276        ]
277    }
278}
279
280/// Visual styling configuration for the file picker.
281///
282/// This struct allows customization of colors and styles for different elements
283/// of the file picker interface, including the cursor, directories, files, and selected items.
284///
285/// # Examples
286///
287/// ```rust
288/// use bubbletea_widgets::filepicker::Styles;
289/// use lipgloss_extras::prelude::*;
290///
291/// let mut styles = Styles::default();
292/// styles.cursor = Style::new().foreground(Color::from("cyan"));
293/// styles.directory = Style::new().foreground(Color::from("blue")).bold(true);
294/// ```
295#[derive(Debug, Clone)]
296pub struct Styles {
297    /// Style for the cursor when disabled.
298    /// Default: foreground color 247 (gray)
299    pub disabled_cursor: Style,
300    /// Style for the cursor indicator (usually "> ").
301    /// Default: foreground color 212 (pink/magenta)
302    pub cursor: Style,
303    /// Style for symlink file names.
304    /// Default: foreground color 36 (cyan)
305    pub symlink: Style,
306    /// Style for directory names in the file list.
307    /// Default: foreground color 99 (purple)
308    pub directory: Style,
309    /// Style for regular file names in the file list.
310    /// Default: no special styling (terminal default)
311    pub file: Style,
312    /// Style for disabled/unselectable files.
313    /// Default: foreground color 243 (dark gray)
314    pub disabled_file: Style,
315    /// Style for file permissions display.
316    /// Default: foreground color 244 (gray)
317    pub permission: Style,
318    /// Style applied to the currently selected item (in addition to file/directory style).
319    /// Default: foreground color 212 (pink/magenta) and bold
320    pub selected: Style,
321    /// Style for disabled selected items.
322    /// Default: foreground color 247 (gray)
323    pub disabled_selected: Style,
324    /// Style for file size display.
325    /// Default: foreground color 240 (dark gray), right-aligned
326    pub file_size: Style,
327    /// Style for empty directory message.
328    /// Default: foreground color 240 (dark gray) with left padding
329    pub empty_directory: Style,
330}
331
332impl Default for Styles {
333    fn default() -> Self {
334        const FILE_SIZE_WIDTH: usize = 7;
335        const PADDING_LEFT: usize = 2;
336
337        Self {
338            disabled_cursor: Style::new().foreground(Color::from("247")),
339            cursor: Style::new().foreground(Color::from("212")),
340            symlink: Style::new().foreground(Color::from("36")),
341            directory: Style::new().foreground(Color::from("99")),
342            file: Style::new(),
343            disabled_file: Style::new().foreground(Color::from("243")),
344            permission: Style::new().foreground(Color::from("244")),
345            selected: Style::new().foreground(Color::from("212")).bold(true),
346            disabled_selected: Style::new().foreground(Color::from("247")),
347            file_size: Style::new()
348                .foreground(Color::from("240"))
349                .width(FILE_SIZE_WIDTH as i32),
350            empty_directory: Style::new()
351                .foreground(Color::from("240"))
352                .padding_left(PADDING_LEFT as i32),
353        }
354    }
355}
356
357/// Represents a single file or directory entry in the file picker.
358///
359/// This struct contains all the information needed to display and interact with
360/// a file system entry, including its name, full path, metadata, and type information.
361///
362/// # Examples
363///
364/// ```rust
365/// use bubbletea_widgets::filepicker::FileEntry;
366/// use std::path::PathBuf;
367///
368/// let entry = FileEntry {
369///     name: "example.txt".to_string(),
370///     path: PathBuf::from("/path/to/example.txt"),
371///     is_dir: false,
372///     is_symlink: false,
373///     size: 1024,
374///     mode: 0o644,
375///     symlink_target: None,
376/// };
377/// ```
378#[derive(Debug, Clone)]
379pub struct FileEntry {
380    /// The display name of the file or directory.
381    /// This is typically just the filename without the full path.
382    pub name: String,
383    /// The complete path to the file or directory.
384    pub path: PathBuf,
385    /// Whether this entry represents a directory (`true`) or a file (`false`).
386    pub is_dir: bool,
387    /// Whether this entry is a symbolic link.
388    pub is_symlink: bool,
389    /// File size in bytes.
390    pub size: u64,
391    /// File permissions mode.
392    pub mode: u32,
393    /// Target path if this is a symlink.
394    pub symlink_target: Option<PathBuf>,
395}
396
397/// The main file picker model containing all state and configuration.
398///
399/// This struct represents the complete state of the file picker, including the current
400/// directory, file list, selection state, and styling configuration. It implements
401/// the BubbleTeaModel trait for integration with bubbletea-rs applications.
402///
403/// # Examples
404///
405/// ```rust
406/// use bubbletea_widgets::filepicker::Model;
407/// use bubbletea_rs::Model as BubbleTeaModel;
408///
409/// // Create a new file picker
410/// let mut picker = Model::new();
411///
412/// // Or use the BubbleTeaModel::init() method
413/// let (picker, cmd) = Model::init();
414/// ```
415///
416/// # State Management
417///
418/// The model maintains:
419/// - Current directory being browsed
420/// - List of files and directories in the current location
421/// - Currently selected item index
422/// - Last selected file path (if any)
423/// - Styling and key binding configuration
424/// - Navigation history and viewport state
425#[derive(Debug, Clone)]
426pub struct Model {
427    id: i64,
428
429    /// Path is the path which the user has selected with the file picker.
430    pub path: String,
431
432    /// The directory currently being browsed.
433    /// This path is updated when navigating into subdirectories or back to parent directories.
434    pub current_directory: PathBuf,
435
436    /// AllowedTypes specifies which file types the user may select.
437    /// If empty the user may select any file.
438    pub allowed_types: Vec<String>,
439
440    /// Key bindings configuration for navigation and interaction.
441    /// Can be customized to change keyboard shortcuts.
442    pub keymap: FilepickerKeyMap,
443
444    files: Vec<FileEntry>,
445
446    /// Whether to show file permissions in the display.
447    pub show_permissions: bool,
448    /// Whether to show file sizes in the display.
449    pub show_size: bool,
450    /// Whether to show hidden files (dotfiles on Unix, Windows FILE_ATTRIBUTE_HIDDEN + dotfiles).
451    pub show_hidden: bool,
452    /// Whether directories can be selected.
453    pub dir_allowed: bool,
454    /// Whether files can be selected.
455    pub file_allowed: bool,
456
457    /// The name of the most recently selected file.
458    pub file_selected: String,
459
460    selected: usize,
461    selected_stack: Stack,
462
463    min: usize,
464    max: usize,
465    max_stack: Stack,
466    min_stack: Stack,
467
468    /// Height of the picker.
469    pub height: usize,
470    /// Whether height should automatically adjust to terminal size.
471    pub auto_height: bool,
472
473    /// The cursor string to display (e.g., "> ").
474    pub cursor: String,
475
476    /// Error message to display when directory operations fail.
477    pub error: Option<String>,
478
479    /// Visual styling configuration for different UI elements.
480    /// Can be customized to change colors and appearance.
481    pub styles: Styles,
482}
483
484/// Creates a new filepicker model with default styling and key bindings.
485///
486/// This function provides a convenient way to create a filepicker without
487/// having to call `Model::new()` directly. It matches the Go implementation's
488/// `New()` function for API compatibility.
489///
490/// # Returns
491///
492/// A new `Model` instance with default settings, starting in the current directory.
493///
494/// # Examples
495///
496/// ```rust
497/// use bubbletea_widgets::filepicker;
498///
499/// let picker = filepicker::new();
500/// assert_eq!(picker.current_directory.as_os_str(), ".");
501/// ```
502pub fn new() -> Model {
503    Model::new()
504}
505
506impl Model {
507    /// Creates a new file picker model with default settings.
508    ///
509    /// The file picker starts in the current working directory (".") and uses
510    /// default key bindings and styles. The file list is initially empty and will
511    /// be populated when the model is initialized or when directories are navigated.
512    ///
513    /// # Returns
514    ///
515    /// A new `Model` instance with default settings matching the Go implementation.
516    ///
517    /// # Examples
518    ///
519    /// ```rust
520    /// use bubbletea_widgets::filepicker::Model;
521    ///
522    /// let mut picker = Model::new();
523    /// assert_eq!(picker.current_directory.as_os_str(), ".");
524    /// assert!(picker.path.is_empty());
525    /// ```
526    pub fn new() -> Self {
527        Self {
528            id: next_id(),
529            path: String::new(),
530            current_directory: PathBuf::from("."),
531            allowed_types: Vec::new(),
532            keymap: FilepickerKeyMap::default(),
533            files: Vec::new(),
534            show_permissions: true,
535            show_size: true,
536            show_hidden: false,
537            dir_allowed: false,
538            file_allowed: true,
539            file_selected: String::new(),
540            selected: 0,
541            selected_stack: Stack::new(),
542            min: 0,
543            max: 0,
544            max_stack: Stack::new(),
545            min_stack: Stack::new(),
546            height: 0,
547            auto_height: true,
548            cursor: ">".to_string(),
549            error: None,
550            styles: Styles::default(),
551        }
552    }
553
554    /// Sets the height of the filepicker viewport.
555    ///
556    /// This controls how many file entries are visible at once. The viewport
557    /// automatically adjusts to show the selected item within the visible range.
558    ///
559    /// # Arguments
560    ///
561    /// * `height` - The number of lines to show in the file list
562    ///
563    /// # Examples
564    ///
565    /// ```rust
566    /// use bubbletea_widgets::filepicker::Model;
567    ///
568    /// let mut picker = Model::new();
569    /// picker.set_height(10); // Show 10 files at a time
570    /// ```
571    pub fn set_height(&mut self, height: usize) {
572        self.height = height;
573        if self.max > self.height.saturating_sub(1) {
574            self.max = self.min + self.height - 1;
575        }
576    }
577
578    fn push_view(&mut self, selected: usize, minimum: usize, maximum: usize) {
579        self.selected_stack.push(selected);
580        self.min_stack.push(minimum);
581        self.max_stack.push(maximum);
582    }
583
584    fn pop_view(&mut self) -> (usize, usize, usize) {
585        let selected = self.selected_stack.pop().unwrap_or(0);
586        let min = self.min_stack.pop().unwrap_or(0);
587        let max = self.max_stack.pop().unwrap_or(0);
588        (selected, min, max)
589    }
590
591    /// Returns whether a user has selected a file with the given message.
592    ///
593    /// This function checks if the message represents a file selection action
594    /// and if the selected file is allowed based on the current configuration.
595    /// It only returns `true` for files that can actually be selected.
596    ///
597    /// # Arguments
598    ///
599    /// * `msg` - The message to check for file selection
600    ///
601    /// # Returns
602    ///
603    /// A tuple containing:
604    /// - `bool`: Whether a valid file was selected
605    /// - `String`: The path of the selected file (empty if no selection)
606    ///
607    /// # Examples
608    ///
609    /// ```no_run
610    /// use bubbletea_widgets::filepicker::Model;
611    /// use bubbletea_rs::Msg;
612    ///
613    /// let picker = Model::new();
614    /// // In your application's update loop:
615    /// // let (selected, path) = picker.did_select_file(&msg);
616    /// // if selected {
617    /// //     println!("User selected: {}", path);
618    /// // }
619    /// ```
620    pub fn did_select_file(&self, msg: &Msg) -> (bool, String) {
621        let (did_select, path) = self.did_select_file_internal(msg);
622        if did_select && self.can_select(&path) {
623            (true, path)
624        } else {
625            (false, String::new())
626        }
627    }
628
629    /// Returns whether a user tried to select a disabled file with the given message.
630    ///
631    /// This function is useful for providing feedback when users attempt to select
632    /// files that are not allowed based on the current `allowed_types` configuration.
633    /// Use this to show warning messages or provide helpful feedback.
634    ///
635    /// # Arguments
636    ///
637    /// * `msg` - The message to check for disabled file selection attempts
638    ///
639    /// # Returns
640    ///
641    /// A tuple containing:
642    /// - `bool`: Whether a disabled file selection was attempted
643    /// - `String`: The path of the disabled file (empty if no disabled selection)
644    ///
645    /// # Examples
646    ///
647    /// ```no_run
648    /// use bubbletea_widgets::filepicker::Model;
649    /// use bubbletea_rs::Msg;
650    ///
651    /// let mut picker = Model::new();
652    /// picker.allowed_types = vec![".txt".to_string()];
653    ///
654    /// // In your application's update loop:
655    /// // let (tried_disabled, path) = picker.did_select_disabled_file(&msg);
656    /// // if tried_disabled {
657    /// //     eprintln!("Cannot select {}: file type not allowed", path);
658    /// // }
659    /// ```
660    pub fn did_select_disabled_file(&self, msg: &Msg) -> (bool, String) {
661        let (did_select, path) = self.did_select_file_internal(msg);
662        if did_select && !self.can_select(&path) {
663            (true, path)
664        } else {
665            (false, String::new())
666        }
667    }
668
669    fn did_select_file_internal(&self, msg: &Msg) -> (bool, String) {
670        if self.files.is_empty() {
671            return (false, String::new());
672        }
673
674        if let Some(key_msg) = msg.downcast_ref::<KeyMsg>() {
675            // If the msg does not match the Select keymap then this could not have been a selection.
676            if !self.keymap.select.matches(key_msg) {
677                return (false, String::new());
678            }
679
680            // The key press was a selection, let's confirm whether the current file could
681            // be selected or used for navigating deeper into the stack.
682            let f = &self.files[self.selected];
683            let is_dir = f.is_dir;
684
685            if (!is_dir && self.file_allowed)
686                || (is_dir && self.dir_allowed) && !self.path.is_empty()
687            {
688                return (true, self.path.clone());
689            }
690        }
691        (false, String::new())
692    }
693
694    fn can_select(&self, file: &str) -> bool {
695        if self.allowed_types.is_empty() {
696            return true;
697        }
698
699        for ext in &self.allowed_types {
700            if file.ends_with(ext) {
701                return true;
702            }
703        }
704        false
705    }
706
707    /// Reads the current directory and populates the files list.
708    /// Clears any existing files and error state before reading.
709    pub fn read_dir(&mut self) {
710        self.files.clear();
711        self.error = None;
712        match std::fs::read_dir(&self.current_directory) {
713            Ok(entries) => {
714                for entry in entries.flatten() {
715                    let path = entry.path();
716                    let name = path
717                        .file_name()
718                        .and_then(|n| n.to_str())
719                        .unwrap_or("?")
720                        .to_string();
721
722                    // Skip hidden files if not showing them
723                    if !self.show_hidden && is_hidden(&path, &name) {
724                        continue;
725                    }
726
727                    // Get file metadata
728                    let (is_dir, is_symlink, size, mode, symlink_target) =
729                        if let Ok(metadata) = entry.metadata() {
730                            let is_symlink = metadata.file_type().is_symlink();
731                            let mut is_dir = metadata.is_dir();
732                            let size = metadata.len();
733
734                            #[cfg(unix)]
735                            let mode = {
736                                use std::os::unix::fs::PermissionsExt;
737                                metadata.permissions().mode()
738                            };
739                            #[cfg(not(unix))]
740                            let mode = 0;
741
742                            // Handle symlink resolution
743                            let symlink_target = if is_symlink {
744                                match std::fs::canonicalize(&path) {
745                                    Ok(target) => {
746                                        // Check if symlink points to a directory
747                                        if let Ok(target_meta) = std::fs::metadata(&target) {
748                                            if target_meta.is_dir() {
749                                                is_dir = true;
750                                            }
751                                        }
752                                        Some(target)
753                                    }
754                                    Err(_) => None,
755                                }
756                            } else {
757                                None
758                            };
759
760                            (is_dir, is_symlink, size, mode, symlink_target)
761                        } else {
762                            (path.is_dir(), false, 0, 0, None)
763                        };
764
765                    self.files.push(FileEntry {
766                        name,
767                        path,
768                        is_dir,
769                        is_symlink,
770                        size,
771                        mode,
772                        symlink_target,
773                    });
774                }
775
776                // Sort directories first, then files, then alphabetically
777                self.files
778                    .sort_by(|a, b| b.is_dir.cmp(&a.is_dir).then_with(|| a.name.cmp(&b.name)));
779
780                self.selected = 0;
781                self.max = std::cmp::max(self.max, self.height.saturating_sub(1));
782            }
783            Err(err) => {
784                self.error = Some(format!("Failed to read directory: {}", err));
785            }
786        }
787    }
788
789    /// Creates a command to read the current directory.
790    ///
791    /// This method allows external code to trigger a directory read without
792    /// directly calling the private `read_dir` method. It's useful for
793    /// initializing the filepicker with a specific directory.
794    ///
795    /// # Returns
796    ///
797    /// A command that will trigger a ReadDirMsg when executed
798    ///
799    /// # Examples
800    ///
801    /// ```rust
802    /// use bubbletea_widgets::filepicker::Model;
803    ///
804    /// let mut picker = Model::new();
805    /// picker.current_directory = std::path::PathBuf::from("/home/user");
806    /// let cmd = picker.read_dir_cmd();
807    /// // This command can be returned from init() or update()
808    /// ```
809    pub fn read_dir_cmd(&self) -> Cmd {
810        // Use bubbletea_rs tick with minimal delay to create an immediate command
811        let current_dir = self.current_directory.clone();
812        let id = self.id;
813
814        bubbletea_rs::tick(std::time::Duration::from_nanos(1), move |_| {
815            let mut entries = Vec::new();
816
817            if let Ok(dir_entries) = std::fs::read_dir(&current_dir) {
818                for entry in dir_entries.flatten() {
819                    let path = entry.path();
820                    let name = path
821                        .file_name()
822                        .and_then(|n| n.to_str())
823                        .unwrap_or("?")
824                        .to_string();
825
826                    // Skip hidden files by default (can be configured later)
827                    if name.starts_with('.') {
828                        continue;
829                    }
830
831                    // Get file metadata
832                    let (is_dir, is_symlink, size, mode, symlink_target) =
833                        if let Ok(metadata) = entry.metadata() {
834                            let is_symlink = metadata.file_type().is_symlink();
835                            let mut is_dir = metadata.is_dir();
836                            let size = metadata.len();
837
838                            #[cfg(unix)]
839                            let mode = {
840                                use std::os::unix::fs::PermissionsExt;
841                                metadata.permissions().mode()
842                            };
843                            #[cfg(not(unix))]
844                            let mode = 0;
845
846                            // Handle symlink resolution
847                            let symlink_target = if is_symlink {
848                                match std::fs::canonicalize(&path) {
849                                    Ok(target) => {
850                                        // Check if symlink points to a directory
851                                        if let Ok(target_meta) = std::fs::metadata(&target) {
852                                            if target_meta.is_dir() {
853                                                is_dir = true;
854                                            }
855                                        }
856                                        Some(target)
857                                    }
858                                    Err(_) => None,
859                                }
860                            } else {
861                                None
862                            };
863
864                            (is_dir, is_symlink, size, mode, symlink_target)
865                        } else {
866                            (path.is_dir(), false, 0, 0, None)
867                        };
868
869                    entries.push(FileEntry {
870                        name,
871                        path,
872                        is_dir,
873                        is_symlink,
874                        size,
875                        mode,
876                        symlink_target,
877                    });
878                }
879            }
880
881            // Sort directories first, then files, then alphabetically
882            entries.sort_by(|a, b| b.is_dir.cmp(&a.is_dir).then_with(|| a.name.cmp(&b.name)));
883
884            Box::new(ReadDirMsg { id, entries }) as Msg
885        })
886    }
887}
888
889impl Default for Model {
890    fn default() -> Self {
891        Self::new()
892    }
893}
894
895/// Determines whether a file is hidden based on its name.
896///
897/// This function matches the Go implementation's `IsHidden` function and provides
898/// basic hidden file detection for compatibility. It only checks if the filename
899/// starts with a dot (dotfile convention on Unix systems).
900///
901/// For more comprehensive hidden file detection that includes Windows file attributes,
902/// use the internal `is_hidden()` function instead.
903///
904/// # Arguments
905///
906/// * `name` - The filename to check
907///
908/// # Returns
909///
910/// A tuple containing:
911/// - `bool`: Whether the file is hidden (starts with '.')
912/// - `Option<String>`: Always `None` in this implementation (for Go compatibility)
913///
914/// # Examples
915///
916/// ```rust
917/// use bubbletea_widgets::filepicker::is_hidden_name;
918///
919/// let (hidden, _) = is_hidden_name(".hidden_file");
920/// assert!(hidden);
921///
922/// let (hidden, _) = is_hidden_name("visible_file.txt");
923/// assert!(!hidden);
924/// ```
925pub fn is_hidden_name(name: &str) -> (bool, Option<String>) {
926    let is_hidden = name.starts_with('.');
927    (is_hidden, None)
928}
929
930/// Determines whether a file or directory should be considered hidden.
931///
932/// This function implements cross-platform hidden file detection:
933/// - On Windows: Checks the FILE_ATTRIBUTE_HIDDEN attribute, with dotfiles as fallback
934/// - On Unix-like systems: Files/directories starting with '.' are considered hidden
935///
936/// # Arguments
937///
938/// * `path` - The full path to the file or directory
939/// * `name` - The filename/directory name (used as fallback on Windows)
940///
941/// # Returns
942///
943/// `true` if the file should be hidden, `false` otherwise
944#[inline]
945fn is_hidden(path: &Path, name: &str) -> bool {
946    is_hidden_impl(path, name)
947}
948
949fn is_hidden_impl(path: &Path, name: &str) -> bool {
950    #[cfg(target_os = "windows")]
951    {
952        // On Windows, check file attributes for hidden flag
953        if let Ok(metadata) = std::fs::metadata(path) {
954            use std::os::windows::fs::MetadataExt;
955            const FILE_ATTRIBUTE_HIDDEN: u32 = 0x2;
956
957            // Check if file has hidden attribute
958            if metadata.file_attributes() & FILE_ATTRIBUTE_HIDDEN != 0 {
959                return true;
960            }
961        }
962
963        // Fallback: also consider dotfiles as hidden on Windows
964        name.starts_with('.')
965    }
966    #[cfg(not(target_os = "windows"))]
967    {
968        // On Unix-like systems, files starting with '.' are hidden
969        let _ = path; // Unused on Unix systems
970        name.starts_with('.')
971    }
972}
973
974impl BubbleTeaModel for Model {
975    fn init() -> (Self, Option<Cmd>) {
976        let mut model = Self::new();
977        model.read_dir();
978        (model, None)
979    }
980
981    fn update(&mut self, msg: Msg) -> Option<Cmd> {
982        // Handle window size messages FIRST to ensure height is set correctly
983        if let Some(_window_msg) = msg.downcast_ref::<bubbletea_rs::WindowSizeMsg>() {
984            if self.auto_height {
985                self.height = (_window_msg.height as usize).saturating_sub(MARGIN_BOTTOM);
986            }
987            // Update max based on new height, but ensure it doesn't exceed file count
988            self.max = if self.files.is_empty() {
989                self.height.saturating_sub(1)
990            } else {
991                std::cmp::min(
992                    self.height.saturating_sub(1),
993                    self.files.len().saturating_sub(1),
994                )
995            };
996
997            // Adjust min if necessary to keep viewport consistent
998            if self.max < self.selected {
999                self.min = self.selected.saturating_sub(self.height.saturating_sub(1));
1000                self.max = self.selected;
1001            }
1002            return None;
1003        }
1004
1005        // Handle readDirMsg and errorMsg (would be async in real implementation)
1006        if let Some(read_dir_msg) = msg.downcast_ref::<ReadDirMsg>() {
1007            if read_dir_msg.id == self.id {
1008                self.files = read_dir_msg.entries.clone();
1009
1010                // Calculate max properly based on current height and file count
1011                if self.files.is_empty() {
1012                    self.max = 0;
1013                } else {
1014                    // Ensure max doesn't exceed available files or viewport height
1015                    let viewport_max = self.height.saturating_sub(1);
1016                    let file_max = self.files.len().saturating_sub(1);
1017                    self.max = std::cmp::min(viewport_max, file_max);
1018
1019                    // Ensure selected index is within bounds
1020                    if self.selected >= self.files.len() {
1021                        self.selected = file_max;
1022                    }
1023
1024                    // Adjust viewport if selected item is outside current view
1025                    if self.selected > self.max {
1026                        self.min = self.selected.saturating_sub(viewport_max);
1027                        self.max = self.selected;
1028                    } else if self.selected < self.min {
1029                        self.min = self.selected;
1030                        self.max = std::cmp::min(self.min + viewport_max, file_max);
1031                    }
1032                }
1033            }
1034            return None;
1035        }
1036
1037        if let Some(key_msg) = msg.downcast_ref::<KeyMsg>() {
1038            match key_msg {
1039                key_msg if self.keymap.go_to_top.matches(key_msg) => {
1040                    self.selected = 0;
1041                    self.min = 0;
1042                    self.max = self.height.saturating_sub(1);
1043                }
1044                key_msg if self.keymap.go_to_last.matches(key_msg) => {
1045                    self.selected = self.files.len().saturating_sub(1);
1046                    self.min = self.files.len().saturating_sub(self.height);
1047                    self.max = self.files.len().saturating_sub(1);
1048                }
1049                key_msg if self.keymap.down.matches(key_msg) => {
1050                    if self.selected < self.files.len().saturating_sub(1) {
1051                        self.selected += 1;
1052                    }
1053                    if self.selected > self.max {
1054                        self.min += 1;
1055                        self.max += 1;
1056                    }
1057                }
1058                key_msg if self.keymap.up.matches(key_msg) => {
1059                    self.selected = self.selected.saturating_sub(1);
1060                    if self.selected < self.min {
1061                        self.min = self.min.saturating_sub(1);
1062                        self.max = self.max.saturating_sub(1);
1063                    }
1064                }
1065                key_msg if self.keymap.page_down.matches(key_msg) => {
1066                    self.selected += self.height;
1067                    if self.selected >= self.files.len() {
1068                        self.selected = self.files.len().saturating_sub(1);
1069                    }
1070                    self.min += self.height;
1071                    self.max += self.height;
1072
1073                    if self.max >= self.files.len() {
1074                        self.max = self.files.len().saturating_sub(1);
1075                        self.min = self.max.saturating_sub(self.height);
1076                    }
1077                }
1078                key_msg if self.keymap.page_up.matches(key_msg) => {
1079                    self.selected = self.selected.saturating_sub(self.height);
1080                    self.min = self.min.saturating_sub(self.height);
1081                    self.max = self.max.saturating_sub(self.height);
1082
1083                    if self.min == 0 {
1084                        self.min = 0;
1085                        self.max = self.min + self.height;
1086                    }
1087                }
1088                key_msg if self.keymap.back.matches(key_msg) => {
1089                    if let Some(parent) = self.current_directory.parent() {
1090                        self.current_directory = parent.to_path_buf();
1091                        if !self.selected_stack.is_empty() {
1092                            let (selected, min, max) = self.pop_view();
1093                            self.selected = selected;
1094                            self.min = min;
1095                            self.max = max;
1096                        } else {
1097                            self.selected = 0;
1098                            self.min = 0;
1099                            self.max = self.height.saturating_sub(1);
1100                        }
1101                        self.read_dir();
1102                    }
1103                }
1104                key_msg if self.keymap.open.matches(key_msg) && !self.files.is_empty() => {
1105                    let f = &self.files[self.selected].clone();
1106                    let mut is_dir = f.is_dir;
1107
1108                    // Handle symlinks
1109                    if f.is_symlink {
1110                        if let Some(target) = &f.symlink_target {
1111                            if target.is_dir() {
1112                                is_dir = true;
1113                            }
1114                        }
1115                    }
1116
1117                    // Check if we can select this file/directory
1118                    if ((!is_dir && self.file_allowed) || (is_dir && self.dir_allowed))
1119                        && self.keymap.select.matches(key_msg)
1120                    {
1121                        // Select the current path as the selection
1122                        self.path = f.path.to_string_lossy().to_string();
1123                    }
1124
1125                    // Navigate into directory
1126                    if is_dir {
1127                        self.push_view(self.selected, self.min, self.max);
1128                        self.current_directory = f.path.clone();
1129                        self.selected = 0;
1130                        self.min = 0;
1131                        self.max = self.height.saturating_sub(1);
1132                        self.read_dir();
1133                    } else {
1134                        // Set the selected file path
1135                        self.path = f.path.to_string_lossy().to_string();
1136                    }
1137                }
1138                _ => {}
1139            }
1140        }
1141        None
1142    }
1143
1144    fn view(&self) -> String {
1145        // Display error if present
1146        if let Some(error) = &self.error {
1147            return self
1148                .styles
1149                .empty_directory
1150                .clone()
1151                .height(self.height as i32)
1152                .max_height(self.height as i32)
1153                .render(error);
1154        }
1155
1156        if self.files.is_empty() {
1157            return self
1158                .styles
1159                .empty_directory
1160                .clone()
1161                .height(self.height as i32)
1162                .max_height(self.height as i32)
1163                .render("Bummer. No Files Found.");
1164        }
1165
1166        let mut output = String::new();
1167
1168        for (i, f) in self.files.iter().enumerate() {
1169            if i < self.min || i > self.max {
1170                continue;
1171            }
1172
1173            let size = format_file_size(f.size);
1174            let disabled = !self.can_select(&f.name) && !f.is_dir;
1175
1176            if self.selected == i {
1177                let mut selected_line = String::new();
1178
1179                if self.show_permissions {
1180                    selected_line.push(' ');
1181                    selected_line.push_str(&format_mode(f.mode));
1182                }
1183
1184                if self.show_size {
1185                    selected_line.push_str(&format!("{:>width$}", size, width = FILE_SIZE_WIDTH));
1186                }
1187
1188                selected_line.push(' ');
1189                selected_line.push_str(&f.name);
1190
1191                if f.is_symlink {
1192                    if let Some(target) = &f.symlink_target {
1193                        selected_line.push_str(" → ");
1194                        selected_line.push_str(&target.to_string_lossy());
1195                    }
1196                }
1197
1198                if disabled {
1199                    output.push_str(&self.styles.disabled_cursor.render(&self.cursor));
1200                    output.push_str(&self.styles.disabled_selected.render(&selected_line));
1201                } else {
1202                    output.push_str(&self.styles.cursor.render(&self.cursor));
1203                    output.push_str(&self.styles.selected.render(&selected_line));
1204                }
1205                output.push('\n');
1206                continue;
1207            }
1208
1209            // Non-selected items
1210            let style = if f.is_dir {
1211                &self.styles.directory
1212            } else if f.is_symlink {
1213                &self.styles.symlink
1214            } else if disabled {
1215                &self.styles.disabled_file
1216            } else {
1217                &self.styles.file
1218            };
1219
1220            let mut file_name = style.render(&f.name);
1221            output.push_str(&self.styles.cursor.render(" "));
1222
1223            if f.is_symlink {
1224                if let Some(target) = &f.symlink_target {
1225                    file_name.push_str(" → ");
1226                    file_name.push_str(&target.to_string_lossy());
1227                }
1228            }
1229
1230            if self.show_permissions {
1231                output.push(' ');
1232                output.push_str(&self.styles.permission.render(&format_mode(f.mode)));
1233            }
1234
1235            if self.show_size {
1236                output.push_str(&self.styles.file_size.render(&size));
1237            }
1238
1239            output.push(' ');
1240            output.push_str(&file_name);
1241            output.push('\n');
1242        }
1243
1244        // Pad to fill height
1245        let current_height = output.lines().count();
1246        for _ in current_height..=self.height {
1247            output.push('\n');
1248        }
1249
1250        output
1251    }
1252}
1253
1254/// Formats file size in human-readable format, similar to go-humanize.
1255///
1256/// Converts byte sizes into readable format using decimal units (1000-based).
1257/// This matches the behavior of the Go humanize library used in the original implementation.
1258///
1259/// # Arguments
1260///
1261/// * `size` - The file size in bytes
1262///
1263/// # Returns
1264///
1265/// A formatted string representation of the file size (e.g., "1.2kB", "45MB")
1266///
1267/// # Examples
1268///
1269/// ```rust
1270/// // Note: This is a private function, shown for documentation purposes
1271/// // format_file_size(1024) -> "1.0kB"
1272/// // format_file_size(1500000) -> "1.5MB"
1273/// ```
1274fn format_file_size(size: u64) -> String {
1275    const UNITS: &[&str] = &["B", "kB", "MB", "GB", "TB", "PB"];
1276
1277    if size == 0 {
1278        return "0B".to_string();
1279    }
1280
1281    let mut size_f = size as f64;
1282    let mut unit_index = 0;
1283
1284    while size_f >= 1000.0 && unit_index < UNITS.len() - 1 {
1285        size_f /= 1000.0;
1286        unit_index += 1;
1287    }
1288
1289    if unit_index == 0 {
1290        format!("{}B", size)
1291    } else if size_f >= 100.0 {
1292        format!("{:.0}{}", size_f, UNITS[unit_index])
1293    } else {
1294        format!("{:.1}{}", size_f, UNITS[unit_index])
1295    }
1296}
1297
1298/// Formats file mode/permissions in Unix style.
1299///
1300/// Converts Unix file permission bits into a human-readable string representation
1301/// similar to what `ls -l` displays. The format is a 10-character string where:
1302/// - First character: file type (d=directory, l=symlink, -=regular file, etc.)
1303/// - Next 9 characters: permissions in rwx format for owner, group, and others
1304///
1305/// # Arguments
1306///
1307/// * `mode` - The file mode bits from file metadata
1308///
1309/// # Returns
1310///
1311/// A 10-character permission string (e.g., "drwxr-xr-x", "-rw-r--r--")
1312///
1313/// # Examples
1314///
1315/// ```rust
1316/// // Note: This is a private function, shown for documentation purposes
1317/// // format_mode(0o755) -> "-rwxr-xr-x"
1318/// // format_mode(0o644) -> "-rw-r--r--"
1319/// ```
1320#[cfg(unix)]
1321fn format_mode(mode: u32) -> String {
1322    // Use standard Rust constants instead of libc for better type compatibility
1323    const S_IFMT: u32 = 0o170000;
1324    const S_IFDIR: u32 = 0o040000;
1325    const S_IFLNK: u32 = 0o120000;
1326    const S_IFBLK: u32 = 0o060000;
1327    const S_IFCHR: u32 = 0o020000;
1328    const S_IFIFO: u32 = 0o010000;
1329    const S_IFSOCK: u32 = 0o140000;
1330
1331    const S_IRUSR: u32 = 0o400;
1332    const S_IWUSR: u32 = 0o200;
1333    const S_IXUSR: u32 = 0o100;
1334    const S_IRGRP: u32 = 0o040;
1335    const S_IWGRP: u32 = 0o020;
1336    const S_IXGRP: u32 = 0o010;
1337    const S_IROTH: u32 = 0o004;
1338    const S_IWOTH: u32 = 0o002;
1339    const S_IXOTH: u32 = 0o001;
1340
1341    let file_type = match mode & S_IFMT {
1342        S_IFDIR => 'd',
1343        S_IFLNK => 'l',
1344        S_IFBLK => 'b',
1345        S_IFCHR => 'c',
1346        S_IFIFO => 'p',
1347        S_IFSOCK => 's',
1348        _ => '-',
1349    };
1350
1351    let owner_perms = format!(
1352        "{}{}{}",
1353        if mode & S_IRUSR != 0 { 'r' } else { '-' },
1354        if mode & S_IWUSR != 0 { 'w' } else { '-' },
1355        if mode & S_IXUSR != 0 { 'x' } else { '-' }
1356    );
1357
1358    let group_perms = format!(
1359        "{}{}{}",
1360        if mode & S_IRGRP != 0 { 'r' } else { '-' },
1361        if mode & S_IWGRP != 0 { 'w' } else { '-' },
1362        if mode & S_IXGRP != 0 { 'x' } else { '-' }
1363    );
1364
1365    let other_perms = format!(
1366        "{}{}{}",
1367        if mode & S_IROTH != 0 { 'r' } else { '-' },
1368        if mode & S_IWOTH != 0 { 'w' } else { '-' },
1369        if mode & S_IXOTH != 0 { 'x' } else { '-' }
1370    );
1371
1372    format!("{}{}{}{}", file_type, owner_perms, group_perms, other_perms)
1373}
1374
1375/// Formats file mode/permissions on non-Unix systems.
1376///
1377/// On non-Unix systems (primarily Windows), file permissions don't use
1378/// the Unix rwx model, so this function returns a placeholder string.
1379///
1380/// # Arguments
1381///
1382/// * `_mode` - The file mode (ignored on non-Unix systems)
1383///
1384/// # Returns
1385///
1386/// A placeholder permission string "----------"
1387#[cfg(not(unix))]
1388fn format_mode(_mode: u32) -> String {
1389    "----------".to_string()
1390}