Skip to main content

ftui_widgets/
file_picker.rs

1#![forbid(unsafe_code)]
2
3//! File picker widget for browsing and selecting files.
4//!
5//! Provides a TUI file browser with keyboard navigation. The widget
6//! renders a directory listing with cursor selection and supports
7//! entering subdirectories and navigating back to parents.
8//!
9//! # Architecture
10//!
11//! - [`FilePicker`] — stateless configuration and rendering
12//! - [`FilePickerState`] — mutable navigation state (cursor, directory, entries)
13//! - [`DirEntry`] — a single file/directory entry
14//!
15//! The widget uses [`StatefulWidget`] so the application owns the state
16//! and can read the selected path.
17
18use crate::{StatefulWidget, clear_text_area, clear_text_row, draw_text_span};
19use ftui_core::geometry::Rect;
20use ftui_render::frame::Frame;
21use ftui_style::Style;
22use std::{
23    io,
24    path::{Path, PathBuf},
25};
26
27/// A single entry in a directory listing.
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct DirEntry {
30    /// Display name.
31    pub name: String,
32    /// Full path.
33    pub path: PathBuf,
34    /// Whether this is a directory.
35    pub is_dir: bool,
36}
37
38impl DirEntry {
39    /// Create a directory entry.
40    pub fn dir(name: impl Into<String>, path: impl Into<PathBuf>) -> Self {
41        Self {
42            name: name.into(),
43            path: path.into(),
44            is_dir: true,
45        }
46    }
47
48    /// Create a file entry.
49    pub fn file(name: impl Into<String>, path: impl Into<PathBuf>) -> Self {
50        Self {
51            name: name.into(),
52            path: path.into(),
53            is_dir: false,
54        }
55    }
56}
57
58/// Mutable state for the file picker.
59#[derive(Debug, Clone)]
60pub struct FilePickerState {
61    /// Current directory being displayed.
62    pub current_dir: PathBuf,
63    /// Root directory for confinement (if set, cannot navigate above this).
64    pub root: Option<PathBuf>,
65    /// Directory entries (sorted: dirs first, then files).
66    pub entries: Vec<DirEntry>,
67    /// Currently highlighted index.
68    pub cursor: usize,
69    /// Scroll offset (first visible row).
70    pub offset: usize,
71    /// The selected/confirmed path (set when user presses enter on a file).
72    pub selected: Option<PathBuf>,
73    /// Navigation history for going back.
74    history: Vec<(PathBuf, usize)>,
75}
76
77impl FilePickerState {
78    /// Create a new state with the given directory and entries.
79    pub fn new(current_dir: PathBuf, entries: Vec<DirEntry>) -> Self {
80        Self {
81            current_dir,
82            root: None,
83            entries,
84            cursor: 0,
85            offset: 0,
86            selected: None,
87            history: Vec::new(),
88        }
89    }
90
91    /// Set a root directory to confine navigation.
92    ///
93    /// When set, the user cannot navigate to a parent directory above this root.
94    #[must_use]
95    pub fn with_root(mut self, root: impl Into<PathBuf>) -> Self {
96        self.root = Some(root.into());
97        self
98    }
99
100    /// Create state from a directory path by reading the filesystem.
101    ///
102    /// Sorts entries: directories first (alphabetical), then files (alphabetical).
103    /// Returns an error if the directory cannot be read.
104    pub fn from_path(path: impl AsRef<Path>) -> std::io::Result<Self> {
105        let path = path.as_ref().to_path_buf();
106        let entries = read_directory(&path)?;
107        Ok(Self::new(path, entries))
108    }
109
110    /// Move cursor up.
111    pub fn cursor_up(&mut self) {
112        if self.cursor > 0 {
113            self.cursor -= 1;
114        }
115    }
116
117    /// Move cursor down.
118    pub fn cursor_down(&mut self) {
119        if !self.entries.is_empty() && self.cursor < self.entries.len() - 1 {
120            self.cursor += 1;
121        }
122    }
123
124    /// Move cursor to the first entry.
125    pub fn cursor_home(&mut self) {
126        self.cursor = 0;
127    }
128
129    /// Move cursor to the last entry.
130    pub fn cursor_end(&mut self) {
131        if !self.entries.is_empty() {
132            self.cursor = self.entries.len() - 1;
133        }
134    }
135
136    /// Page up by `page_size` rows.
137    pub fn page_up(&mut self, page_size: usize) {
138        self.cursor = self.cursor.saturating_sub(page_size);
139    }
140
141    /// Page down by `page_size` rows.
142    pub fn page_down(&mut self, page_size: usize) {
143        if !self.entries.is_empty() {
144            self.cursor = (self.cursor + page_size).min(self.entries.len() - 1);
145        }
146    }
147
148    /// Enter the selected directory (if cursor is on a directory).
149    ///
150    /// Returns `Ok(true)` if navigation succeeded, `Ok(false)` if cursor is on a file,
151    /// or an error if the directory cannot be read.
152    pub fn enter(&mut self) -> std::io::Result<bool> {
153        let Some(entry) = self.entries.get(self.cursor) else {
154            return Ok(false);
155        };
156
157        if !entry.is_dir {
158            if let Some(root) = &self.root {
159                ensure_path_within_root(
160                    &entry.path,
161                    root,
162                    "Cannot select a file outside root directory",
163                )?;
164            }
165            self.selected = Some(entry.path.clone());
166            return Ok(false);
167        }
168
169        let new_dir = entry.path.clone();
170
171        if let Some(root) = &self.root {
172            ensure_path_within_root(
173                &new_dir,
174                root,
175                "Cannot traverse outside root directory via symlink",
176            )?;
177        }
178
179        let new_entries = read_directory(&new_dir)?;
180
181        self.history.push((self.current_dir.clone(), self.cursor));
182        self.current_dir = new_dir;
183        self.entries = new_entries;
184        self.cursor = 0;
185        self.offset = 0;
186        Ok(true)
187    }
188
189    /// Go back to the parent directory.
190    ///
191    /// Returns `Ok(true)` if navigation succeeded.
192    pub fn go_back(&mut self) -> std::io::Result<bool> {
193        // If root is set, prevent going above it using canonicalized paths
194        if let Some(root) = &self.root {
195            let (resolved_curr, resolved_root) = canonicalize_candidate_and_root(
196                &self.current_dir,
197                root,
198                "current file picker directory",
199            )?;
200            if resolved_curr == resolved_root || !resolved_curr.starts_with(&resolved_root) {
201                return Ok(false);
202            }
203        }
204
205        if let Some((prev_dir, prev_cursor)) = self.history.last().cloned() {
206            if let Some(root) = &self.root {
207                ensure_path_within_root(
208                    &prev_dir,
209                    root,
210                    "Cannot restore directory outside root directory",
211                )?;
212            }
213            self.history.pop();
214            let entries = read_directory(&prev_dir)?;
215            self.current_dir = prev_dir;
216            self.entries = entries;
217            self.cursor = prev_cursor.min(self.entries.len().saturating_sub(1));
218            self.offset = 0;
219            return Ok(true);
220        }
221
222        // No history — try parent directory
223        if let Some(parent) = self.current_dir.parent().map(|p| p.to_path_buf()) {
224            if let Some(root) = &self.root
225                && !path_is_within_root(&parent, root)?
226            {
227                return Ok(false); // Block parent traversal outside root
228            }
229
230            let entries = read_directory(&parent)?;
231            self.current_dir = parent;
232            self.entries = entries;
233            self.cursor = 0;
234            self.offset = 0;
235            return Ok(true);
236        }
237
238        Ok(false)
239    }
240
241    /// Ensure scroll offset keeps cursor visible for the given viewport height.
242    fn adjust_scroll(&mut self, visible_rows: usize) {
243        if visible_rows == 0 {
244            return;
245        }
246        if self.cursor < self.offset {
247            self.offset = self.cursor;
248        }
249        if self.cursor >= self.offset + visible_rows {
250            self.offset = self.cursor + 1 - visible_rows;
251        }
252    }
253}
254
255fn canonicalize_for_confinement(path: &Path, label: &str) -> io::Result<PathBuf> {
256    std::fs::canonicalize(path).map_err(|error| {
257        io::Error::new(
258            error.kind(),
259            format!("Cannot resolve {label} {}: {error}", path.display()),
260        )
261    })
262}
263
264fn canonicalize_candidate_and_root(
265    candidate: &Path,
266    root: &Path,
267    label: &str,
268) -> io::Result<(PathBuf, PathBuf)> {
269    let resolved_candidate = canonicalize_for_confinement(candidate, label)?;
270    let resolved_root = canonicalize_for_confinement(root, "file picker root")?;
271    Ok((resolved_candidate, resolved_root))
272}
273
274fn path_is_within_root(candidate: &Path, root: &Path) -> io::Result<bool> {
275    let (resolved_candidate, resolved_root) =
276        canonicalize_candidate_and_root(candidate, root, "file picker path")?;
277    Ok(resolved_candidate.starts_with(resolved_root))
278}
279
280fn ensure_path_within_root(candidate: &Path, root: &Path, message: &str) -> io::Result<()> {
281    if path_is_within_root(candidate, root)? {
282        return Ok(());
283    }
284
285    Err(io::Error::new(io::ErrorKind::PermissionDenied, message))
286}
287
288/// Read a directory and return sorted entries (dirs first, then files).
289fn read_directory(path: &Path) -> std::io::Result<Vec<DirEntry>> {
290    let mut dirs = Vec::new();
291    let mut files = Vec::new();
292
293    for entry in std::fs::read_dir(path)? {
294        let entry = entry?;
295        let name = entry.file_name().to_string_lossy().to_string();
296        let mut file_type = entry.file_type()?;
297        let full_path = entry.path();
298
299        // If it's a symlink, check what it points to
300        if file_type.is_symlink()
301            && let Ok(metadata) = std::fs::metadata(&full_path)
302        {
303            file_type = metadata.file_type();
304        }
305
306        if file_type.is_dir() {
307            dirs.push(DirEntry::dir(name, full_path));
308        } else {
309            files.push(DirEntry::file(name, full_path));
310        }
311    }
312
313    dirs.sort_by_key(|a| a.name.to_lowercase());
314    files.sort_by_key(|a| a.name.to_lowercase());
315
316    dirs.extend(files);
317    Ok(dirs)
318}
319
320/// Configuration and rendering for the file picker widget.
321///
322/// # Example
323///
324/// ```ignore
325/// let picker = FilePicker::new()
326///     .dir_style(Style::new().fg(PackedRgba::rgb(100, 100, 255)))
327///     .cursor_style(Style::new().bold());
328///
329/// let mut state = FilePickerState::from_path(".").unwrap();
330/// picker.render(area, &mut frame, &mut state);
331/// ```
332#[derive(Debug, Clone)]
333pub struct FilePicker {
334    /// Style for directory entries.
335    pub dir_style: Style,
336    /// Style for file entries.
337    pub file_style: Style,
338    /// Style for the cursor row.
339    pub cursor_style: Style,
340    /// Style for the header (current directory).
341    pub header_style: Style,
342    /// Whether to show the current directory path as a header.
343    pub show_header: bool,
344    /// Prefix for directory entries.
345    pub dir_prefix: &'static str,
346    /// Prefix for file entries.
347    pub file_prefix: &'static str,
348}
349
350impl Default for FilePicker {
351    fn default() -> Self {
352        Self {
353            dir_style: Style::default(),
354            file_style: Style::default(),
355            cursor_style: Style::default(),
356            header_style: Style::default(),
357            show_header: true,
358            dir_prefix: "📁 ",
359            file_prefix: "  ",
360        }
361    }
362}
363
364impl FilePicker {
365    /// Create a new file picker with default styles.
366    pub fn new() -> Self {
367        Self::default()
368    }
369
370    /// Set the directory entry style.
371    #[must_use]
372    pub fn dir_style(mut self, style: Style) -> Self {
373        self.dir_style = style;
374        self
375    }
376
377    /// Set the file entry style.
378    #[must_use]
379    pub fn file_style(mut self, style: Style) -> Self {
380        self.file_style = style;
381        self
382    }
383
384    /// Set the cursor (highlight) style.
385    #[must_use]
386    pub fn cursor_style(mut self, style: Style) -> Self {
387        self.cursor_style = style;
388        self
389    }
390
391    /// Set the header style.
392    #[must_use]
393    pub fn header_style(mut self, style: Style) -> Self {
394        self.header_style = style;
395        self
396    }
397
398    /// Toggle header display.
399    #[must_use]
400    pub fn show_header(mut self, show: bool) -> Self {
401        self.show_header = show;
402        self
403    }
404}
405
406impl StatefulWidget for FilePicker {
407    type State = FilePickerState;
408
409    fn render(&self, area: Rect, frame: &mut Frame, state: &mut Self::State) {
410        if area.is_empty() {
411            return;
412        }
413
414        let deg = frame.buffer.degradation;
415        if !deg.render_content() {
416            return;
417        }
418
419        clear_text_area(frame, area, Style::default());
420
421        let header_style = if deg.apply_styling() {
422            self.header_style
423        } else {
424            Style::default()
425        };
426        let dir_style = if deg.apply_styling() {
427            self.dir_style
428        } else {
429            Style::default()
430        };
431        let file_style = if deg.apply_styling() {
432            self.file_style
433        } else {
434            Style::default()
435        };
436        let cursor_style = if deg.apply_styling() {
437            self.cursor_style
438        } else {
439            Style::default()
440        };
441
442        let mut y = area.y;
443        let max_y = area.bottom();
444
445        // Header: current directory path
446        if self.show_header && y < max_y {
447            clear_text_row(frame, Rect::new(area.x, y, area.width, 1), header_style);
448            let header = state.current_dir.to_string_lossy();
449            draw_text_span(frame, area.x, y, &header, header_style, area.right());
450            y += 1;
451        }
452
453        if y >= max_y {
454            return;
455        }
456
457        let visible_rows = (max_y - y) as usize;
458        state.adjust_scroll(visible_rows);
459
460        if state.entries.is_empty() {
461            clear_text_row(frame, Rect::new(area.x, y, area.width, 1), file_style);
462            draw_text_span(
463                frame,
464                area.x,
465                y,
466                "(empty directory)",
467                file_style,
468                area.right(),
469            );
470            return;
471        }
472
473        let end_idx = (state.offset + visible_rows).min(state.entries.len());
474        for (i, entry) in state.entries[state.offset..end_idx].iter().enumerate() {
475            if y >= max_y {
476                break;
477            }
478
479            let actual_idx = state.offset + i;
480            let is_cursor = actual_idx == state.cursor;
481
482            let prefix = if entry.is_dir {
483                self.dir_prefix
484            } else {
485                self.file_prefix
486            };
487
488            let base_style = if entry.is_dir { dir_style } else { file_style };
489
490            let style = if is_cursor {
491                cursor_style.merge(&base_style)
492            } else {
493                base_style
494            };
495
496            clear_text_row(frame, Rect::new(area.x, y, area.width, 1), style);
497
498            // Draw cursor indicator
499            let mut x = area.x;
500            if is_cursor {
501                draw_text_span(frame, x, y, "> ", cursor_style, area.right());
502                x = x.saturating_add(2);
503            } else {
504                x = x.saturating_add(2);
505            }
506
507            // Draw prefix + name
508            x = draw_text_span(frame, x, y, prefix, style, area.right());
509            draw_text_span(frame, x, y, &entry.name, style, area.right());
510
511            y += 1;
512        }
513    }
514}
515
516#[cfg(test)]
517mod tests {
518    use super::*;
519    use ftui_render::grapheme_pool::GraphemePool;
520
521    fn buf_to_lines(buf: &ftui_render::buffer::Buffer) -> Vec<String> {
522        let mut lines = Vec::new();
523        for y in 0..buf.height() {
524            let mut row = String::with_capacity(buf.width() as usize);
525            for x in 0..buf.width() {
526                let ch = buf
527                    .get(x, y)
528                    .and_then(|c| c.content.as_char())
529                    .unwrap_or(' ');
530                row.push(ch);
531            }
532            lines.push(row);
533        }
534        lines
535    }
536
537    fn make_entries() -> Vec<DirEntry> {
538        vec![
539            DirEntry::dir("docs", "/tmp/docs"),
540            DirEntry::dir("src", "/tmp/src"),
541            DirEntry::file("README.md", "/tmp/README.md"),
542            DirEntry::file("main.rs", "/tmp/main.rs"),
543        ]
544    }
545
546    fn make_state() -> FilePickerState {
547        FilePickerState::new(PathBuf::from("/tmp"), make_entries())
548    }
549
550    #[test]
551    fn dir_entry_constructors() {
552        let d = DirEntry::dir("src", "/src");
553        assert!(d.is_dir);
554        assert_eq!(d.name, "src");
555
556        let f = DirEntry::file("main.rs", "/main.rs");
557        assert!(!f.is_dir);
558        assert_eq!(f.name, "main.rs");
559    }
560
561    #[test]
562    fn state_cursor_movement() {
563        let mut state = make_state();
564        assert_eq!(state.cursor, 0);
565
566        state.cursor_down();
567        assert_eq!(state.cursor, 1);
568
569        state.cursor_down();
570        state.cursor_down();
571        assert_eq!(state.cursor, 3);
572
573        // Can't go past end
574        state.cursor_down();
575        assert_eq!(state.cursor, 3);
576
577        state.cursor_up();
578        assert_eq!(state.cursor, 2);
579
580        state.cursor_home();
581        assert_eq!(state.cursor, 0);
582
583        // Can't go before start
584        state.cursor_up();
585        assert_eq!(state.cursor, 0);
586
587        state.cursor_end();
588        assert_eq!(state.cursor, 3);
589    }
590
591    #[test]
592    fn state_page_navigation() {
593        let entries: Vec<DirEntry> = (0..20)
594            .map(|i| DirEntry::file(format!("file{i}.txt"), format!("/tmp/file{i}.txt")))
595            .collect();
596        let mut state = FilePickerState::new(PathBuf::from("/tmp"), entries);
597
598        state.page_down(5);
599        assert_eq!(state.cursor, 5);
600
601        state.page_down(5);
602        assert_eq!(state.cursor, 10);
603
604        state.page_up(3);
605        assert_eq!(state.cursor, 7);
606
607        state.page_up(100);
608        assert_eq!(state.cursor, 0);
609
610        state.page_down(100);
611        assert_eq!(state.cursor, 19);
612    }
613
614    #[test]
615    fn state_empty_entries() {
616        let mut state = FilePickerState::new(PathBuf::from("/tmp"), vec![]);
617        state.cursor_down(); // should not panic
618        state.cursor_up();
619        state.cursor_end();
620        state.cursor_home();
621        state.page_down(10);
622        state.page_up(10);
623        assert_eq!(state.cursor, 0);
624    }
625
626    #[test]
627    fn adjust_scroll_keeps_cursor_visible() {
628        let entries: Vec<DirEntry> = (0..20)
629            .map(|i| DirEntry::file(format!("f{i}"), format!("/f{i}")))
630            .collect();
631        let mut state = FilePickerState::new(PathBuf::from("/"), entries);
632
633        state.cursor = 15;
634        state.adjust_scroll(5);
635        // cursor=15 should be visible in a 5-row window
636        assert!(state.offset <= 15);
637        assert!(state.offset + 5 > 15);
638
639        state.cursor = 0;
640        state.adjust_scroll(5);
641        assert_eq!(state.offset, 0);
642    }
643
644    #[test]
645    fn render_basic() {
646        let picker = FilePicker::new().show_header(false);
647        let mut state = make_state();
648
649        let area = Rect::new(0, 0, 30, 5);
650        let mut pool = GraphemePool::new();
651        let mut frame = Frame::new(30, 5, &mut pool);
652
653        picker.render(area, &mut frame, &mut state);
654        let lines = buf_to_lines(&frame.buffer);
655
656        // First entry should have cursor indicator "> "
657        assert!(lines[0].starts_with("> "));
658        // Should contain directory and file names
659        let all_text = lines.join("\n");
660        assert!(all_text.contains("docs"));
661        assert!(all_text.contains("src"));
662        assert!(all_text.contains("README.md"));
663        assert!(all_text.contains("main.rs"));
664    }
665
666    #[test]
667    fn render_with_header() {
668        let picker = FilePicker::new().show_header(true);
669        let mut state = make_state();
670
671        let area = Rect::new(0, 0, 30, 6);
672        let mut pool = GraphemePool::new();
673        let mut frame = Frame::new(30, 6, &mut pool);
674
675        picker.render(area, &mut frame, &mut state);
676        let lines = buf_to_lines(&frame.buffer);
677
678        // First line should be the directory path
679        assert!(lines[0].starts_with("/tmp"));
680    }
681
682    #[test]
683    fn render_empty_directory() {
684        let picker = FilePicker::new().show_header(false);
685        let mut state = FilePickerState::new(PathBuf::from("/empty"), vec![]);
686
687        let area = Rect::new(0, 0, 30, 3);
688        let mut pool = GraphemePool::new();
689        let mut frame = Frame::new(30, 3, &mut pool);
690
691        picker.render(area, &mut frame, &mut state);
692        let lines = buf_to_lines(&frame.buffer);
693
694        assert!(lines[0].contains("empty directory"));
695    }
696
697    #[test]
698    fn render_scrolling() {
699        let entries: Vec<DirEntry> = (0..20)
700            .map(|i| DirEntry::file(format!("file{i:02}.txt"), format!("/tmp/file{i:02}.txt")))
701            .collect();
702        let mut state = FilePickerState::new(PathBuf::from("/tmp"), entries);
703        let picker = FilePicker::new().show_header(false);
704
705        // Move cursor to item 15, viewport is 5 rows
706        state.cursor = 15;
707        let area = Rect::new(0, 0, 30, 5);
708        let mut pool = GraphemePool::new();
709        let mut frame = Frame::new(30, 5, &mut pool);
710
711        picker.render(area, &mut frame, &mut state);
712        let lines = buf_to_lines(&frame.buffer);
713
714        // file15 should be visible (with cursor)
715        let all_text = lines.join("\n");
716        assert!(all_text.contains("file15"));
717    }
718
719    #[test]
720    fn cursor_style_applied_to_selected_row() {
721        use ftui_render::cell::PackedRgba;
722
723        let picker = FilePicker::new()
724            .show_header(false)
725            .cursor_style(Style::new().fg(PackedRgba::rgb(255, 0, 0)));
726        let mut state = make_state();
727        state.cursor = 1; // "src"
728
729        let area = Rect::new(0, 0, 30, 4);
730        let mut pool = GraphemePool::new();
731        let mut frame = Frame::new(30, 4, &mut pool);
732
733        picker.render(area, &mut frame, &mut state);
734
735        // The cursor row (y=1) should have the cursor indicator
736        let lines = buf_to_lines(&frame.buffer);
737        assert!(lines[1].starts_with("> "));
738        // Non-cursor rows should not
739        assert!(!lines[0].starts_with("> "));
740    }
741
742    #[test]
743    fn selected_set_on_file_entry() {
744        let mut state = make_state();
745        state.cursor = 2; // README.md (a file)
746
747        // enter() on a file should set selected
748        let result = state.enter();
749        assert!(result.is_ok());
750        assert_eq!(state.selected, Some(PathBuf::from("/tmp/README.md")));
751    }
752
753    #[test]
754    fn enter_on_file_rejects_canonical_path_outside_root() {
755        let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
756        let repo = root
757            .parent()
758            .and_then(Path::parent)
759            .expect("crate should be under workspace crates directory");
760        let outside_file = repo.join("Cargo.toml");
761        let mut state = FilePickerState::new(
762            root.clone(),
763            vec![DirEntry::file("Cargo.toml", outside_file)],
764        )
765        .with_root(root);
766
767        let error = state
768            .enter()
769            .expect_err("root confinement should reject outside file selections");
770
771        assert_eq!(error.kind(), std::io::ErrorKind::PermissionDenied);
772        assert!(state.selected.is_none());
773    }
774
775    #[test]
776    fn enter_on_directory_with_unresolvable_root_fails_closed() {
777        let current_dir = std::env::current_dir().expect("test should run inside the workspace");
778        let missing_root =
779            current_dir.join(format!(".missing-file-picker-root-{}", std::process::id()));
780        let target_dir = std::env::temp_dir();
781        let mut state = FilePickerState::new(
782            current_dir.clone(),
783            vec![DirEntry::dir("tmp", target_dir.clone())],
784        )
785        .with_root(missing_root);
786
787        let error = state
788            .enter()
789            .expect_err("unresolvable confinement root should fail closed");
790
791        assert_eq!(error.kind(), std::io::ErrorKind::NotFound);
792        assert_eq!(state.current_dir, current_dir);
793        assert_eq!(state.entries[0].path, target_dir);
794    }
795
796    // ── DirEntry edge cases ───────────────────────────────────────
797
798    #[test]
799    fn dir_entry_equality() {
800        let a = DirEntry::dir("src", "/src");
801        let b = DirEntry::dir("src", "/src");
802        assert_eq!(a, b);
803
804        let c = DirEntry::file("src", "/src");
805        assert_ne!(a, c, "dir vs file should differ");
806    }
807
808    #[test]
809    fn dir_entry_clone() {
810        let orig = DirEntry::file("main.rs", "/main.rs");
811        let cloned = orig.clone();
812        assert_eq!(orig, cloned);
813    }
814
815    #[test]
816    fn dir_entry_debug_format() {
817        let e = DirEntry::dir("test", "/test");
818        let dbg = format!("{e:?}");
819        assert!(dbg.contains("test"));
820        assert!(dbg.contains("is_dir: true"));
821    }
822
823    // ── FilePickerState construction ──────────────────────────────
824
825    #[test]
826    fn state_new_defaults() {
827        let state = FilePickerState::new(PathBuf::from("/home"), vec![]);
828        assert_eq!(state.current_dir, PathBuf::from("/home"));
829        assert_eq!(state.cursor, 0);
830        assert_eq!(state.offset, 0);
831        assert!(state.selected.is_none());
832        assert!(state.root.is_none());
833        assert!(state.entries.is_empty());
834    }
835
836    #[test]
837    fn state_with_root_sets_root() {
838        let state = FilePickerState::new(PathBuf::from("/home/user"), vec![]).with_root("/home");
839        assert_eq!(state.root, Some(PathBuf::from("/home")));
840    }
841
842    // ── Cursor on single entry ────────────────────────────────────
843
844    #[test]
845    fn cursor_movement_single_entry() {
846        let entries = vec![DirEntry::file("only.txt", "/only.txt")];
847        let mut state = FilePickerState::new(PathBuf::from("/"), entries);
848
849        assert_eq!(state.cursor, 0);
850        state.cursor_down();
851        assert_eq!(state.cursor, 0, "can't go past single entry");
852        state.cursor_up();
853        assert_eq!(state.cursor, 0);
854        state.cursor_end();
855        assert_eq!(state.cursor, 0);
856        state.cursor_home();
857        assert_eq!(state.cursor, 0);
858    }
859
860    #[test]
861    fn page_down_clamps_to_last() {
862        let entries: Vec<DirEntry> = (0..5)
863            .map(|i| DirEntry::file(format!("f{i}"), format!("/f{i}")))
864            .collect();
865        let mut state = FilePickerState::new(PathBuf::from("/"), entries);
866
867        state.page_down(100);
868        assert_eq!(state.cursor, 4);
869    }
870
871    #[test]
872    fn page_up_clamps_to_zero() {
873        let entries: Vec<DirEntry> = (0..5)
874            .map(|i| DirEntry::file(format!("f{i}"), format!("/f{i}")))
875            .collect();
876        let mut state = FilePickerState::new(PathBuf::from("/"), entries);
877        state.cursor = 3;
878
879        state.page_up(100);
880        assert_eq!(state.cursor, 0);
881    }
882
883    #[test]
884    fn page_operations_on_empty_entries() {
885        let mut state = FilePickerState::new(PathBuf::from("/"), vec![]);
886        state.page_down(10);
887        assert_eq!(state.cursor, 0);
888        state.page_up(10);
889        assert_eq!(state.cursor, 0);
890    }
891
892    // ── enter() edge cases ────────────────────────────────────────
893
894    #[test]
895    fn enter_on_empty_entries_returns_false() {
896        let mut state = FilePickerState::new(PathBuf::from("/"), vec![]);
897        let result = state.enter();
898        assert!(result.is_ok());
899        assert!(!result.unwrap());
900        assert!(state.selected.is_none());
901    }
902
903    #[test]
904    fn enter_on_file_sets_selected_without_navigation() {
905        let entries = vec![
906            DirEntry::dir("sub", "/sub"),
907            DirEntry::file("readme.txt", "/readme.txt"),
908        ];
909        let mut state = FilePickerState::new(PathBuf::from("/"), entries);
910        state.cursor = 1;
911
912        let result = state.enter().unwrap();
913        assert!(!result, "enter on file returns false (no navigation)");
914        assert_eq!(state.selected, Some(PathBuf::from("/readme.txt")));
915        // Current directory unchanged.
916        assert_eq!(state.current_dir, PathBuf::from("/"));
917    }
918
919    // ── go_back() edge cases ──────────────────────────────────────
920
921    #[test]
922    fn go_back_blocked_at_root() {
923        let root = std::env::temp_dir();
924        let mut state = FilePickerState::new(root.clone(), vec![]).with_root(root);
925
926        let changed = state.go_back().unwrap();
927        assert!(!changed, "go_back should be blocked when already at root");
928    }
929
930    #[test]
931    fn go_back_without_history_uses_parent_directory() {
932        let current = std::env::temp_dir();
933        let parent = current
934            .parent()
935            .expect("temp_dir should have a parent")
936            .to_path_buf();
937
938        let mut state = FilePickerState::new(current.clone(), vec![]);
939        let changed = state.go_back().unwrap();
940
941        assert!(
942            changed,
943            "go_back should navigate to parent when history is empty"
944        );
945        assert_eq!(state.current_dir, parent);
946        assert_eq!(state.cursor, 0, "parent navigation resets cursor to home");
947    }
948
949    #[test]
950    fn go_back_restores_history_cursor_with_clamp() {
951        let child = std::env::temp_dir();
952        let parent = child
953            .parent()
954            .expect("temp_dir should have a parent")
955            .to_path_buf();
956
957        let mut state = FilePickerState::new(
958            parent.clone(),
959            vec![
960                DirEntry::file("placeholder.txt", parent.join("placeholder.txt")),
961                DirEntry::dir("child", child.clone()),
962            ],
963        );
964        state.cursor = 1;
965
966        let entered = state.enter().unwrap();
967        assert!(entered, "enter should navigate into selected directory");
968
969        let went_back = state.go_back().unwrap();
970        assert!(
971            went_back,
972            "go_back should restore previous directory from history"
973        );
974        assert_eq!(state.current_dir, parent);
975
976        let expected_cursor = 1.min(state.entries.len().saturating_sub(1));
977        assert_eq!(state.cursor, expected_cursor);
978    }
979
980    // ── adjust_scroll edge cases ──────────────────────────────────
981
982    #[test]
983    fn adjust_scroll_zero_visible_rows_is_noop() {
984        let entries: Vec<DirEntry> = (0..10)
985            .map(|i| DirEntry::file(format!("f{i}"), format!("/f{i}")))
986            .collect();
987        let mut state = FilePickerState::new(PathBuf::from("/"), entries);
988        state.cursor = 5;
989        state.offset = 0;
990
991        state.adjust_scroll(0);
992        assert_eq!(
993            state.offset, 0,
994            "zero visible rows should not change offset"
995        );
996    }
997
998    #[test]
999    fn adjust_scroll_cursor_above_viewport() {
1000        let entries: Vec<DirEntry> = (0..20)
1001            .map(|i| DirEntry::file(format!("f{i}"), format!("/f{i}")))
1002            .collect();
1003        let mut state = FilePickerState::new(PathBuf::from("/"), entries);
1004        state.offset = 10;
1005        state.cursor = 5;
1006
1007        state.adjust_scroll(5);
1008        assert_eq!(state.offset, 5, "offset should snap to cursor");
1009    }
1010
1011    #[test]
1012    fn adjust_scroll_cursor_below_viewport() {
1013        let entries: Vec<DirEntry> = (0..20)
1014            .map(|i| DirEntry::file(format!("f{i}"), format!("/f{i}")))
1015            .collect();
1016        let mut state = FilePickerState::new(PathBuf::from("/"), entries);
1017        state.offset = 0;
1018        state.cursor = 10;
1019
1020        state.adjust_scroll(5);
1021        // cursor=10 should be the last visible row: offset + 5 > 10 → offset = 6
1022        assert_eq!(state.offset, 6);
1023    }
1024
1025    // ── FilePicker builder ────────────────────────────────────────
1026
1027    #[test]
1028    fn file_picker_default_values() {
1029        let picker = FilePicker::default();
1030        assert!(picker.show_header);
1031        assert_eq!(picker.dir_prefix, "📁 ");
1032        assert_eq!(picker.file_prefix, "  ");
1033    }
1034
1035    #[test]
1036    fn file_picker_builder_chain() {
1037        let picker = FilePicker::new()
1038            .dir_style(Style::default())
1039            .file_style(Style::default())
1040            .cursor_style(Style::default())
1041            .header_style(Style::default())
1042            .show_header(false);
1043        assert!(!picker.show_header);
1044    }
1045
1046    #[test]
1047    fn file_picker_debug_format() {
1048        let picker = FilePicker::new();
1049        let dbg = format!("{picker:?}");
1050        assert!(dbg.contains("FilePicker"));
1051    }
1052
1053    // ── Render edge cases ─────────────────────────────────────────
1054
1055    #[test]
1056    fn render_zero_area_is_noop() {
1057        let picker = FilePicker::new();
1058        let mut state = make_state();
1059
1060        let area = Rect::new(0, 0, 0, 0);
1061        let mut pool = GraphemePool::new();
1062        let mut frame = Frame::new(30, 5, &mut pool);
1063
1064        picker.render(area, &mut frame, &mut state);
1065        // No crash, buffer untouched.
1066        let lines = buf_to_lines(&frame.buffer);
1067        assert!(lines[0].trim().is_empty());
1068    }
1069
1070    #[test]
1071    fn render_height_one_shows_only_header() {
1072        let picker = FilePicker::new().show_header(true);
1073        let mut state = make_state();
1074
1075        let area = Rect::new(0, 0, 30, 1);
1076        let mut pool = GraphemePool::new();
1077        let mut frame = Frame::new(30, 5, &mut pool);
1078
1079        picker.render(area, &mut frame, &mut state);
1080        let lines = buf_to_lines(&frame.buffer);
1081        // Only the header row should have content.
1082        assert!(lines[0].starts_with("/tmp"));
1083        // Row 1 should be empty (no room for entries).
1084        assert!(lines[1].trim().is_empty());
1085    }
1086
1087    #[test]
1088    fn render_no_header_uses_full_area_for_entries() {
1089        let picker = FilePicker::new().show_header(false);
1090        let mut state = make_state();
1091
1092        let area = Rect::new(0, 0, 30, 4);
1093        let mut pool = GraphemePool::new();
1094        let mut frame = Frame::new(30, 4, &mut pool);
1095
1096        picker.render(area, &mut frame, &mut state);
1097        let lines = buf_to_lines(&frame.buffer);
1098        // First line should be an entry (cursor on first entry), not a header.
1099        assert!(lines[0].starts_with("> "));
1100    }
1101
1102    #[test]
1103    fn render_cursor_on_last_entry() {
1104        let picker = FilePicker::new().show_header(false);
1105        let mut state = make_state();
1106        state.cursor = 3; // last entry: main.rs
1107
1108        let area = Rect::new(0, 0, 30, 5);
1109        let mut pool = GraphemePool::new();
1110        let mut frame = Frame::new(30, 5, &mut pool);
1111
1112        picker.render(area, &mut frame, &mut state);
1113        let lines = buf_to_lines(&frame.buffer);
1114        // The cursor row should contain "main.rs".
1115        let cursor_line = lines.iter().find(|l| l.starts_with("> ")).unwrap();
1116        assert!(cursor_line.contains("main.rs"));
1117    }
1118
1119    #[test]
1120    fn render_area_offset() {
1121        // Render into a sub-area of a larger buffer.
1122        let picker = FilePicker::new().show_header(false);
1123        let mut state = make_state();
1124
1125        let area = Rect::new(5, 2, 20, 3);
1126        let mut pool = GraphemePool::new();
1127        let mut frame = Frame::new(30, 10, &mut pool);
1128
1129        picker.render(area, &mut frame, &mut state);
1130        let lines = buf_to_lines(&frame.buffer);
1131        // Rows 0 and 1 should be empty (area starts at y=2).
1132        assert!(lines[0].trim().is_empty());
1133        assert!(lines[1].trim().is_empty());
1134        // Row 2 should have content starting at x=5.
1135        assert!(lines[2].len() >= 7);
1136    }
1137
1138    #[test]
1139    fn render_shorter_header_and_fewer_entries_clear_stale_content() {
1140        let picker = FilePicker::new().show_header(true);
1141        let mut state = FilePickerState::new(PathBuf::from("/tmp/very/long/path"), make_entries());
1142
1143        let area = Rect::new(0, 0, 24, 4);
1144        let mut pool = GraphemePool::new();
1145        let mut frame = Frame::new(24, 4, &mut pool);
1146
1147        picker.render(area, &mut frame, &mut state);
1148
1149        state.current_dir = PathBuf::from("/x");
1150        state.entries = vec![DirEntry::file("a", "/x/a")];
1151        state.cursor = 0;
1152        state.offset = 0;
1153
1154        picker.render(area, &mut frame, &mut state);
1155        let lines = buf_to_lines(&frame.buffer);
1156
1157        assert_eq!(lines[0], format!("{:<24}", "/x"));
1158        assert!(lines[1].starts_with(">   a"));
1159        assert_eq!(lines[2], " ".repeat(24));
1160        assert_eq!(lines[3], " ".repeat(24));
1161    }
1162}