Skip to main content

bubbles/
filepicker.rs

1//! File picker component for browsing and selecting files.
2//!
3//! This module provides a file picker widget for TUI applications that allows
4//! users to navigate directories and select files.
5//!
6//! # Example
7//!
8//! ```rust,ignore
9//! use bubbles::filepicker::FilePicker;
10//!
11//! let mut picker = FilePicker::new();
12//! picker.set_current_directory(".");
13//!
14//! // In your init function, call init() to start reading the directory
15//! let cmd = picker.init();
16//! ```
17
18use crate::key::{Binding, matches};
19use bubbletea::{Cmd, KeyMsg, Message, Model, WindowSizeMsg};
20use lipgloss::{Color, Style};
21use std::path::{Path, PathBuf};
22use std::sync::atomic::{AtomicU64, Ordering};
23
24/// Global ID counter for file picker instances.
25static NEXT_ID: AtomicU64 = AtomicU64::new(1);
26
27fn next_id() -> u64 {
28    NEXT_ID.fetch_add(1, Ordering::Relaxed)
29}
30
31/// A directory entry in the file picker.
32#[derive(Debug, Clone)]
33pub struct DirEntry {
34    /// Name of the file or directory.
35    pub name: String,
36    /// Full path.
37    pub path: PathBuf,
38    /// Whether this is a directory.
39    pub is_dir: bool,
40    /// Whether this is a symbolic link.
41    pub is_symlink: bool,
42    /// File size in bytes.
43    pub size: u64,
44    /// Permission string (e.g., "drwxr-xr-x").
45    pub mode: String,
46}
47
48/// Message sent when directory reading completes.
49#[derive(Debug, Clone)]
50pub struct ReadDirMsg {
51    /// The file picker ID this message is for.
52    pub id: u64,
53    /// The directory entries read.
54    pub entries: Vec<DirEntry>,
55}
56
57/// Message sent when directory reading fails.
58#[derive(Debug, Clone)]
59pub struct ReadDirErrMsg {
60    /// The file picker ID this message is for.
61    pub id: u64,
62    /// Error message.
63    pub error: String,
64}
65
66/// Key bindings for file picker navigation.
67#[derive(Debug, Clone)]
68pub struct KeyMap {
69    /// Go to first entry.
70    pub goto_top: Binding,
71    /// Go to last entry.
72    pub goto_last: Binding,
73    /// Move down one entry.
74    pub down: Binding,
75    /// Move up one entry.
76    pub up: Binding,
77    /// Page up.
78    pub page_up: Binding,
79    /// Page down.
80    pub page_down: Binding,
81    /// Go back to parent directory.
82    pub back: Binding,
83    /// Open directory or select file.
84    pub open: Binding,
85    /// Confirm selection.
86    pub select: Binding,
87}
88
89impl Default for KeyMap {
90    fn default() -> Self {
91        Self {
92            goto_top: Binding::new().keys(&["g"]).help("g", "first"),
93            goto_last: Binding::new().keys(&["G"]).help("G", "last"),
94            down: Binding::new()
95                .keys(&["j", "down", "ctrl+n"])
96                .help("j", "down"),
97            up: Binding::new().keys(&["k", "up", "ctrl+p"]).help("k", "up"),
98            page_up: Binding::new().keys(&["K", "pgup"]).help("pgup", "page up"),
99            page_down: Binding::new()
100                .keys(&["J", "pgdown"])
101                .help("pgdown", "page down"),
102            back: Binding::new()
103                .keys(&["h", "backspace", "left", "esc"])
104                .help("h", "back"),
105            open: Binding::new()
106                .keys(&["l", "right", "enter"])
107                .help("l", "open"),
108            select: Binding::new().keys(&["enter"]).help("enter", "select"),
109        }
110    }
111}
112
113/// Styles for the file picker.
114#[derive(Debug, Clone)]
115pub struct Styles {
116    /// Style for the cursor when disabled.
117    pub disabled_cursor: Style,
118    /// Style for the cursor.
119    pub cursor: Style,
120    /// Style for symbolic links.
121    pub symlink: Style,
122    /// Style for directories.
123    pub directory: Style,
124    /// Style for regular files.
125    pub file: Style,
126    /// Style for disabled files.
127    pub disabled_file: Style,
128    /// Style for permissions.
129    pub permission: Style,
130    /// Style for selected item.
131    pub selected: Style,
132    /// Style for disabled selected item.
133    pub disabled_selected: Style,
134    /// Style for file size.
135    pub file_size: Style,
136    /// Style for empty directory message.
137    pub empty_directory: Style,
138}
139
140impl Default for Styles {
141    fn default() -> Self {
142        Self {
143            disabled_cursor: Style::new().foreground_color(Color::from("247")),
144            cursor: Style::new().foreground_color(Color::from("212")),
145            symlink: Style::new().foreground_color(Color::from("36")),
146            directory: Style::new().foreground_color(Color::from("99")),
147            file: Style::new(),
148            disabled_file: Style::new().foreground_color(Color::from("243")),
149            permission: Style::new().foreground_color(Color::from("244")),
150            selected: Style::new().foreground_color(Color::from("212")).bold(),
151            disabled_selected: Style::new().foreground_color(Color::from("247")),
152            file_size: Style::new().foreground_color(Color::from("240")),
153            empty_directory: Style::new().foreground_color(Color::from("240")),
154        }
155    }
156}
157
158/// File picker model for browsing and selecting files.
159#[derive(Debug, Clone)]
160pub struct FilePicker {
161    /// Unique ID for this file picker.
162    id: u64,
163    /// Root directory (jail) for navigation.
164    pub root: Option<PathBuf>,
165    /// Currently selected path (after selection).
166    pub path: Option<PathBuf>,
167    /// Current directory being displayed.
168    current_directory: PathBuf,
169    /// Allowed file extensions (empty = all allowed).
170    pub allowed_types: Vec<String>,
171    /// Key bindings.
172    pub key_map: KeyMap,
173    /// Directory entries.
174    files: Vec<DirEntry>,
175    /// Whether to show permissions.
176    pub show_permissions: bool,
177    /// Whether to show file sizes.
178    pub show_size: bool,
179    /// Whether to show hidden files.
180    pub show_hidden: bool,
181    /// Whether directories can be selected.
182    pub dir_allowed: bool,
183    /// Whether files can be selected.
184    pub file_allowed: bool,
185    /// Currently selected index.
186    selected: usize,
187    /// Navigation stack for selected indices.
188    selected_stack: Vec<usize>,
189    /// Minimum visible index.
190    min: usize,
191    /// Maximum visible index.
192    max: usize,
193    /// Navigation stack for min values.
194    min_stack: Vec<usize>,
195    /// Navigation stack for max values.
196    max_stack: Vec<usize>,
197    /// Height of the picker in rows.
198    pub height: usize,
199    /// Whether to auto-adjust height based on window size.
200    pub auto_height: bool,
201    /// Cursor character.
202    pub cursor_char: String,
203    /// Styles.
204    pub styles: Styles,
205}
206
207impl Default for FilePicker {
208    fn default() -> Self {
209        Self::new()
210    }
211}
212
213impl FilePicker {
214    /// Creates a new file picker with default settings.
215    #[must_use]
216    pub fn new() -> Self {
217        Self {
218            id: next_id(),
219            root: None,
220            path: None,
221            current_directory: PathBuf::from("."),
222            allowed_types: Vec::new(),
223            key_map: KeyMap::default(),
224            files: Vec::new(),
225            show_permissions: true,
226            show_size: true,
227            show_hidden: false,
228            dir_allowed: false,
229            file_allowed: true,
230            selected: 0,
231            selected_stack: Vec::new(),
232            min: 0,
233            max: 0,
234            min_stack: Vec::new(),
235            max_stack: Vec::new(),
236            height: 0,
237            auto_height: true,
238            cursor_char: ">".to_string(),
239            styles: Styles::default(),
240        }
241    }
242
243    /// Returns the unique ID of this file picker.
244    #[must_use]
245    pub fn id(&self) -> u64 {
246        self.id
247    }
248
249    /// Returns the current directory.
250    #[must_use]
251    pub fn current_directory(&self) -> &Path {
252        &self.current_directory
253    }
254
255    /// Sets the root directory (jail). Navigation above this directory will be blocked.
256    pub fn set_root(&mut self, root: impl AsRef<Path>) {
257        self.root = Some(root.as_ref().to_path_buf());
258        // Ensure current directory satisfies new root
259        if let Some(root) = &self.root
260            && !self.current_directory.starts_with(root)
261        {
262            self.current_directory = root.clone();
263        }
264    }
265
266    /// Sets the current directory.
267    pub fn set_current_directory(&mut self, path: impl AsRef<Path>) {
268        let path = path.as_ref();
269        if let Some(root) = &self.root
270            && !path.starts_with(root)
271        {
272            // If path is outside root, default to root
273            self.current_directory = root.clone();
274            return;
275        }
276        self.current_directory = path.to_path_buf();
277    }
278
279    /// Sets the height of the file picker.
280    pub fn set_height(&mut self, height: usize) {
281        self.height = height;
282        self.clamp_viewport();
283    }
284
285    /// Sets the allowed file types.
286    pub fn set_allowed_types(&mut self, types: Vec<String>) {
287        self.allowed_types = types;
288    }
289
290    /// Returns the selected file path, if any.
291    #[must_use]
292    pub fn selected_path(&self) -> Option<&Path> {
293        self.path.as_deref()
294    }
295
296    /// Returns the currently highlighted entry, if any.
297    #[must_use]
298    pub fn highlighted_entry(&self) -> Option<&DirEntry> {
299        self.files.get(self.selected)
300    }
301
302    /// Initializes the file picker and returns a command to read the directory.
303    #[must_use]
304    pub fn init(&self) -> Option<Cmd> {
305        Some(self.read_dir_cmd())
306    }
307
308    /// Creates a command to read the current directory.
309    fn read_dir_cmd(&self) -> Cmd {
310        let id = self.id;
311        let path = self.current_directory.clone();
312        let show_hidden = self.show_hidden;
313
314        Cmd::new(move || match read_directory(&path, show_hidden) {
315            Ok(entries) => Message::new(ReadDirMsg { id, entries }),
316            Err(e) => Message::new(ReadDirErrMsg {
317                id,
318                error: e.to_string(),
319            }),
320        })
321    }
322
323    /// Checks if a file can be selected based on allowed types.
324    fn can_select(&self, name: &str) -> bool {
325        if self.allowed_types.is_empty() {
326            return true;
327        }
328        self.allowed_types.iter().any(|ext| name.ends_with(ext))
329    }
330
331    /// Returns whether the given entry can be selected.
332    fn is_selectable(&self, entry: &DirEntry) -> bool {
333        if entry.is_dir {
334            self.dir_allowed
335        } else {
336            self.file_allowed && self.can_select(&entry.name)
337        }
338    }
339
340    /// Keeps selection and viewport within bounds for the current file list.
341    fn clamp_viewport(&mut self) {
342        let len = self.files.len();
343        if len == 0 {
344            self.selected = 0;
345            self.min = 0;
346            self.max = 0;
347            return;
348        }
349
350        if self.selected >= len {
351            self.selected = len.saturating_sub(1);
352        }
353
354        let height = self.height.max(1);
355        self.min = self.min.min(self.selected);
356        self.max = self.min + height.saturating_sub(1);
357        if self.max >= len {
358            self.max = len.saturating_sub(1);
359            self.min = self.max.saturating_sub(height.saturating_sub(1));
360        }
361    }
362
363    /// Pushes current view state to the navigation stack.
364    fn push_view(&mut self) {
365        self.selected_stack.push(self.selected);
366        self.min_stack.push(self.min);
367        self.max_stack.push(self.max);
368    }
369
370    /// Pops view state from the navigation stack.
371    fn pop_view(&mut self) -> Option<(usize, usize, usize)> {
372        if let (Some(sel), Some(min), Some(max)) = (
373            self.selected_stack.pop(),
374            self.min_stack.pop(),
375            self.max_stack.pop(),
376        ) {
377            Some((sel, min, max))
378        } else {
379            None
380        }
381    }
382
383    /// Checks if this message indicates a file was selected.
384    pub fn did_select_file(&self, msg: &Message) -> Option<PathBuf> {
385        if let Some(key) = msg.downcast_ref::<KeyMsg>() {
386            let key_str = key.to_string();
387            if matches(&key_str, &[&self.key_map.select])
388                && let Some(entry) = self.files.get(self.selected)
389                && self.is_selectable(entry)
390            {
391                return Some(entry.path.clone());
392            }
393        }
394        None
395    }
396
397    /// Checks if this message indicates a disabled file was selected.
398    pub fn did_select_disabled_file(&self, msg: &Message) -> Option<PathBuf> {
399        if let Some(key) = msg.downcast_ref::<KeyMsg>() {
400            let key_str = key.to_string();
401            if matches(&key_str, &[&self.key_map.select])
402                && let Some(entry) = self.files.get(self.selected)
403                && !self.is_selectable(entry)
404            {
405                return Some(entry.path.clone());
406            }
407        }
408        None
409    }
410
411    /// Updates the file picker based on messages.
412    pub fn update(&mut self, msg: Message) -> Option<Cmd> {
413        // Handle directory read result
414        if let Some(read_msg) = msg.downcast_ref::<ReadDirMsg>() {
415            if read_msg.id != self.id {
416                return None;
417            }
418            self.files = read_msg.entries.clone();
419            self.clamp_viewport();
420            return None;
421        }
422
423        // Handle window size
424        if let Some(size) = msg.downcast_ref::<WindowSizeMsg>() {
425            if self.auto_height {
426                self.height = (size.height as usize).saturating_sub(5);
427            }
428            self.clamp_viewport();
429            return None;
430        }
431
432        // Handle key messages
433        if let Some(key) = msg.downcast_ref::<KeyMsg>() {
434            let key_str = key.to_string();
435
436            if matches(&key_str, &[&self.key_map.goto_top]) {
437                self.selected = 0;
438                self.min = 0;
439                self.max = self.height.saturating_sub(1);
440            } else if matches(&key_str, &[&self.key_map.goto_last]) {
441                self.selected = self.files.len().saturating_sub(1);
442                self.min = self.files.len().saturating_sub(self.height);
443                self.max = self.files.len().saturating_sub(1);
444            } else if matches(&key_str, &[&self.key_map.down]) {
445                if self.selected < self.files.len().saturating_sub(1) {
446                    self.selected += 1;
447                    if self.selected > self.max {
448                        self.min += 1;
449                        self.max += 1;
450                    }
451                }
452            } else if matches(&key_str, &[&self.key_map.up]) {
453                if self.selected > 0 {
454                    self.selected -= 1;
455                    if self.selected < self.min {
456                        self.min = self.min.saturating_sub(1);
457                        self.max = self.max.saturating_sub(1);
458                    }
459                }
460            } else if matches(&key_str, &[&self.key_map.page_down]) {
461                self.selected =
462                    (self.selected + self.height).min(self.files.len().saturating_sub(1));
463                self.min += self.height;
464                self.max += self.height;
465                if self.max >= self.files.len() {
466                    self.max = self.files.len().saturating_sub(1);
467                    self.min = self.max.saturating_sub(self.height);
468                }
469            } else if matches(&key_str, &[&self.key_map.page_up]) {
470                self.selected = self.selected.saturating_sub(self.height);
471                self.min = self.min.saturating_sub(self.height);
472                self.max = self.max.saturating_sub(self.height);
473                if self.min == 0 {
474                    self.max = self
475                        .height
476                        .saturating_sub(1)
477                        .min(self.files.len().saturating_sub(1));
478                }
479            } else if matches(&key_str, &[&self.key_map.back]) {
480                // Go to parent directory
481                // Check if we are at root
482                let at_root = if let Some(root) = &self.root {
483                    self.current_directory == *root
484                } else {
485                    false
486                };
487
488                if !at_root {
489                    if let Some(parent) = self.current_directory.parent() {
490                        self.current_directory = parent.to_path_buf();
491                    }
492                    if let Some((sel, min, max)) = self.pop_view() {
493                        self.selected = sel;
494                        self.min = min;
495                        self.max = max;
496                    } else {
497                        self.selected = 0;
498                        self.min = 0;
499                        self.max = self.height.saturating_sub(1);
500                    }
501                    return Some(self.read_dir_cmd());
502                }
503            } else {
504                let is_select = matches(&key_str, &[&self.key_map.select]);
505                let is_open = matches(&key_str, &[&self.key_map.open]);
506                if !is_select && !is_open {
507                    return None;
508                }
509
510                if self.files.is_empty() {
511                    return None;
512                }
513
514                let entry = &self.files[self.selected];
515                let is_dir = entry.is_dir;
516
517                if is_select {
518                    self.path = None;
519                }
520
521                // Check if we can select this
522                if is_select && self.is_selectable(entry) {
523                    self.path = Some(entry.path.clone());
524                }
525
526                // If it's a directory, navigate into it
527                if is_open && is_dir {
528                    self.current_directory = entry.path.clone();
529                    self.push_view();
530                    self.selected = 0;
531                    self.min = 0;
532                    self.max = self.height.saturating_sub(1);
533                    return Some(self.read_dir_cmd());
534                }
535            }
536        }
537
538        None
539    }
540
541    /// Renders the file picker.
542    #[must_use]
543    pub fn view(&self) -> String {
544        if self.files.is_empty() {
545            return self.styles.empty_directory.render("No files found.");
546        }
547
548        let mut lines = Vec::new();
549
550        for (i, entry) in self.files.iter().enumerate() {
551            if i < self.min || i > self.max {
552                continue;
553            }
554
555            let disabled = !self.is_selectable(entry);
556
557            if i == self.selected {
558                // Selected row
559                let mut parts = Vec::new();
560
561                if self.show_permissions {
562                    parts.push(format!(" {}", entry.mode));
563                }
564                if self.show_size {
565                    parts.push(format!("{:>7}", format_size(entry.size)));
566                }
567                parts.push(format!(" {}", entry.name));
568                if entry.is_symlink {
569                    parts.push(" →".to_string());
570                }
571
572                let content = parts.join("");
573
574                if disabled {
575                    lines.push(format!(
576                        "{}{}",
577                        self.styles.disabled_selected.render(&self.cursor_char),
578                        self.styles.disabled_selected.render(&content)
579                    ));
580                } else {
581                    lines.push(format!(
582                        "{}{}",
583                        self.styles.cursor.render(&self.cursor_char),
584                        self.styles.selected.render(&content)
585                    ));
586                }
587            } else {
588                // Non-selected row
589                let style = if entry.is_dir {
590                    &self.styles.directory
591                } else if entry.is_symlink {
592                    &self.styles.symlink
593                } else if disabled {
594                    &self.styles.disabled_file
595                } else {
596                    &self.styles.file
597                };
598
599                let mut parts = vec![" ".to_string()]; // Space for cursor
600
601                if self.show_permissions {
602                    parts.push(format!(" {}", self.styles.permission.render(&entry.mode)));
603                }
604                if self.show_size {
605                    parts.push(
606                        self.styles
607                            .file_size
608                            .render(&format!("{:>7}", format_size(entry.size))),
609                    );
610                }
611                parts.push(format!(" {}", style.render(&entry.name)));
612                if entry.is_symlink {
613                    parts.push(" →".to_string());
614                }
615
616                lines.push(parts.join(""));
617            }
618        }
619
620        // Pad to height
621        while lines.len() < self.height {
622            lines.push(String::new());
623        }
624
625        lines.join("\n")
626    }
627}
628
629impl Model for FilePicker {
630    /// Initialize the file picker by reading the current directory.
631    fn init(&self) -> Option<Cmd> {
632        FilePicker::init(self)
633    }
634
635    /// Update the file picker state based on incoming messages.
636    fn update(&mut self, msg: Message) -> Option<Cmd> {
637        FilePicker::update(self, msg)
638    }
639
640    /// Render the file picker.
641    fn view(&self) -> String {
642        FilePicker::view(self)
643    }
644}
645
646/// Reads a directory and returns sorted entries.
647fn read_directory(path: &Path, show_hidden: bool) -> std::io::Result<Vec<DirEntry>> {
648    let mut entries = Vec::new();
649
650    for entry in std::fs::read_dir(path)? {
651        let entry = entry?;
652        let name = entry.file_name().to_string_lossy().to_string();
653
654        // Skip hidden files if not showing
655        if !show_hidden && name.starts_with('.') {
656            continue;
657        }
658
659        let metadata = entry.metadata()?;
660        let file_type = entry.file_type()?;
661        let is_symlink = file_type.is_symlink();
662
663        let mode = format_mode(&metadata, is_symlink);
664
665        entries.push(DirEntry {
666            name,
667            path: entry.path(),
668            is_dir: file_type.is_dir(),
669            is_symlink: file_type.is_symlink(),
670            size: metadata.len(),
671            mode,
672        });
673    }
674
675    // Sort: directories first, then alphabetically
676    entries.sort_by(|a, b| match (a.is_dir, b.is_dir) {
677        (true, false) => std::cmp::Ordering::Less,
678        (false, true) => std::cmp::Ordering::Greater,
679        _ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
680    });
681
682    Ok(entries)
683}
684
685/// Formats file permissions as a string.
686#[cfg(unix)]
687fn format_mode(metadata: &std::fs::Metadata, is_symlink: bool) -> String {
688    use std::os::unix::fs::PermissionsExt;
689    let mode = metadata.permissions().mode();
690
691    let file_type = if metadata.is_dir() {
692        'd'
693    } else if is_symlink {
694        'l'
695    } else {
696        '-'
697    };
698
699    let user = format!(
700        "{}{}{}",
701        if mode & 0o400 != 0 { 'r' } else { '-' },
702        if mode & 0o200 != 0 { 'w' } else { '-' },
703        if mode & 0o100 != 0 { 'x' } else { '-' }
704    );
705    let group = format!(
706        "{}{}{}",
707        if mode & 0o040 != 0 { 'r' } else { '-' },
708        if mode & 0o020 != 0 { 'w' } else { '-' },
709        if mode & 0o010 != 0 { 'x' } else { '-' }
710    );
711    let other = format!(
712        "{}{}{}",
713        if mode & 0o004 != 0 { 'r' } else { '-' },
714        if mode & 0o002 != 0 { 'w' } else { '-' },
715        if mode & 0o001 != 0 { 'x' } else { '-' }
716    );
717
718    format!("{}{}{}{}", file_type, user, group, other)
719}
720
721#[cfg(not(unix))]
722fn format_mode(metadata: &std::fs::Metadata, is_symlink: bool) -> String {
723    let file_type = if metadata.is_dir() {
724        'd'
725    } else if is_symlink {
726        'l'
727    } else {
728        '-'
729    };
730    let readonly = if metadata.permissions().readonly() {
731        "r--"
732    } else {
733        "rw-"
734    };
735    format!("{}{}{}{}", file_type, readonly, readonly, readonly)
736}
737
738/// Formats a file size in human-readable form.
739fn format_size(size: u64) -> String {
740    const KB: u64 = 1024;
741    const MB: u64 = KB * 1024;
742    const GB: u64 = MB * 1024;
743
744    if size >= GB {
745        format!("{:.1}G", size as f64 / GB as f64)
746    } else if size >= MB {
747        format!("{:.1}M", size as f64 / MB as f64)
748    } else if size >= KB {
749        format!("{:.1}K", size as f64 / KB as f64)
750    } else {
751        format!("{}B", size)
752    }
753}
754
755#[cfg(test)]
756mod tests {
757    use super::*;
758
759    #[test]
760    fn test_filepicker_new() {
761        let fp = FilePicker::new();
762        assert!(fp.allowed_types.is_empty());
763        assert!(fp.show_permissions);
764        assert!(fp.show_size);
765        assert!(!fp.show_hidden);
766        assert!(fp.file_allowed);
767        assert!(!fp.dir_allowed);
768    }
769
770    #[test]
771    fn test_filepicker_unique_ids() {
772        let fp1 = FilePicker::new();
773        let fp2 = FilePicker::new();
774        assert_ne!(fp1.id(), fp2.id());
775    }
776
777    #[test]
778    fn test_filepicker_set_current_directory() {
779        let mut fp = FilePicker::new();
780        fp.set_current_directory("/tmp");
781        assert_eq!(fp.current_directory(), Path::new("/tmp"));
782    }
783
784    #[test]
785    fn test_filepicker_set_height() {
786        let mut fp = FilePicker::new();
787        fp.set_height(20);
788        assert_eq!(fp.height, 20);
789    }
790
791    #[test]
792    fn test_filepicker_allowed_types() {
793        let mut fp = FilePicker::new();
794        fp.set_allowed_types(vec![".txt".to_string(), ".md".to_string()]);
795
796        assert!(fp.can_select("readme.txt"));
797        assert!(fp.can_select("notes.md"));
798        assert!(!fp.can_select("image.png"));
799    }
800
801    #[test]
802    fn test_filepicker_all_types_allowed() {
803        let fp = FilePicker::new();
804        assert!(fp.can_select("anything.xyz"));
805    }
806
807    #[test]
808    fn test_format_size() {
809        assert_eq!(format_size(0), "0B");
810        assert_eq!(format_size(512), "512B");
811        assert_eq!(format_size(1024), "1.0K");
812        assert_eq!(format_size(1536), "1.5K");
813        assert_eq!(format_size(1048576), "1.0M");
814        assert_eq!(format_size(1073741824), "1.0G");
815    }
816
817    #[test]
818    fn test_filepicker_navigation_stack() {
819        let mut fp = FilePicker::new();
820
821        fp.selected = 5;
822        fp.min = 2;
823        fp.max = 10;
824
825        fp.push_view();
826
827        fp.selected = 0;
828        fp.min = 0;
829        fp.max = 5;
830
831        let (sel, min, max) = fp.pop_view().unwrap();
832        assert_eq!(sel, 5);
833        assert_eq!(min, 2);
834        assert_eq!(max, 10);
835    }
836
837    #[test]
838    fn test_filepicker_view_empty() {
839        let fp = FilePicker::new();
840        let view = fp.view();
841        assert!(view.contains("No files"));
842    }
843
844    #[test]
845    fn test_keymap_default() {
846        let km = KeyMap::default();
847        assert!(!km.up.get_keys().is_empty());
848        assert!(!km.down.get_keys().is_empty());
849        assert!(!km.open.get_keys().is_empty());
850    }
851
852    #[test]
853    fn test_dir_entry() {
854        let entry = DirEntry {
855            name: "test.txt".to_string(),
856            path: PathBuf::from("/tmp/test.txt"),
857            is_dir: false,
858            is_symlink: false,
859            size: 1024,
860            mode: "-rw-r--r--".to_string(),
861        };
862
863        assert_eq!(entry.name, "test.txt");
864        assert!(!entry.is_dir);
865        assert_eq!(entry.size, 1024);
866    }
867
868    // Model trait implementation tests
869    #[test]
870    fn test_model_init_returns_cmd() {
871        let fp = FilePicker::new();
872        // FilePicker init returns a command to read the directory
873        let cmd = Model::init(&fp);
874        assert!(cmd.is_some());
875    }
876
877    #[test]
878    fn test_model_view_matches_filepicker_view() {
879        let fp = FilePicker::new();
880        // Model::view should return same result as FilePicker::view
881        let model_view = Model::view(&fp);
882        let filepicker_view = FilePicker::view(&fp);
883        assert_eq!(model_view, filepicker_view);
884    }
885
886    #[test]
887    fn test_filepicker_satisfies_model_bounds() {
888        fn requires_model<T: Model + Send + 'static>() {}
889        requires_model::<FilePicker>();
890    }
891
892    #[test]
893    fn test_model_update_handles_navigation() {
894        use bubbletea::{KeyMsg, KeyType, Message};
895
896        let mut fp = FilePicker::new();
897        // Simulate having some files loaded
898        fp.files = vec![
899            DirEntry {
900                name: "file1.txt".to_string(),
901                path: PathBuf::from("/tmp/file1.txt"),
902                is_dir: false,
903                is_symlink: false,
904                size: 100,
905                mode: "-rw-r--r--".to_string(),
906            },
907            DirEntry {
908                name: "file2.txt".to_string(),
909                path: PathBuf::from("/tmp/file2.txt"),
910                is_dir: false,
911                is_symlink: false,
912                size: 200,
913                mode: "-rw-r--r--".to_string(),
914            },
915        ];
916        fp.max = 10;
917        fp.selected = 0;
918
919        // Press down arrow
920        let down_msg = Message::new(KeyMsg::from_type(KeyType::Down));
921        let _ = Model::update(&mut fp, down_msg);
922
923        assert_eq!(
924            fp.selected, 1,
925            "FilePicker should navigate down on Down key"
926        );
927    }
928
929    #[test]
930    fn test_model_update_handles_read_dir_msg() {
931        use bubbletea::Message;
932
933        let mut fp = FilePicker::new();
934        let id = fp.id();
935        assert!(fp.files.is_empty());
936
937        // Simulate receiving a ReadDirMsg
938        let read_msg = ReadDirMsg {
939            id,
940            entries: vec![DirEntry {
941                name: "test.txt".to_string(),
942                path: PathBuf::from("/tmp/test.txt"),
943                is_dir: false,
944                is_symlink: false,
945                size: 42,
946                mode: "-rw-r--r--".to_string(),
947            }],
948        };
949
950        let _ = Model::update(&mut fp, Message::new(read_msg));
951
952        assert_eq!(
953            fp.files.len(),
954            1,
955            "FilePicker should populate files from ReadDirMsg"
956        );
957        assert_eq!(fp.files[0].name, "test.txt");
958    }
959
960    #[test]
961    fn test_filepicker_read_dir_clamps_selection() {
962        use bubbletea::Message;
963
964        let mut fp = FilePicker::new();
965        fp.height = 5;
966        fp.selected = 10;
967        fp.min = 8;
968        fp.max = 12;
969
970        let read_msg = ReadDirMsg {
971            id: fp.id(),
972            entries: vec![
973                DirEntry {
974                    name: "file1.txt".to_string(),
975                    path: PathBuf::from("/tmp/file1.txt"),
976                    is_dir: false,
977                    is_symlink: false,
978                    size: 100,
979                    mode: "-rw-r--r--".to_string(),
980                },
981                DirEntry {
982                    name: "file2.txt".to_string(),
983                    path: PathBuf::from("/tmp/file2.txt"),
984                    is_dir: false,
985                    is_symlink: false,
986                    size: 200,
987                    mode: "-rw-r--r--".to_string(),
988                },
989            ],
990        };
991
992        let _ = Model::update(&mut fp, Message::new(read_msg));
993
994        assert!(
995            fp.selected < fp.files.len(),
996            "Selection should clamp to list"
997        );
998        assert!(fp.min <= fp.selected && fp.selected <= fp.max);
999        assert!(fp.max < fp.files.len());
1000    }
1001
1002    #[test]
1003    fn test_model_update_ignores_wrong_id() {
1004        use bubbletea::Message;
1005
1006        let mut fp = FilePicker::new();
1007        assert!(fp.files.is_empty());
1008
1009        // Send ReadDirMsg with wrong ID
1010        let read_msg = ReadDirMsg {
1011            id: fp.id() + 1, // Wrong ID
1012            entries: vec![DirEntry {
1013                name: "test.txt".to_string(),
1014                path: PathBuf::from("/tmp/test.txt"),
1015                is_dir: false,
1016                is_symlink: false,
1017                size: 42,
1018                mode: "-rw-r--r--".to_string(),
1019            }],
1020        };
1021
1022        let _ = Model::update(&mut fp, Message::new(read_msg));
1023
1024        assert!(
1025            fp.files.is_empty(),
1026            "FilePicker should ignore ReadDirMsg with wrong ID"
1027        );
1028    }
1029
1030    // ========================================================================
1031    // Additional Model trait tests for bead charmed_rust-amx
1032    // ========================================================================
1033
1034    #[test]
1035    fn test_model_update_navigate_up_moves_cursor() {
1036        use bubbletea::{KeyMsg, KeyType, Message};
1037
1038        let mut fp = FilePicker::new();
1039        fp.files = vec![
1040            DirEntry {
1041                name: "file1.txt".to_string(),
1042                path: PathBuf::from("/tmp/file1.txt"),
1043                is_dir: false,
1044                is_symlink: false,
1045                size: 100,
1046                mode: "-rw-r--r--".to_string(),
1047            },
1048            DirEntry {
1049                name: "file2.txt".to_string(),
1050                path: PathBuf::from("/tmp/file2.txt"),
1051                is_dir: false,
1052                is_symlink: false,
1053                size: 200,
1054                mode: "-rw-r--r--".to_string(),
1055            },
1056        ];
1057        fp.max = 10;
1058        fp.selected = 1;
1059
1060        // Press up arrow
1061        let up_msg = Message::new(KeyMsg::from_type(KeyType::Up));
1062        let _ = Model::update(&mut fp, up_msg);
1063
1064        assert_eq!(fp.selected, 0, "FilePicker should navigate up on Up key");
1065    }
1066
1067    #[test]
1068    fn test_filepicker_toggle_hidden_files() {
1069        let mut fp = FilePicker::new();
1070        assert!(!fp.show_hidden, "Hidden files should be hidden by default");
1071
1072        fp.show_hidden = true;
1073        assert!(fp.show_hidden, "Hidden files should be shown after toggle");
1074
1075        fp.show_hidden = false;
1076        assert!(!fp.show_hidden, "Hidden files should be hidden again");
1077    }
1078
1079    #[test]
1080    fn test_filepicker_filter_files() {
1081        let mut fp = FilePicker::new();
1082        fp.set_allowed_types(vec![".txt".to_string()]);
1083
1084        // Test filtering
1085        assert!(fp.can_select("readme.txt"));
1086        assert!(!fp.can_select("image.png"));
1087        assert!(!fp.can_select("document.pdf"));
1088    }
1089
1090    #[test]
1091    fn test_filepicker_select_respects_allowed_types() {
1092        use bubbletea::{KeyMsg, KeyType, Message};
1093
1094        let mut fp = FilePicker::new();
1095        fp.set_allowed_types(vec![".txt".to_string()]);
1096        fp.files = vec![DirEntry {
1097            name: "image.png".to_string(),
1098            path: PathBuf::from("/tmp/image.png"),
1099            is_dir: false,
1100            is_symlink: false,
1101            size: 100,
1102            mode: "-rw-r--r--".to_string(),
1103        }];
1104        fp.selected = 0;
1105
1106        let msg = Message::new(KeyMsg::from_type(KeyType::Enter));
1107        let _ = Model::update(&mut fp, msg);
1108
1109        assert!(
1110            fp.selected_path().is_none(),
1111            "Disallowed file should not be selected"
1112        );
1113        assert_eq!(
1114            fp.did_select_disabled_file(&Message::new(KeyMsg::from_type(KeyType::Enter))),
1115            Some(PathBuf::from("/tmp/image.png")),
1116            "Selecting a disallowed file should be reported as disabled"
1117        );
1118    }
1119
1120    #[test]
1121    fn test_filepicker_select_dir_when_disallowed_reports_disabled() {
1122        use bubbletea::{KeyMsg, KeyType, Message};
1123
1124        let mut fp = FilePicker::new();
1125        fp.dir_allowed = false;
1126        fp.files = vec![DirEntry {
1127            name: "subdir".to_string(),
1128            path: PathBuf::from("/tmp/subdir"),
1129            is_dir: true,
1130            is_symlink: false,
1131            size: 4096,
1132            mode: "drwxr-xr-x".to_string(),
1133        }];
1134        fp.selected = 0;
1135
1136        let msg = Message::new(KeyMsg::from_type(KeyType::Enter));
1137        let _ = Model::update(&mut fp, msg);
1138
1139        assert!(
1140            fp.selected_path().is_none(),
1141            "Disallowed dir should not be selected"
1142        );
1143        assert_eq!(
1144            fp.did_select_disabled_file(&Message::new(KeyMsg::from_type(KeyType::Enter))),
1145            Some(PathBuf::from("/tmp/subdir")),
1146            "Selecting a disallowed dir should be reported as disabled"
1147        );
1148    }
1149
1150    #[test]
1151    fn test_filepicker_view_shows_current_path() {
1152        let mut fp = FilePicker::new();
1153        fp.set_current_directory("/tmp");
1154
1155        // Add some files so view isn't empty
1156        fp.files = vec![DirEntry {
1157            name: "test.txt".to_string(),
1158            path: PathBuf::from("/tmp/test.txt"),
1159            is_dir: false,
1160            is_symlink: false,
1161            size: 100,
1162            mode: "-rw-r--r--".to_string(),
1163        }];
1164        fp.max = 10;
1165
1166        let view = fp.view();
1167        // The view should contain file names
1168        assert!(view.contains("test") || !view.is_empty());
1169    }
1170
1171    #[test]
1172    fn test_filepicker_symlink_entry() {
1173        let entry = DirEntry {
1174            name: "link".to_string(),
1175            path: PathBuf::from("/tmp/link"),
1176            is_dir: false,
1177            is_symlink: true,
1178            size: 0,
1179            mode: "lrwxrwxrwx".to_string(),
1180        };
1181
1182        assert!(entry.is_symlink, "Entry should be marked as symlink");
1183        assert!(!entry.is_dir, "Symlink should not be marked as directory");
1184    }
1185
1186    #[test]
1187    fn test_filepicker_directory_entry() {
1188        let entry = DirEntry {
1189            name: "subdir".to_string(),
1190            path: PathBuf::from("/tmp/subdir"),
1191            is_dir: true,
1192            is_symlink: false,
1193            size: 4096,
1194            mode: "drwxr-xr-x".to_string(),
1195        };
1196
1197        assert!(entry.is_dir, "Entry should be marked as directory");
1198        assert!(!entry.is_symlink);
1199    }
1200
1201    #[test]
1202    fn test_filepicker_cursor_boundary_top() {
1203        use bubbletea::{KeyMsg, KeyType, Message};
1204
1205        let mut fp = FilePicker::new();
1206        fp.files = vec![DirEntry {
1207            name: "file1.txt".to_string(),
1208            path: PathBuf::from("/tmp/file1.txt"),
1209            is_dir: false,
1210            is_symlink: false,
1211            size: 100,
1212            mode: "-rw-r--r--".to_string(),
1213        }];
1214        fp.max = 10;
1215        fp.selected = 0;
1216
1217        // Try to move up from top
1218        let up_msg = Message::new(KeyMsg::from_type(KeyType::Up));
1219        let _ = Model::update(&mut fp, up_msg);
1220
1221        assert_eq!(fp.selected, 0, "Cursor should not go below 0");
1222    }
1223
1224    #[test]
1225    fn test_filepicker_cursor_boundary_bottom() {
1226        use bubbletea::{KeyMsg, KeyType, Message};
1227
1228        let mut fp = FilePicker::new();
1229        fp.files = vec![
1230            DirEntry {
1231                name: "file1.txt".to_string(),
1232                path: PathBuf::from("/tmp/file1.txt"),
1233                is_dir: false,
1234                is_symlink: false,
1235                size: 100,
1236                mode: "-rw-r--r--".to_string(),
1237            },
1238            DirEntry {
1239                name: "file2.txt".to_string(),
1240                path: PathBuf::from("/tmp/file2.txt"),
1241                is_dir: false,
1242                is_symlink: false,
1243                size: 200,
1244                mode: "-rw-r--r--".to_string(),
1245            },
1246        ];
1247        fp.max = 10;
1248        fp.selected = 1;
1249
1250        // Try to move down from bottom
1251        let down_msg = Message::new(KeyMsg::from_type(KeyType::Down));
1252        let _ = Model::update(&mut fp, down_msg);
1253
1254        assert_eq!(fp.selected, 1, "Cursor should not exceed file count");
1255    }
1256
1257    #[test]
1258    fn test_filepicker_empty_navigation() {
1259        use bubbletea::{KeyMsg, KeyType, Message};
1260
1261        let mut fp = FilePicker::new();
1262        assert!(fp.files.is_empty());
1263        assert_eq!(fp.selected, 0);
1264
1265        // Navigation on empty should not panic
1266        let down_msg = Message::new(KeyMsg::from_type(KeyType::Down));
1267        let _ = Model::update(&mut fp, down_msg);
1268        assert_eq!(fp.selected, 0, "Empty filepicker cursor should stay at 0");
1269
1270        let up_msg = Message::new(KeyMsg::from_type(KeyType::Up));
1271        let _ = Model::update(&mut fp, up_msg);
1272        assert_eq!(fp.selected, 0);
1273    }
1274
1275    #[test]
1276    fn test_filepicker_j_k_navigation() {
1277        use bubbletea::{KeyMsg, Message};
1278
1279        let mut fp = FilePicker::new();
1280        fp.files = vec![
1281            DirEntry {
1282                name: "a.txt".to_string(),
1283                path: PathBuf::from("/tmp/a.txt"),
1284                is_dir: false,
1285                is_symlink: false,
1286                size: 100,
1287                mode: "-rw-r--r--".to_string(),
1288            },
1289            DirEntry {
1290                name: "b.txt".to_string(),
1291                path: PathBuf::from("/tmp/b.txt"),
1292                is_dir: false,
1293                is_symlink: false,
1294                size: 100,
1295                mode: "-rw-r--r--".to_string(),
1296            },
1297        ];
1298        fp.max = 10;
1299        fp.selected = 0;
1300
1301        // Test 'j' for down
1302        let j_msg = Message::new(KeyMsg::from_char('j'));
1303        let _ = Model::update(&mut fp, j_msg);
1304        assert_eq!(fp.selected, 1, "'j' should move cursor down");
1305
1306        // Test 'k' for up
1307        let k_msg = Message::new(KeyMsg::from_char('k'));
1308        let _ = Model::update(&mut fp, k_msg);
1309        assert_eq!(fp.selected, 0, "'k' should move cursor up");
1310    }
1311
1312    #[test]
1313    fn test_filepicker_page_navigation() {
1314        use bubbletea::{KeyMsg, KeyType, Message};
1315
1316        let mut fp = FilePicker::new();
1317        // Create 20 files
1318        fp.files = (0..20)
1319            .map(|i| DirEntry {
1320                name: format!("file{}.txt", i),
1321                path: PathBuf::from(format!("/tmp/file{}.txt", i)),
1322                is_dir: false,
1323                is_symlink: false,
1324                size: 100,
1325                mode: "-rw-r--r--".to_string(),
1326            })
1327            .collect();
1328        fp.height = 5;
1329        fp.max = fp.height;
1330        fp.selected = 0;
1331
1332        // PageDown
1333        let pgdown_msg = Message::new(KeyMsg::from_type(KeyType::PgDown));
1334        let _ = Model::update(&mut fp, pgdown_msg);
1335        assert!(fp.selected > 0, "PageDown should move cursor down");
1336    }
1337
1338    #[test]
1339    fn test_filepicker_set_show_permissions() {
1340        let mut fp = FilePicker::new();
1341        assert!(fp.show_permissions, "Permissions shown by default");
1342
1343        fp.show_permissions = false;
1344        assert!(!fp.show_permissions);
1345    }
1346
1347    #[test]
1348    fn test_filepicker_set_show_size() {
1349        let mut fp = FilePicker::new();
1350        assert!(fp.show_size, "Size shown by default");
1351
1352        fp.show_size = false;
1353        assert!(!fp.show_size);
1354    }
1355
1356    #[test]
1357    fn test_filepicker_dir_allowed() {
1358        let mut fp = FilePicker::new();
1359        assert!(fp.file_allowed, "Files allowed by default");
1360        assert!(!fp.dir_allowed, "Directories not allowed by default");
1361
1362        fp.dir_allowed = true;
1363        fp.file_allowed = false;
1364        assert!(fp.dir_allowed);
1365        assert!(!fp.file_allowed);
1366    }
1367
1368    #[test]
1369    fn test_filepicker_selected_file() {
1370        let mut fp = FilePicker::new();
1371        fp.files = vec![
1372            DirEntry {
1373                name: "first.txt".to_string(),
1374                path: PathBuf::from("/tmp/first.txt"),
1375                is_dir: false,
1376                is_symlink: false,
1377                size: 100,
1378                mode: "-rw-r--r--".to_string(),
1379            },
1380            DirEntry {
1381                name: "second.txt".to_string(),
1382                path: PathBuf::from("/tmp/second.txt"),
1383                is_dir: false,
1384                is_symlink: false,
1385                size: 200,
1386                mode: "-rw-r--r--".to_string(),
1387            },
1388        ];
1389        fp.max = 10;
1390        fp.selected = 0;
1391
1392        // Check selected file
1393        if let Some(entry) = fp.files.get(fp.selected) {
1394            assert_eq!(entry.name, "first.txt");
1395        }
1396
1397        fp.selected = 1;
1398        if let Some(entry) = fp.files.get(fp.selected) {
1399            assert_eq!(entry.name, "second.txt");
1400        }
1401    }
1402
1403    #[test]
1404    fn test_filepicker_select_key_independent_of_open() {
1405        use bubbletea::{KeyMsg, Message};
1406
1407        let mut fp = FilePicker::new();
1408        fp.key_map.select = Binding::new().keys(&["s"]);
1409        fp.key_map.open = Binding::new().keys(&["enter"]);
1410        fp.files = vec![DirEntry {
1411            name: "selected.txt".to_string(),
1412            path: PathBuf::from("/tmp/selected.txt"),
1413            is_dir: false,
1414            is_symlink: false,
1415            size: 10,
1416            mode: "-rw-r--r--".to_string(),
1417        }];
1418        fp.max = 10;
1419        fp.selected = 0;
1420
1421        let msg = Message::new(KeyMsg::from_char('s'));
1422        let _ = Model::update(&mut fp, msg);
1423
1424        assert_eq!(
1425            fp.selected_path(),
1426            Some(Path::new("/tmp/selected.txt")),
1427            "Select key should set path even when open key differs"
1428        );
1429    }
1430
1431    #[test]
1432    fn test_filepicker_current_directory_accessor() {
1433        let mut fp = FilePicker::new();
1434        let initial_dir = fp.current_directory().to_path_buf();
1435
1436        fp.set_current_directory("/home");
1437        assert_eq!(fp.current_directory(), Path::new("/home"));
1438
1439        fp.set_current_directory("/var/log");
1440        assert_eq!(fp.current_directory(), Path::new("/var/log"));
1441
1442        // Reset
1443        fp.current_directory = initial_dir;
1444    }
1445
1446    // ========================================================================
1447    // Additional Model trait tests for bead charmed_rust-amx (missing tests)
1448    // ========================================================================
1449
1450    #[test]
1451    fn test_filepicker_read_dir_error_updates_state() {
1452        use bubbletea::Message;
1453
1454        let mut fp = FilePicker::new();
1455        let id = fp.id();
1456
1457        // Simulate receiving a ReadDirErrMsg (error reading directory)
1458        let err_msg = ReadDirErrMsg {
1459            id,
1460            error: "Permission denied".to_string(),
1461        };
1462
1463        // The update should handle the error message gracefully
1464        let cmd = Model::update(&mut fp, Message::new(err_msg));
1465        // Currently the implementation just ignores the error (returns None)
1466        // This test verifies it doesn't panic
1467        assert!(cmd.is_none(), "Error handling should not return a command");
1468    }
1469
1470    #[test]
1471    fn test_filepicker_enter_directory_changes_path() {
1472        use bubbletea::{KeyMsg, KeyType, Message};
1473
1474        let mut fp = FilePicker::new();
1475        fp.set_current_directory("/tmp");
1476        fp.files = vec![
1477            DirEntry {
1478                name: "subdir".to_string(),
1479                path: PathBuf::from("/tmp/subdir"),
1480                is_dir: true,
1481                is_symlink: false,
1482                size: 4096,
1483                mode: "drwxr-xr-x".to_string(),
1484            },
1485            DirEntry {
1486                name: "file.txt".to_string(),
1487                path: PathBuf::from("/tmp/file.txt"),
1488                is_dir: false,
1489                is_symlink: false,
1490                size: 100,
1491                mode: "-rw-r--r--".to_string(),
1492            },
1493        ];
1494        fp.max = 10;
1495        fp.selected = 0;
1496
1497        // Press Enter on directory should navigate into it
1498        let enter_msg = Message::new(KeyMsg::from_type(KeyType::Enter));
1499        let cmd = Model::update(&mut fp, enter_msg);
1500
1501        assert_eq!(
1502            fp.current_directory(),
1503            Path::new("/tmp/subdir"),
1504            "Enter on directory should change current path"
1505        );
1506        assert!(
1507            cmd.is_some(),
1508            "Entering directory should return read_dir command"
1509        );
1510    }
1511
1512    #[test]
1513    fn test_filepicker_backspace_goes_parent() {
1514        use bubbletea::{KeyMsg, KeyType, Message};
1515
1516        let mut fp = FilePicker::new();
1517        fp.set_current_directory("/tmp/subdir");
1518        fp.files = vec![DirEntry {
1519            name: "file.txt".to_string(),
1520            path: PathBuf::from("/tmp/subdir/file.txt"),
1521            is_dir: false,
1522            is_symlink: false,
1523            size: 100,
1524            mode: "-rw-r--r--".to_string(),
1525        }];
1526        fp.max = 10;
1527
1528        // Press Backspace should go to parent directory
1529        let back_msg = Message::new(KeyMsg::from_type(KeyType::Backspace));
1530        let cmd = Model::update(&mut fp, back_msg);
1531
1532        assert_eq!(
1533            fp.current_directory(),
1534            Path::new("/tmp"),
1535            "Backspace should navigate to parent directory"
1536        );
1537        assert!(
1538            cmd.is_some(),
1539            "Going to parent should return read_dir command"
1540        );
1541    }
1542
1543    #[test]
1544    fn test_filepicker_view_highlights_selected() {
1545        let mut fp = FilePicker::new();
1546        fp.files = vec![
1547            DirEntry {
1548                name: "first.txt".to_string(),
1549                path: PathBuf::from("/tmp/first.txt"),
1550                is_dir: false,
1551                is_symlink: false,
1552                size: 100,
1553                mode: "-rw-r--r--".to_string(),
1554            },
1555            DirEntry {
1556                name: "second.txt".to_string(),
1557                path: PathBuf::from("/tmp/second.txt"),
1558                is_dir: false,
1559                is_symlink: false,
1560                size: 200,
1561                mode: "-rw-r--r--".to_string(),
1562            },
1563        ];
1564        fp.max = 10;
1565        fp.selected = 0;
1566
1567        let view = fp.view();
1568        // The view should contain the cursor character on the selected line
1569        assert!(
1570            view.contains(&fp.cursor_char),
1571            "View should show cursor on selected item"
1572        );
1573        assert!(
1574            view.contains("first.txt"),
1575            "View should show the first file"
1576        );
1577    }
1578
1579    #[test]
1580    #[allow(clippy::useless_vec)]
1581    fn test_filepicker_view_shows_directories_first() {
1582        // This test verifies that read_directory sorts directories before files
1583        // We test by creating entries in wrong order and checking the sort
1584
1585        let mut entries = vec![
1586            DirEntry {
1587                name: "zebra.txt".to_string(),
1588                path: PathBuf::from("/tmp/zebra.txt"),
1589                is_dir: false,
1590                is_symlink: false,
1591                size: 100,
1592                mode: "-rw-r--r--".to_string(),
1593            },
1594            DirEntry {
1595                name: "apple_dir".to_string(),
1596                path: PathBuf::from("/tmp/apple_dir"),
1597                is_dir: true,
1598                is_symlink: false,
1599                size: 4096,
1600                mode: "drwxr-xr-x".to_string(),
1601            },
1602            DirEntry {
1603                name: "banana.txt".to_string(),
1604                path: PathBuf::from("/tmp/banana.txt"),
1605                is_dir: false,
1606                is_symlink: false,
1607                size: 200,
1608                mode: "-rw-r--r--".to_string(),
1609            },
1610        ];
1611
1612        // Sort same way as read_directory
1613        entries.sort_by(|a, b| match (a.is_dir, b.is_dir) {
1614            (true, false) => std::cmp::Ordering::Less,
1615            (false, true) => std::cmp::Ordering::Greater,
1616            _ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
1617        });
1618
1619        // Directory should be first
1620        assert!(entries[0].is_dir, "Directories should come first");
1621        assert_eq!(entries[0].name, "apple_dir");
1622        // Then files alphabetically
1623        assert_eq!(entries[1].name, "banana.txt");
1624        assert_eq!(entries[2].name, "zebra.txt");
1625    }
1626
1627    #[test]
1628    fn test_filepicker_root_directory_no_parent() {
1629        use bubbletea::{KeyMsg, KeyType, Message};
1630
1631        let mut fp = FilePicker::new();
1632        fp.set_current_directory("/");
1633        fp.files = vec![DirEntry {
1634            name: "etc".to_string(),
1635            path: PathBuf::from("/etc"),
1636            is_dir: true,
1637            is_symlink: false,
1638            size: 4096,
1639            mode: "drwxr-xr-x".to_string(),
1640        }];
1641        fp.max = 10;
1642
1643        // Press Backspace at root should stay at root
1644        let back_msg = Message::new(KeyMsg::from_type(KeyType::Backspace));
1645        let _ = Model::update(&mut fp, back_msg);
1646
1647        // At root, parent() returns None, so we should stay at root or empty path
1648        // The implementation sets current_directory to parent, which for "/" returns None
1649        // and the path stays as-is or becomes empty
1650        let current = fp.current_directory();
1651        assert!(
1652            current == Path::new("/") || current == Path::new(""),
1653            "Should stay at or near root when trying to go up from root"
1654        );
1655    }
1656
1657    #[test]
1658    fn test_filepicker_highlighted_entry() {
1659        let mut fp = FilePicker::new();
1660        fp.files = vec![
1661            DirEntry {
1662                name: "first.txt".to_string(),
1663                path: PathBuf::from("/tmp/first.txt"),
1664                is_dir: false,
1665                is_symlink: false,
1666                size: 100,
1667                mode: "-rw-r--r--".to_string(),
1668            },
1669            DirEntry {
1670                name: "second.txt".to_string(),
1671                path: PathBuf::from("/tmp/second.txt"),
1672                is_dir: false,
1673                is_symlink: false,
1674                size: 200,
1675                mode: "-rw-r--r--".to_string(),
1676            },
1677        ];
1678        fp.selected = 0;
1679
1680        let entry = fp
1681            .highlighted_entry()
1682            .expect("Should have highlighted entry");
1683        assert_eq!(entry.name, "first.txt");
1684
1685        fp.selected = 1;
1686        let entry = fp
1687            .highlighted_entry()
1688            .expect("Should have highlighted entry");
1689        assert_eq!(entry.name, "second.txt");
1690    }
1691
1692    #[test]
1693    fn test_filepicker_window_size_msg() {
1694        use bubbletea::{Message, WindowSizeMsg};
1695
1696        let mut fp = FilePicker::new();
1697        fp.auto_height = true;
1698        assert_eq!(fp.height, 0);
1699
1700        // Simulate window resize
1701        let size_msg = WindowSizeMsg {
1702            width: 80,
1703            height: 24,
1704        };
1705        let _ = Model::update(&mut fp, Message::new(size_msg));
1706
1707        // Height should be updated (height - 5 for auto_height)
1708        assert_eq!(fp.height, 19, "Height should be terminal height minus 5");
1709    }
1710
1711    #[test]
1712    fn test_filepicker_goto_top_and_last() {
1713        use bubbletea::{KeyMsg, Message};
1714
1715        let mut fp = FilePicker::new();
1716        fp.files = (0..10)
1717            .map(|i| DirEntry {
1718                name: format!("file{}.txt", i),
1719                path: PathBuf::from(format!("/tmp/file{}.txt", i)),
1720                is_dir: false,
1721                is_symlink: false,
1722                size: 100,
1723                mode: "-rw-r--r--".to_string(),
1724            })
1725            .collect();
1726        fp.height = 5;
1727        fp.max = fp.height;
1728        fp.selected = 5;
1729
1730        // Press 'g' to go to top
1731        let g_msg = Message::new(KeyMsg::from_char('g'));
1732        let _ = Model::update(&mut fp, g_msg);
1733        assert_eq!(fp.selected, 0, "'g' should go to first item");
1734
1735        // Press 'G' to go to last
1736        let shift_g_msg = Message::new(KeyMsg::from_char('G'));
1737        let _ = Model::update(&mut fp, shift_g_msg);
1738        assert_eq!(fp.selected, 9, "'G' should go to last item");
1739    }
1740}