1use std::cmp::Reverse;
59use std::path::{Path, PathBuf};
60
61use crate::{
62 Backend, Icon, Key, Modifiers, NamedKey, Palette, PaletteItem, Rect, StyledText, UiEvent,
63 WidgetId,
64};
65
66pub const PALETTE_CHROME_ROWS: usize = 4;
77
78pub const DEFAULT_MAX_DEPTH: usize = 5;
82
83pub const DEFAULT_IGNORE_DIRS: &[&str] = &["target", "node_modules", "__pycache__"];
88
89#[derive(Debug, Clone, PartialEq)]
91pub enum FolderPickerEvent {
92 Confirmed { path: PathBuf },
100 Cancelled,
102 Consumed,
104 Ignored,
106}
107
108pub struct FolderPickerController {
117 id: WidgetId,
119 root: PathBuf,
121 query: String,
123 all_entries: Vec<PathBuf>,
125 filtered: Vec<PathBuf>,
127 selected: usize,
129 scroll_top: usize,
131 show_hidden: bool,
133 extra_file_names: Vec<String>,
138 ignore_dirs: Vec<String>,
143 max_depth: usize,
148}
149
150impl FolderPickerController {
151 pub fn new(root: impl Into<PathBuf>, extra_file_names: Vec<String>, show_hidden: bool) -> Self {
163 let root = root.into();
164 let ignore_dirs: Vec<String> = DEFAULT_IGNORE_DIRS
165 .iter()
166 .map(|s| (*s).to_string())
167 .collect();
168 let max_depth = DEFAULT_MAX_DEPTH;
169 let all_entries = collect_dir_entries(
170 &root,
171 show_hidden,
172 &extra_file_names,
173 &ignore_dirs,
174 max_depth,
175 );
176 let filtered = all_entries.iter().take(50).cloned().collect();
177 Self {
178 id: WidgetId::new("folder_picker"),
179 root,
180 query: String::new(),
181 all_entries,
182 filtered,
183 selected: 0,
184 scroll_top: 0,
185 show_hidden,
186 extra_file_names,
187 ignore_dirs,
188 max_depth,
189 }
190 }
191
192 pub fn with_id(mut self, id: impl Into<String>) -> Self {
200 self.id = WidgetId::new(id.into());
201 self
202 }
203
204 pub fn with_ignore_dirs(mut self, ignore_dirs: Vec<String>) -> Self {
212 self.ignore_dirs = ignore_dirs;
213 self.all_entries = collect_dir_entries(
214 &self.root,
215 self.show_hidden,
216 &self.extra_file_names,
217 &self.ignore_dirs,
218 self.max_depth,
219 );
220 self.refilter();
221 self
222 }
223
224 pub fn with_max_depth(mut self, max_depth: usize) -> Self {
232 self.max_depth = max_depth;
233 self.all_entries = collect_dir_entries(
234 &self.root,
235 self.show_hidden,
236 &self.extra_file_names,
237 &self.ignore_dirs,
238 self.max_depth,
239 );
240 self.refilter();
241 self
242 }
243
244 pub fn root(&self) -> &Path {
248 &self.root
249 }
250
251 pub fn query(&self) -> &str {
253 &self.query
254 }
255
256 pub fn filtered(&self) -> &[PathBuf] {
258 &self.filtered
259 }
260
261 pub fn selected(&self) -> usize {
263 self.selected
264 }
265
266 pub fn scroll_top(&self) -> usize {
268 self.scroll_top
269 }
270
271 pub fn selected_path(&self) -> Option<PathBuf> {
277 let rel = self.filtered.get(self.selected)?;
278 if rel.as_os_str() == ".." {
279 self.root.parent().map(|p| p.to_path_buf())
280 } else {
281 Some(self.root.join(rel))
282 }
283 }
284
285 pub fn navigate_to(&mut self, new_root: PathBuf) {
289 self.root = new_root;
290 self.query.clear();
291 self.all_entries = collect_dir_entries(
292 &self.root,
293 self.show_hidden,
294 &self.extra_file_names,
295 &self.ignore_dirs,
296 self.max_depth,
297 );
298 self.filtered = self.all_entries.iter().take(50).cloned().collect();
299 self.selected = 0;
300 self.scroll_top = 0;
301 }
302
303 pub fn navigate_up(&mut self) {
305 if let Some(parent) = self.root.parent() {
306 self.navigate_to(parent.to_path_buf());
307 }
308 }
309
310 pub fn push_char(&mut self, c: char) {
314 self.query.push(c);
315 self.refilter();
316 }
317
318 pub fn pop_char(&mut self) {
320 self.query.pop();
321 self.refilter();
322 }
323
324 pub fn refilter(&mut self) {
326 self.filtered = filter_dir_entries(&self.all_entries, &self.query);
327 self.selected = 0;
328 self.scroll_top = 0;
329 }
330
331 pub fn move_up(&mut self) {
335 self.selected = self.selected.saturating_sub(1);
336 }
337
338 pub fn move_down(&mut self) {
340 if !self.filtered.is_empty() {
341 self.selected = (self.selected + 1).min(self.filtered.len() - 1);
342 }
343 }
344
345 pub fn sync_scroll(&mut self, visible_rows: usize) {
348 if visible_rows == 0 {
349 return;
350 }
351 if self.selected < self.scroll_top {
352 self.scroll_top = self.selected;
353 }
354 if self.selected >= self.scroll_top + visible_rows {
355 self.scroll_top = self.selected + 1 - visible_rows;
356 }
357 }
358
359 pub fn render(&self, rect: Rect, backend: &mut dyn Backend) {
365 let palette = self.build_palette(rect);
366 backend.draw_palette(rect, &palette);
367 }
368
369 pub fn handle(&mut self, event: &UiEvent, visible_rows: usize) -> FolderPickerEvent {
387 let result = match event {
388 UiEvent::KeyPressed { key, modifiers, .. } => self.handle_key(key, modifiers),
389 _ => FolderPickerEvent::Ignored,
390 };
391
392 if matches!(result, FolderPickerEvent::Consumed) {
394 self.sync_scroll(visible_rows);
395 }
396
397 result
398 }
399
400 fn handle_key(&mut self, key: &Key, modifiers: &Modifiers) -> FolderPickerEvent {
415 let ctrl = modifiers.ctrl;
416 match key {
417 Key::Named(NamedKey::Escape) => FolderPickerEvent::Cancelled,
418
419 Key::Named(NamedKey::Enter) => {
420 let is_dotdot = self
422 .filtered
423 .get(self.selected)
424 .map(|p| p.as_os_str() == "..")
425 .unwrap_or(false);
426 if is_dotdot {
427 self.navigate_up();
428 FolderPickerEvent::Consumed
429 } else if let Some(path) = self.selected_path() {
430 FolderPickerEvent::Confirmed { path }
431 } else {
432 FolderPickerEvent::Consumed
433 }
434 }
435
436 Key::Char('-') if !ctrl => {
438 self.navigate_up();
439 FolderPickerEvent::Consumed
440 }
441
442 Key::Named(NamedKey::Up) => {
443 self.move_up();
444 FolderPickerEvent::Consumed
445 }
446 Key::Char('k') if !ctrl => {
448 self.move_up();
449 FolderPickerEvent::Consumed
450 }
451
452 Key::Named(NamedKey::Down) => {
453 self.move_down();
454 FolderPickerEvent::Consumed
455 }
456 Key::Char('j') if !ctrl => {
458 self.move_down();
459 FolderPickerEvent::Consumed
460 }
461
462 Key::Named(NamedKey::Backspace) => {
463 self.pop_char();
464 FolderPickerEvent::Consumed
465 }
466
467 Key::Char(c) if !ctrl => {
468 self.push_char(*c);
469 FolderPickerEvent::Consumed
470 }
471
472 _ => FolderPickerEvent::Ignored,
473 }
474 }
475
476 fn build_palette(&self, rect: Rect) -> Palette {
478 let r = self.root.to_string_lossy();
479 let max = (rect.width as usize).saturating_sub(30).max(10);
482 let root_display = if r.len() > max {
487 let s: &str = r.as_ref();
488 let target_drop = s.len().saturating_sub(max);
489 let mut split_at = s.len();
491 for (idx, _) in s.char_indices() {
492 if idx >= target_drop {
493 split_at = idx;
494 break;
495 }
496 }
497 format!("…{}", &s[split_at..])
498 } else {
499 r.into_owned()
500 };
501 let title = format!("Open Folder {root_display}");
502
503 let folder_icon = Icon::new("\u{1F4C1}", "\u{1F4C1}"); let file_icon = Icon::new("\u{2699}", "\u{2699}"); let items: Vec<PaletteItem> = self
507 .filtered
508 .iter()
509 .map(|entry| {
510 let name = entry.file_name().and_then(|n| n.to_str()).unwrap_or("");
515 let is_extra_file = self.extra_file_names.iter().any(|e| e == name);
516 let icon = if is_extra_file {
517 Some(file_icon.clone())
518 } else {
519 Some(folder_icon.clone())
520 };
521 PaletteItem {
522 text: StyledText::plain(entry.to_string_lossy().to_string()),
523 detail: None,
524 icon,
525 match_positions: Vec::new(),
526 depth: 0,
527 expandable: false,
528 expanded: false,
529 }
530 })
531 .collect();
532
533 Palette {
534 id: self.id,
535 title,
536 query: self.query.clone(),
537 query_cursor: self.query.len(),
538 items,
539 selected_idx: self.selected,
540 scroll_offset: self.scroll_top,
541 total_count: self.all_entries.len(),
542 has_focus: true,
543 show_query: true,
544 create_label: None,
545 preview: None,
546 mode: crate::primitives::palette::PaletteMode::List,
547 }
548 }
549}
550
551fn collect_dir_entries(
563 root: &Path,
564 show_hidden: bool,
565 extra_file_names: &[String],
566 ignore_dirs: &[String],
567 max_depth: usize,
568) -> Vec<PathBuf> {
569 let mut out = Vec::new();
570 if root.parent().is_some() {
572 out.push(PathBuf::from(".."));
573 }
574 out.push(PathBuf::from("."));
575 walk_dir_entries_recursive(
576 root,
577 root,
578 &mut out,
579 0,
580 show_hidden,
581 extra_file_names,
582 ignore_dirs,
583 max_depth,
584 );
585 out
586}
587
588#[allow(clippy::too_many_arguments)]
589fn walk_dir_entries_recursive(
590 root: &Path,
591 dir: &Path,
592 out: &mut Vec<PathBuf>,
593 depth: usize,
594 show_hidden: bool,
595 extra_file_names: &[String],
596 ignore_dirs: &[String],
597 max_depth: usize,
598) {
599 if depth > max_depth {
600 return;
601 }
602 let mut entries: Vec<_> = match std::fs::read_dir(dir) {
603 Ok(e) => e.flatten().collect(),
604 Err(_) => return,
605 };
606 entries.sort_by_key(|e| e.file_name());
607 for entry in entries {
608 let path = entry.path();
609 let name = match path.file_name().and_then(|n| n.to_str()) {
610 Some(n) => n.to_owned(),
611 None => continue,
612 };
613 if name.starts_with('.') && !show_hidden {
615 if path.is_file() && extra_file_names.iter().any(|e| e == &name) {
616 if let Ok(rel) = path.strip_prefix(root) {
617 out.push(rel.to_path_buf());
618 }
619 }
620 continue;
621 }
622 if ignore_dirs.iter().any(|d| d == &name) {
624 continue;
625 }
626 if path.is_dir() {
627 if let Ok(rel) = path.strip_prefix(root) {
628 out.push(rel.to_path_buf());
629 }
630 walk_dir_entries_recursive(
631 root,
632 &path,
633 out,
634 depth + 1,
635 show_hidden,
636 extra_file_names,
637 ignore_dirs,
638 max_depth,
639 );
640 }
641 }
642}
643
644fn filter_dir_entries(all: &[PathBuf], query: &str) -> Vec<PathBuf> {
649 const CAP: usize = 50;
650 if query.is_empty() {
651 return all.iter().take(CAP).cloned().collect();
652 }
653 let q = query.to_lowercase();
654 let mut scored: Vec<(i32, &PathBuf)> = all
655 .iter()
656 .filter_map(|p| {
657 let display = p.to_string_lossy().to_lowercase();
658 dir_fuzzy_score(&display, &q).map(|s| (s, p))
659 })
660 .collect();
661 scored.sort_by_key(|&(s, _)| Reverse(s));
662 scored
663 .into_iter()
664 .take(CAP)
665 .map(|(_, p)| p.clone())
666 .collect()
667}
668
669fn dir_fuzzy_score(path: &str, query: &str) -> Option<i32> {
682 crate::text_util::fuzzy_score(path, query).map(|(score, _positions)| score)
683}
684
685#[cfg(test)]
688mod tests {
689 use super::*;
690
691 const PICKER_ROOT_CHILDREN: usize = 12;
695
696 fn picker_root() -> tempfile::TempDir {
707 let dir = tempfile::tempdir().expect("create picker tempdir");
708 for i in 0..PICKER_ROOT_CHILDREN {
709 std::fs::create_dir(dir.path().join(format!("child{i:02}"))).expect("create child dir");
710 }
711 dir
712 }
713
714 #[test]
717 fn fuzzy_score_exact_match_scores_high() {
718 let s = dir_fuzzy_score("src/main.rs", "src/main.rs");
719 assert!(s.is_some());
720 assert!(s.unwrap() > 0);
721 }
722
723 #[test]
724 fn fuzzy_score_subsequence_matches() {
725 let s = dir_fuzzy_score("src/main", "sm");
727 assert!(s.is_some());
728 }
729
730 #[test]
731 fn fuzzy_score_non_subsequence_is_none() {
732 let s = dir_fuzzy_score("src/main", "xyz");
733 assert!(s.is_none());
734 }
735
736 #[test]
737 fn fuzzy_score_boundary_bonus() {
738 let at_boundary = dir_fuzzy_score("src/main", "m");
740 let mid_word = dir_fuzzy_score("abcmain", "m");
741 assert!(at_boundary.unwrap() > mid_word.unwrap());
742 }
743
744 #[test]
745 fn fuzzy_score_empty_query_none() {
746 let s = dir_fuzzy_score("anything", "");
749 assert!(s.is_some());
753 }
754
755 #[test]
758 fn filter_returns_all_on_empty_query() {
759 let paths: Vec<PathBuf> = (0..10).map(|i| PathBuf::from(format!("dir{i}"))).collect();
760 let result = filter_dir_entries(&paths, "");
761 assert_eq!(result.len(), 10);
762 }
763
764 #[test]
765 fn filter_caps_at_50() {
766 let paths: Vec<PathBuf> = (0..100).map(|i| PathBuf::from(format!("dir{i}"))).collect();
767 let result = filter_dir_entries(&paths, "");
768 assert_eq!(result.len(), 50);
769 }
770
771 #[test]
772 fn filter_excludes_non_matching() {
773 let paths = vec![
774 PathBuf::from("src/main"),
775 PathBuf::from("docs/readme"),
776 PathBuf::from("tests/unit"),
777 ];
778 let result = filter_dir_entries(&paths, "src");
779 assert_eq!(result.len(), 1);
780 assert_eq!(result[0], PathBuf::from("src/main"));
781 }
782
783 #[test]
784 fn filter_sorts_by_score() {
785 let paths = vec![PathBuf::from("usr/local"), PathBuf::from("src/main")];
787 let result = filter_dir_entries(&paths, "sr");
788 assert_eq!(result[0], PathBuf::from("src/main"));
790 }
791
792 #[test]
795 fn new_sets_dotdot_and_dot_entries() {
796 let dir = picker_root();
797 let tmp = dir.path().to_path_buf();
798 let picker = FolderPickerController::new(tmp, vec![], false);
799 assert!(picker.all_entries.contains(&PathBuf::from("..")));
801 assert!(picker.all_entries.contains(&PathBuf::from(".")));
802 assert_eq!(
805 picker.filtered().len(),
806 PICKER_ROOT_CHILDREN + 2,
807 "isolated root must yield exactly \"..\", \".\" and one entry per child"
808 );
809 }
810
811 #[test]
812 fn push_and_pop_char_update_query() {
813 let dir = picker_root();
814 let tmp = dir.path().to_path_buf();
815 let mut picker = FolderPickerController::new(tmp, vec![], false);
816 picker.push_char('s');
817 picker.push_char('r');
818 assert_eq!(picker.query(), "sr");
819 picker.pop_char();
820 assert_eq!(picker.query(), "s");
821 }
822
823 #[test]
824 fn move_up_clamps_at_zero() {
825 let dir = picker_root();
826 let tmp = dir.path().to_path_buf();
827 let mut picker = FolderPickerController::new(tmp, vec![], false);
828 assert_eq!(picker.selected(), 0);
829 picker.move_up();
830 assert_eq!(picker.selected(), 0);
831 }
832
833 #[test]
834 fn move_down_advances_selection() {
835 let dir = picker_root();
836 let tmp = dir.path().to_path_buf();
837 let mut picker = FolderPickerController::new(tmp, vec![], false);
838 assert!(picker.filtered().len() >= 2);
839 picker.move_down();
840 assert_eq!(picker.selected(), 1);
841 }
842
843 #[test]
844 fn move_down_clamps_at_last() {
845 let dir = picker_root();
846 let tmp = dir.path().to_path_buf();
847 let mut picker = FolderPickerController::new(tmp, vec![], false);
848 let last = picker.filtered().len().saturating_sub(1);
849 for _ in 0..last + 5 {
850 picker.move_down();
851 }
852 assert_eq!(picker.selected(), last);
853 }
854
855 #[test]
856 fn sync_scroll_brings_selected_into_view() {
857 let dir = picker_root();
858 let tmp = dir.path().to_path_buf();
859 let mut picker = FolderPickerController::new(tmp, vec![], false);
860 assert!(picker.filtered().len() >= 10);
862 picker.selected = 9;
863 picker.sync_scroll(5);
864 assert!(picker.scroll_top + 5 > picker.selected);
865 assert!(picker.scroll_top <= picker.selected);
866 }
867
868 #[test]
869 fn sync_scroll_scrolls_up_when_needed() {
870 let dir = picker_root();
871 let tmp = dir.path().to_path_buf();
872 let mut picker = FolderPickerController::new(tmp, vec![], false);
873 assert!(picker.filtered().len() >= 5);
874 picker.scroll_top = 5;
875 picker.selected = 2; picker.sync_scroll(3);
877 assert_eq!(picker.scroll_top, 2);
878 }
879
880 #[test]
881 fn escape_event_returns_cancelled() {
882 let dir = picker_root();
883 let tmp = dir.path().to_path_buf();
884 let mut picker = FolderPickerController::new(tmp, vec![], false);
885 let ev = UiEvent::KeyPressed {
886 key: Key::Named(NamedKey::Escape),
887 modifiers: Modifiers::default(),
888 repeat: false,
889 };
890 assert_eq!(picker.handle(&ev, 10), FolderPickerEvent::Cancelled);
891 }
892
893 #[test]
894 fn enter_on_dotdot_navigates_up() {
895 let parent = tempfile::tempdir().expect("create parent tempdir");
898 let sub = parent.path().join("nav_up_child");
899 std::fs::create_dir_all(&sub).expect("create child dir");
900 let mut picker = FolderPickerController::new(sub.clone(), vec![], false);
901 picker.selected = 0;
903 assert_eq!(picker.filtered().first(), Some(&PathBuf::from("..")));
904 let ev = UiEvent::KeyPressed {
905 key: Key::Named(NamedKey::Enter),
906 modifiers: Modifiers::default(),
907 repeat: false,
908 };
909 let result = picker.handle(&ev, 10);
910 assert_eq!(result, FolderPickerEvent::Consumed);
911 assert_eq!(picker.root(), parent.path());
912 }
913
914 #[test]
915 fn enter_on_dot_returns_confirmed_with_root() {
916 let dir = picker_root();
917 let tmp = dir.path().to_path_buf();
918 let mut picker = FolderPickerController::new(tmp.clone(), vec![], false);
919 let dot_idx = picker
921 .filtered()
922 .iter()
923 .position(|p| p.as_os_str() == ".")
924 .expect("'.' should always be in filtered");
925 picker.selected = dot_idx;
926 let ev = UiEvent::KeyPressed {
927 key: Key::Named(NamedKey::Enter),
928 modifiers: Modifiers::default(),
929 repeat: false,
930 };
931 let result = picker.handle(&ev, 10);
932 assert_eq!(result, FolderPickerEvent::Confirmed { path: tmp });
933 }
934
935 #[test]
936 fn up_down_key_moves_selection() {
937 let dir = picker_root();
938 let tmp = dir.path().to_path_buf();
939 let mut picker = FolderPickerController::new(tmp, vec![], false);
940 assert!(picker.filtered().len() >= 2);
941 let ev_down = UiEvent::KeyPressed {
942 key: Key::Named(NamedKey::Down),
943 modifiers: Modifiers::default(),
944 repeat: false,
945 };
946 picker.handle(&ev_down, 10);
947 assert_eq!(picker.selected(), 1);
948
949 let ev_up = UiEvent::KeyPressed {
950 key: Key::Named(NamedKey::Up),
951 modifiers: Modifiers::default(),
952 repeat: false,
953 };
954 picker.handle(&ev_up, 10);
955 assert_eq!(picker.selected(), 0);
956 }
957
958 #[test]
959 fn key_char_appends_to_query() {
960 let dir = picker_root();
964 let tmp = dir.path().to_path_buf();
965 let mut picker = FolderPickerController::new(tmp, vec![], false);
966 let ev = UiEvent::KeyPressed {
967 key: Key::Char('s'),
968 modifiers: Modifiers::default(),
969 repeat: false,
970 };
971 picker.handle(&ev, 10);
972 assert_eq!(picker.query(), "s");
973 }
974
975 #[test]
976 fn char_typed_is_ignored() {
977 let dir = picker_root();
980 let tmp = dir.path().to_path_buf();
981 let mut picker = FolderPickerController::new(tmp, vec![], false);
982 let ev = UiEvent::CharTyped('s');
983 let result = picker.handle(&ev, 10);
984 assert_eq!(result, FolderPickerEvent::Ignored);
985 assert_eq!(picker.query(), "");
986 }
987
988 #[test]
989 fn backspace_removes_last_char() {
990 let dir = picker_root();
991 let tmp = dir.path().to_path_buf();
992 let mut picker = FolderPickerController::new(tmp, vec![], false);
993 picker.push_char('s');
994 picker.push_char('r');
995 let ev = UiEvent::KeyPressed {
996 key: Key::Named(NamedKey::Backspace),
997 modifiers: Modifiers::default(),
998 repeat: false,
999 };
1000 picker.handle(&ev, 10);
1001 assert_eq!(picker.query(), "s");
1002 }
1003
1004 #[test]
1005 fn selected_path_resolves_dot_to_root() {
1006 let dir = picker_root();
1007 let tmp = dir.path().to_path_buf();
1008 let picker = FolderPickerController::new(tmp.clone(), vec![], false);
1009 let dot_idx = picker
1010 .filtered()
1011 .iter()
1012 .position(|p| p.as_os_str() == ".")
1013 .unwrap();
1014 let mut picker = picker;
1015 picker.selected = dot_idx;
1016 assert_eq!(picker.selected_path(), Some(tmp));
1017 }
1018
1019 #[test]
1020 fn dash_key_navigates_up() {
1021 let parent = tempfile::tempdir().expect("create parent tempdir");
1022 let sub = parent.path().join("dash_nav_child");
1023 std::fs::create_dir_all(&sub).expect("create child dir");
1024 let mut picker = FolderPickerController::new(sub.clone(), vec![], false);
1025 let ev = UiEvent::KeyPressed {
1026 key: Key::Char('-'),
1027 modifiers: Modifiers::default(),
1028 repeat: false,
1029 };
1030 let result = picker.handle(&ev, 10);
1031 assert_eq!(result, FolderPickerEvent::Consumed);
1032 assert_eq!(picker.root(), parent.path());
1033 }
1034
1035 #[test]
1036 fn build_palette_id_is_folder_picker() {
1037 let dir = picker_root();
1038 let tmp = dir.path().to_path_buf();
1039 let picker = FolderPickerController::new(tmp, vec![], false);
1040 let rect = Rect::new(0.0, 0.0, 80.0, 24.0);
1041 let palette = picker.build_palette(rect);
1042 assert_eq!(palette.id.as_str(), "folder_picker");
1043 }
1044
1045 #[test]
1046 fn build_palette_title_contains_open_folder() {
1047 let dir = picker_root();
1048 let tmp = dir.path().to_path_buf();
1049 let picker = FolderPickerController::new(tmp, vec![], false);
1050 let rect = Rect::new(0.0, 0.0, 80.0, 24.0);
1051 let palette = picker.build_palette(rect);
1052 assert!(palette.title.starts_with("Open Folder"));
1053 }
1054
1055 #[test]
1056 fn build_palette_items_match_filtered() {
1057 let dir = picker_root();
1058 let tmp = dir.path().to_path_buf();
1059 let picker = FolderPickerController::new(tmp, vec![], false);
1060 let rect = Rect::new(0.0, 0.0, 80.0, 24.0);
1061 let palette = picker.build_palette(rect);
1062 assert_eq!(palette.items.len(), picker.filtered().len());
1063 }
1064
1065 #[test]
1066 fn extra_file_names_gets_file_icon() {
1067 let root = tempfile::tempdir().expect("create marker tempdir");
1070 let marker = root.path().join(".my-workspace");
1071 std::fs::write(&marker, b"").expect("write marker file");
1072 let picker = FolderPickerController::new(
1073 root.path().to_path_buf(),
1074 vec![".my-workspace".to_string()],
1075 false,
1076 );
1077 let rect = Rect::new(0.0, 0.0, 80.0, 24.0);
1078 let palette = picker.build_palette(rect);
1079 let has_marker = palette.items.iter().any(|item| {
1081 item.text
1082 .spans
1083 .first()
1084 .map(|s| s.text.contains(".my-workspace"))
1085 .unwrap_or(false)
1086 });
1087 assert!(has_marker, "marker file should appear in palette items");
1088 }
1089
1090 #[test]
1091 fn build_palette_handles_non_ascii_root_path() {
1092 let long_japanese = PathBuf::from(format!("/a/{}", "ト".repeat(50)));
1096 let picker = FolderPickerController::new(long_japanese, vec![], false);
1097 let rect = Rect::new(0.0, 0.0, 80.0, 24.0);
1099 let palette = picker.build_palette(rect);
1101 assert!(palette.title.starts_with("Open Folder "));
1102 assert!(palette.title.contains('…'));
1105 }
1106
1107 #[test]
1108 fn build_palette_handles_accented_root_path() {
1109 let path = PathBuf::from(
1112 "/home/utilisateur/répertoire/sous-répertoire/encore-plus-profond/données",
1113 );
1114 let picker = FolderPickerController::new(path, vec![], false);
1115 let rect = Rect::new(0.0, 0.0, 60.0, 24.0);
1116 let palette = picker.build_palette(rect);
1117 assert!(palette.title.starts_with("Open Folder "));
1118 }
1119
1120 #[test]
1123 fn fuzzy_score_treats_backslash_as_boundary() {
1124 let posix = dir_fuzzy_score("src/main", "m");
1128 let windows = dir_fuzzy_score("src\\main", "m");
1129 let mid = dir_fuzzy_score("abcmain", "m");
1131 assert!(posix.unwrap() > mid.unwrap());
1132 assert!(windows.unwrap() > mid.unwrap());
1133 assert_eq!(posix.unwrap(), windows.unwrap());
1135 }
1136
1137 #[test]
1140 fn with_id_overrides_widget_id() {
1141 let dir = picker_root();
1142 let tmp = dir.path().to_path_buf();
1143 let picker = FolderPickerController::new(tmp, vec![], false).with_id("picker_b");
1144 let rect = Rect::new(0.0, 0.0, 80.0, 24.0);
1145 assert_eq!(picker.build_palette(rect).id.as_str(), "picker_b");
1146 }
1147
1148 #[test]
1149 fn with_ignore_dirs_replaces_default_list() {
1150 let root = tempfile::tempdir().expect("tempdir");
1151 std::fs::create_dir_all(root.path().join("target")).unwrap();
1153 std::fs::create_dir_all(root.path().join("keep_me")).unwrap();
1154 let picker = FolderPickerController::new(root.path().to_path_buf(), vec![], false)
1156 .with_ignore_dirs(vec![]);
1157 let has_target = picker
1158 .all_entries
1159 .iter()
1160 .any(|p| p == &PathBuf::from("target"));
1161 let has_keep = picker
1162 .all_entries
1163 .iter()
1164 .any(|p| p == &PathBuf::from("keep_me"));
1165 assert!(
1166 has_target,
1167 "target should be present after with_ignore_dirs(empty)"
1168 );
1169 assert!(has_keep);
1170 }
1171
1172 #[test]
1173 fn with_max_depth_limits_recursion() {
1174 let root = tempfile::tempdir().expect("tempdir");
1175 let d3 = root.path().join("d1").join("d2").join("d3");
1177 std::fs::create_dir_all(&d3).unwrap();
1178 let picker =
1180 FolderPickerController::new(root.path().to_path_buf(), vec![], false).with_max_depth(0);
1181 let has_d1 = picker.all_entries.iter().any(|p| p.ends_with("d1"));
1182 let has_d2 = picker.all_entries.iter().any(|p| p.ends_with("d2"));
1183 assert!(has_d1);
1184 assert!(!has_d2, "depth=0 should not surface nested subdirs");
1185 }
1186}