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 fn read_dir(&mut self) {
708 self.files.clear();
709 self.error = None;
710 match std::fs::read_dir(&self.current_directory) {
711 Ok(entries) => {
712 for entry in entries.flatten() {
713 let path = entry.path();
714 let name = path
715 .file_name()
716 .and_then(|n| n.to_str())
717 .unwrap_or("?")
718 .to_string();
719
720 // Skip hidden files if not showing them
721 if !self.show_hidden && is_hidden(&path, &name) {
722 continue;
723 }
724
725 // Get file metadata
726 let (is_dir, is_symlink, size, mode, symlink_target) =
727 if let Ok(metadata) = entry.metadata() {
728 let is_symlink = metadata.file_type().is_symlink();
729 let mut is_dir = metadata.is_dir();
730 let size = metadata.len();
731
732 #[cfg(unix)]
733 let mode = {
734 use std::os::unix::fs::PermissionsExt;
735 metadata.permissions().mode()
736 };
737 #[cfg(not(unix))]
738 let mode = 0;
739
740 // Handle symlink resolution
741 let symlink_target = if is_symlink {
742 match std::fs::canonicalize(&path) {
743 Ok(target) => {
744 // Check if symlink points to a directory
745 if let Ok(target_meta) = std::fs::metadata(&target) {
746 if target_meta.is_dir() {
747 is_dir = true;
748 }
749 }
750 Some(target)
751 }
752 Err(_) => None,
753 }
754 } else {
755 None
756 };
757
758 (is_dir, is_symlink, size, mode, symlink_target)
759 } else {
760 (path.is_dir(), false, 0, 0, None)
761 };
762
763 self.files.push(FileEntry {
764 name,
765 path,
766 is_dir,
767 is_symlink,
768 size,
769 mode,
770 symlink_target,
771 });
772 }
773
774 // Sort directories first, then files, then alphabetically
775 self.files
776 .sort_by(|a, b| b.is_dir.cmp(&a.is_dir).then_with(|| a.name.cmp(&b.name)));
777
778 self.selected = 0;
779 self.max = std::cmp::max(self.max, self.height.saturating_sub(1));
780 }
781 Err(err) => {
782 self.error = Some(format!("Failed to read directory: {}", err));
783 }
784 }
785 }
786}
787
788impl Default for Model {
789 fn default() -> Self {
790 Self::new()
791 }
792}
793
794/// Determines whether a file is hidden based on its name.
795///
796/// This function matches the Go implementation's `IsHidden` function and provides
797/// basic hidden file detection for compatibility. It only checks if the filename
798/// starts with a dot (dotfile convention on Unix systems).
799///
800/// For more comprehensive hidden file detection that includes Windows file attributes,
801/// use the internal `is_hidden()` function instead.
802///
803/// # Arguments
804///
805/// * `name` - The filename to check
806///
807/// # Returns
808///
809/// A tuple containing:
810/// - `bool`: Whether the file is hidden (starts with '.')
811/// - `Option<String>`: Always `None` in this implementation (for Go compatibility)
812///
813/// # Examples
814///
815/// ```rust
816/// use bubbletea_widgets::filepicker::is_hidden_name;
817///
818/// let (hidden, _) = is_hidden_name(".hidden_file");
819/// assert!(hidden);
820///
821/// let (hidden, _) = is_hidden_name("visible_file.txt");
822/// assert!(!hidden);
823/// ```
824pub fn is_hidden_name(name: &str) -> (bool, Option<String>) {
825 let is_hidden = name.starts_with('.');
826 (is_hidden, None)
827}
828
829/// Determines whether a file or directory should be considered hidden.
830///
831/// This function implements cross-platform hidden file detection:
832/// - On Windows: Checks the FILE_ATTRIBUTE_HIDDEN attribute, with dotfiles as fallback
833/// - On Unix-like systems: Files/directories starting with '.' are considered hidden
834///
835/// # Arguments
836///
837/// * `path` - The full path to the file or directory
838/// * `name` - The filename/directory name (used as fallback on Windows)
839///
840/// # Returns
841///
842/// `true` if the file should be hidden, `false` otherwise
843#[inline]
844fn is_hidden(path: &Path, name: &str) -> bool {
845 is_hidden_impl(path, name)
846}
847
848fn is_hidden_impl(path: &Path, name: &str) -> bool {
849 #[cfg(target_os = "windows")]
850 {
851 // On Windows, check file attributes for hidden flag
852 if let Ok(metadata) = std::fs::metadata(path) {
853 use std::os::windows::fs::MetadataExt;
854 const FILE_ATTRIBUTE_HIDDEN: u32 = 0x2;
855
856 // Check if file has hidden attribute
857 if metadata.file_attributes() & FILE_ATTRIBUTE_HIDDEN != 0 {
858 return true;
859 }
860 }
861
862 // Fallback: also consider dotfiles as hidden on Windows
863 name.starts_with('.')
864 }
865 #[cfg(not(target_os = "windows"))]
866 {
867 // On Unix-like systems, files starting with '.' are hidden
868 let _ = path; // Unused on Unix systems
869 name.starts_with('.')
870 }
871}
872
873impl BubbleTeaModel for Model {
874 fn init() -> (Self, Option<Cmd>) {
875 let mut model = Self::new();
876 model.read_dir();
877 (model, None)
878 }
879
880 fn update(&mut self, msg: Msg) -> Option<Cmd> {
881 // Handle readDirMsg and errorMsg (would be async in real implementation)
882 if let Some(read_dir_msg) = msg.downcast_ref::<ReadDirMsg>() {
883 if read_dir_msg.id == self.id {
884 self.files = read_dir_msg.entries.clone();
885 self.max = std::cmp::max(self.max, self.height.saturating_sub(1));
886 }
887 return None;
888 }
889
890 // Handle window size messages
891 if let Some(_window_msg) = msg.downcast_ref::<bubbletea_rs::WindowSizeMsg>() {
892 if self.auto_height {
893 self.height = (_window_msg.height as usize).saturating_sub(MARGIN_BOTTOM);
894 }
895 self.max = self.height.saturating_sub(1);
896 return None;
897 }
898
899 if let Some(key_msg) = msg.downcast_ref::<KeyMsg>() {
900 match key_msg {
901 key_msg if self.keymap.go_to_top.matches(key_msg) => {
902 self.selected = 0;
903 self.min = 0;
904 self.max = self.height.saturating_sub(1);
905 }
906 key_msg if self.keymap.go_to_last.matches(key_msg) => {
907 self.selected = self.files.len().saturating_sub(1);
908 self.min = self.files.len().saturating_sub(self.height);
909 self.max = self.files.len().saturating_sub(1);
910 }
911 key_msg if self.keymap.down.matches(key_msg) => {
912 if self.selected < self.files.len().saturating_sub(1) {
913 self.selected += 1;
914 }
915 if self.selected > self.max {
916 self.min += 1;
917 self.max += 1;
918 }
919 }
920 key_msg if self.keymap.up.matches(key_msg) => {
921 self.selected = self.selected.saturating_sub(1);
922 if self.selected < self.min {
923 self.min = self.min.saturating_sub(1);
924 self.max = self.max.saturating_sub(1);
925 }
926 }
927 key_msg if self.keymap.page_down.matches(key_msg) => {
928 self.selected += self.height;
929 if self.selected >= self.files.len() {
930 self.selected = self.files.len().saturating_sub(1);
931 }
932 self.min += self.height;
933 self.max += self.height;
934
935 if self.max >= self.files.len() {
936 self.max = self.files.len().saturating_sub(1);
937 self.min = self.max.saturating_sub(self.height);
938 }
939 }
940 key_msg if self.keymap.page_up.matches(key_msg) => {
941 self.selected = self.selected.saturating_sub(self.height);
942 self.min = self.min.saturating_sub(self.height);
943 self.max = self.max.saturating_sub(self.height);
944
945 if self.min == 0 {
946 self.min = 0;
947 self.max = self.min + self.height;
948 }
949 }
950 key_msg if self.keymap.back.matches(key_msg) => {
951 if let Some(parent) = self.current_directory.parent() {
952 self.current_directory = parent.to_path_buf();
953 if !self.selected_stack.is_empty() {
954 let (selected, min, max) = self.pop_view();
955 self.selected = selected;
956 self.min = min;
957 self.max = max;
958 } else {
959 self.selected = 0;
960 self.min = 0;
961 self.max = self.height.saturating_sub(1);
962 }
963 self.read_dir();
964 }
965 }
966 key_msg if self.keymap.open.matches(key_msg) && !self.files.is_empty() => {
967 let f = &self.files[self.selected].clone();
968 let mut is_dir = f.is_dir;
969
970 // Handle symlinks
971 if f.is_symlink {
972 if let Some(target) = &f.symlink_target {
973 if target.is_dir() {
974 is_dir = true;
975 }
976 }
977 }
978
979 // Check if we can select this file/directory
980 if ((!is_dir && self.file_allowed) || (is_dir && self.dir_allowed))
981 && self.keymap.select.matches(key_msg)
982 {
983 // Select the current path as the selection
984 self.path = f.path.to_string_lossy().to_string();
985 }
986
987 // Navigate into directory
988 if is_dir {
989 self.push_view(self.selected, self.min, self.max);
990 self.current_directory = f.path.clone();
991 self.selected = 0;
992 self.min = 0;
993 self.max = self.height.saturating_sub(1);
994 self.read_dir();
995 } else {
996 // Set the selected file path
997 self.path = f.path.to_string_lossy().to_string();
998 }
999 }
1000 _ => {}
1001 }
1002 }
1003 None
1004 }
1005
1006 fn view(&self) -> String {
1007 // Display error if present
1008 if let Some(error) = &self.error {
1009 return self
1010 .styles
1011 .empty_directory
1012 .clone()
1013 .height(self.height as i32)
1014 .max_height(self.height as i32)
1015 .render(error);
1016 }
1017
1018 if self.files.is_empty() {
1019 return self
1020 .styles
1021 .empty_directory
1022 .clone()
1023 .height(self.height as i32)
1024 .max_height(self.height as i32)
1025 .render("Bummer. No Files Found.");
1026 }
1027
1028 let mut output = String::new();
1029
1030 for (i, f) in self.files.iter().enumerate() {
1031 if i < self.min || i > self.max {
1032 continue;
1033 }
1034
1035 let size = format_file_size(f.size);
1036 let disabled = !self.can_select(&f.name) && !f.is_dir;
1037
1038 if self.selected == i {
1039 let mut selected_line = String::new();
1040
1041 if self.show_permissions {
1042 selected_line.push(' ');
1043 selected_line.push_str(&format_mode(f.mode));
1044 }
1045
1046 if self.show_size {
1047 selected_line.push_str(&format!("{:>width$}", size, width = FILE_SIZE_WIDTH));
1048 }
1049
1050 selected_line.push(' ');
1051 selected_line.push_str(&f.name);
1052
1053 if f.is_symlink {
1054 if let Some(target) = &f.symlink_target {
1055 selected_line.push_str(" → ");
1056 selected_line.push_str(&target.to_string_lossy());
1057 }
1058 }
1059
1060 if disabled {
1061 output.push_str(&self.styles.disabled_cursor.render(&self.cursor));
1062 output.push_str(&self.styles.disabled_selected.render(&selected_line));
1063 } else {
1064 output.push_str(&self.styles.cursor.render(&self.cursor));
1065 output.push_str(&self.styles.selected.render(&selected_line));
1066 }
1067 output.push('\n');
1068 continue;
1069 }
1070
1071 // Non-selected items
1072 let style = if f.is_dir {
1073 &self.styles.directory
1074 } else if f.is_symlink {
1075 &self.styles.symlink
1076 } else if disabled {
1077 &self.styles.disabled_file
1078 } else {
1079 &self.styles.file
1080 };
1081
1082 let mut file_name = style.render(&f.name);
1083 output.push_str(&self.styles.cursor.render(" "));
1084
1085 if f.is_symlink {
1086 if let Some(target) = &f.symlink_target {
1087 file_name.push_str(" → ");
1088 file_name.push_str(&target.to_string_lossy());
1089 }
1090 }
1091
1092 if self.show_permissions {
1093 output.push(' ');
1094 output.push_str(&self.styles.permission.render(&format_mode(f.mode)));
1095 }
1096
1097 if self.show_size {
1098 output.push_str(&self.styles.file_size.render(&size));
1099 }
1100
1101 output.push(' ');
1102 output.push_str(&file_name);
1103 output.push('\n');
1104 }
1105
1106 // Pad to fill height
1107 let current_height = output.lines().count();
1108 for _ in current_height..=self.height {
1109 output.push('\n');
1110 }
1111
1112 output
1113 }
1114}
1115
1116/// Formats file size in human-readable format, similar to go-humanize.
1117///
1118/// Converts byte sizes into readable format using decimal units (1000-based).
1119/// This matches the behavior of the Go humanize library used in the original implementation.
1120///
1121/// # Arguments
1122///
1123/// * `size` - The file size in bytes
1124///
1125/// # Returns
1126///
1127/// A formatted string representation of the file size (e.g., "1.2kB", "45MB")
1128///
1129/// # Examples
1130///
1131/// ```rust
1132/// // Note: This is a private function, shown for documentation purposes
1133/// // format_file_size(1024) -> "1.0kB"
1134/// // format_file_size(1500000) -> "1.5MB"
1135/// ```
1136fn format_file_size(size: u64) -> String {
1137 const UNITS: &[&str] = &["B", "kB", "MB", "GB", "TB", "PB"];
1138
1139 if size == 0 {
1140 return "0B".to_string();
1141 }
1142
1143 let mut size_f = size as f64;
1144 let mut unit_index = 0;
1145
1146 while size_f >= 1000.0 && unit_index < UNITS.len() - 1 {
1147 size_f /= 1000.0;
1148 unit_index += 1;
1149 }
1150
1151 if unit_index == 0 {
1152 format!("{}B", size)
1153 } else if size_f >= 100.0 {
1154 format!("{:.0}{}", size_f, UNITS[unit_index])
1155 } else {
1156 format!("{:.1}{}", size_f, UNITS[unit_index])
1157 }
1158}
1159
1160/// Formats file mode/permissions in Unix style.
1161///
1162/// Converts Unix file permission bits into a human-readable string representation
1163/// similar to what `ls -l` displays. The format is a 10-character string where:
1164/// - First character: file type (d=directory, l=symlink, -=regular file, etc.)
1165/// - Next 9 characters: permissions in rwx format for owner, group, and others
1166///
1167/// # Arguments
1168///
1169/// * `mode` - The file mode bits from file metadata
1170///
1171/// # Returns
1172///
1173/// A 10-character permission string (e.g., "drwxr-xr-x", "-rw-r--r--")
1174///
1175/// # Examples
1176///
1177/// ```rust
1178/// // Note: This is a private function, shown for documentation purposes
1179/// // format_mode(0o755) -> "-rwxr-xr-x"
1180/// // format_mode(0o644) -> "-rw-r--r--"
1181/// ```
1182#[cfg(unix)]
1183fn format_mode(mode: u32) -> String {
1184 // Use standard Rust constants instead of libc for better type compatibility
1185 const S_IFMT: u32 = 0o170000;
1186 const S_IFDIR: u32 = 0o040000;
1187 const S_IFLNK: u32 = 0o120000;
1188 const S_IFBLK: u32 = 0o060000;
1189 const S_IFCHR: u32 = 0o020000;
1190 const S_IFIFO: u32 = 0o010000;
1191 const S_IFSOCK: u32 = 0o140000;
1192
1193 const S_IRUSR: u32 = 0o400;
1194 const S_IWUSR: u32 = 0o200;
1195 const S_IXUSR: u32 = 0o100;
1196 const S_IRGRP: u32 = 0o040;
1197 const S_IWGRP: u32 = 0o020;
1198 const S_IXGRP: u32 = 0o010;
1199 const S_IROTH: u32 = 0o004;
1200 const S_IWOTH: u32 = 0o002;
1201 const S_IXOTH: u32 = 0o001;
1202
1203 let file_type = match mode & S_IFMT {
1204 S_IFDIR => 'd',
1205 S_IFLNK => 'l',
1206 S_IFBLK => 'b',
1207 S_IFCHR => 'c',
1208 S_IFIFO => 'p',
1209 S_IFSOCK => 's',
1210 _ => '-',
1211 };
1212
1213 let owner_perms = format!(
1214 "{}{}{}",
1215 if mode & S_IRUSR != 0 { 'r' } else { '-' },
1216 if mode & S_IWUSR != 0 { 'w' } else { '-' },
1217 if mode & S_IXUSR != 0 { 'x' } else { '-' }
1218 );
1219
1220 let group_perms = format!(
1221 "{}{}{}",
1222 if mode & S_IRGRP != 0 { 'r' } else { '-' },
1223 if mode & S_IWGRP != 0 { 'w' } else { '-' },
1224 if mode & S_IXGRP != 0 { 'x' } else { '-' }
1225 );
1226
1227 let other_perms = format!(
1228 "{}{}{}",
1229 if mode & S_IROTH != 0 { 'r' } else { '-' },
1230 if mode & S_IWOTH != 0 { 'w' } else { '-' },
1231 if mode & S_IXOTH != 0 { 'x' } else { '-' }
1232 );
1233
1234 format!("{}{}{}{}", file_type, owner_perms, group_perms, other_perms)
1235}
1236
1237/// Formats file mode/permissions on non-Unix systems.
1238///
1239/// On non-Unix systems (primarily Windows), file permissions don't use
1240/// the Unix rwx model, so this function returns a placeholder string.
1241///
1242/// # Arguments
1243///
1244/// * `_mode` - The file mode (ignored on non-Unix systems)
1245///
1246/// # Returns
1247///
1248/// A placeholder permission string "----------"
1249#[cfg(not(unix))]
1250fn format_mode(_mode: u32) -> String {
1251 "----------".to_string()
1252}