1#![forbid(unsafe_code)]
2
3use 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#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct DirEntry {
30 pub name: String,
32 pub path: PathBuf,
34 pub is_dir: bool,
36}
37
38impl DirEntry {
39 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 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#[derive(Debug, Clone)]
60pub struct FilePickerState {
61 pub current_dir: PathBuf,
63 pub root: Option<PathBuf>,
65 pub entries: Vec<DirEntry>,
67 pub cursor: usize,
69 pub offset: usize,
71 pub selected: Option<PathBuf>,
73 history: Vec<(PathBuf, usize)>,
75}
76
77impl FilePickerState {
78 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 #[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 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 pub fn cursor_up(&mut self) {
112 if self.cursor > 0 {
113 self.cursor -= 1;
114 }
115 }
116
117 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 pub fn cursor_home(&mut self) {
126 self.cursor = 0;
127 }
128
129 pub fn cursor_end(&mut self) {
131 if !self.entries.is_empty() {
132 self.cursor = self.entries.len() - 1;
133 }
134 }
135
136 pub fn page_up(&mut self, page_size: usize) {
138 self.cursor = self.cursor.saturating_sub(page_size);
139 }
140
141 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 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 pub fn go_back(&mut self) -> std::io::Result<bool> {
193 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 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); }
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 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
288fn 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 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#[derive(Debug, Clone)]
333pub struct FilePicker {
334 pub dir_style: Style,
336 pub file_style: Style,
338 pub cursor_style: Style,
340 pub header_style: Style,
342 pub show_header: bool,
344 pub dir_prefix: &'static str,
346 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 pub fn new() -> Self {
367 Self::default()
368 }
369
370 #[must_use]
372 pub fn dir_style(mut self, style: Style) -> Self {
373 self.dir_style = style;
374 self
375 }
376
377 #[must_use]
379 pub fn file_style(mut self, style: Style) -> Self {
380 self.file_style = style;
381 self
382 }
383
384 #[must_use]
386 pub fn cursor_style(mut self, style: Style) -> Self {
387 self.cursor_style = style;
388 self
389 }
390
391 #[must_use]
393 pub fn header_style(mut self, style: Style) -> Self {
394 self.header_style = style;
395 self
396 }
397
398 #[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 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 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 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 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 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(); 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 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 assert!(lines[0].starts_with("> "));
658 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 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 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 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; 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 let lines = buf_to_lines(&frame.buffer);
737 assert!(lines[1].starts_with("> "));
738 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; 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 #[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 #[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 #[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 #[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 assert_eq!(state.current_dir, PathBuf::from("/"));
917 }
918
919 #[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 #[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 assert_eq!(state.offset, 6);
1023 }
1024
1025 #[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 #[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 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 assert!(lines[0].starts_with("/tmp"));
1083 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 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; 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 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 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 assert!(lines[0].trim().is_empty());
1133 assert!(lines[1].trim().is_empty());
1134 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}