1use crate::input::fuzzy::fuzzy_match;
8use crate::model::filesystem::{DirEntry, EntryType, FileSystem};
9use rust_i18n::t;
10use std::cmp::Ordering;
11use std::path::{Path, PathBuf};
12use std::sync::Arc;
13use std::time::SystemTime;
14
15#[derive(Debug, Clone)]
17pub struct FileOpenEntry {
18 pub fs_entry: DirEntry,
20 pub matches_filter: bool,
22 pub match_score: i32,
24}
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
28pub enum SortMode {
29 #[default]
30 Name,
31 Size,
32 Modified,
33 Type,
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
38pub enum FileOpenSection {
39 Navigation,
41 #[default]
43 Files,
44}
45
46#[derive(Debug, Clone)]
48pub struct NavigationShortcut {
49 pub label: String,
51 pub path: PathBuf,
53 pub description: String,
55}
56
57#[derive(Clone)]
59pub struct FileOpenState {
60 pub current_dir: PathBuf,
62
63 raw_entries: Vec<DirEntry>,
69
70 pub entries: Vec<FileOpenEntry>,
73
74 pub loading: bool,
76
77 pub error: Option<String>,
79
80 pub sort_mode: SortMode,
82
83 pub sort_ascending: bool,
85
86 pub selected_index: Option<usize>,
88
89 pub scroll_offset: usize,
91
92 pub last_visible_rows: usize,
97
98 pub active_section: FileOpenSection,
100
101 pub filter: String,
103
104 pub shortcuts: Vec<NavigationShortcut>,
106
107 pub selected_shortcut: usize,
109
110 pub show_hidden: bool,
112
113 pub detect_encoding: bool,
116
117 filesystem: Arc<dyn FileSystem + Send + Sync>,
119}
120
121impl FileOpenState {
122 pub fn new(
126 dir: PathBuf,
127 show_hidden: bool,
128 filesystem: Arc<dyn FileSystem + Send + Sync>,
129 ) -> Self {
130 let shortcuts = Self::build_shortcuts_sync(&dir, &*filesystem);
132 Self {
133 current_dir: dir,
134 raw_entries: Vec::new(),
135 entries: Vec::new(),
136 loading: true,
137 error: None,
138 sort_mode: SortMode::Name,
139 sort_ascending: true,
140 selected_index: None,
141 scroll_offset: 0,
142 last_visible_rows: 0,
143 active_section: FileOpenSection::Files,
144 filter: String::new(),
145 shortcuts,
146 selected_shortcut: 0,
147 show_hidden,
148 detect_encoding: true,
149 filesystem,
150 }
151 }
152
153 fn build_shortcuts_sync(
156 current_dir: &Path,
157 filesystem: &dyn FileSystem,
158 ) -> Vec<NavigationShortcut> {
159 let mut shortcuts = Vec::new();
160
161 if let Some(parent) = current_dir.parent() {
163 shortcuts.push(NavigationShortcut {
164 label: "..".to_string(),
165 path: parent.to_path_buf(),
166 description: t!("file_browser.parent_dir").to_string(),
167 });
168 }
169
170 #[cfg(unix)]
172 {
173 shortcuts.push(NavigationShortcut {
174 label: "/".to_string(),
175 path: PathBuf::from("/"),
176 description: t!("file_browser.root_dir").to_string(),
177 });
178 }
179
180 if let Ok(home) = filesystem.home_dir() {
183 shortcuts.push(NavigationShortcut {
184 label: "~".to_string(),
185 path: home,
186 description: t!("file_browser.home_dir").to_string(),
187 });
188 }
189
190 shortcuts
191 }
192
193 pub fn build_shortcuts_async(filesystem: &dyn FileSystem) -> Vec<NavigationShortcut> {
198 let mut shortcuts = Vec::new();
199
200 if let Some(docs) = dirs::document_dir() {
202 if filesystem.exists(&docs) {
203 shortcuts.push(NavigationShortcut {
204 label: t!("file_browser.documents").to_string(),
205 path: docs,
206 description: t!("file_browser.documents_folder").to_string(),
207 });
208 }
209 }
210
211 if let Some(downloads) = dirs::download_dir() {
213 if filesystem.exists(&downloads) {
214 shortcuts.push(NavigationShortcut {
215 label: t!("file_browser.downloads").to_string(),
216 path: downloads,
217 description: t!("file_browser.downloads_folder").to_string(),
218 });
219 }
220 }
221
222 #[cfg(windows)]
227 {
228 for letter in b'A'..=b'Z' {
229 let path = PathBuf::from(format!("{}:\\", letter as char));
230 if filesystem.exists(&path) {
231 shortcuts.push(NavigationShortcut {
232 label: format!("{}:", letter as char),
233 path,
234 description: t!("file_browser.drive").to_string(),
235 });
236 }
237 }
238 }
239
240 shortcuts
241 }
242
243 pub fn merge_async_shortcuts(&mut self, async_shortcuts: Vec<NavigationShortcut>) {
246 self.shortcuts.extend(async_shortcuts);
248 }
249
250 pub fn update_shortcuts(&mut self) {
253 self.shortcuts = Self::build_shortcuts_sync(&self.current_dir, &*self.filesystem);
254 self.selected_shortcut = 0;
255 }
256
257 pub fn set_entries(&mut self, entries: Vec<DirEntry>) {
263 self.raw_entries = entries;
264 self.loading = false;
265 self.error = None;
266 self.rebuild_entries();
267 self.sort_entries();
268 self.selected_index = None;
270 self.scroll_offset = 0;
271 }
272
273 fn rebuild_entries(&mut self) {
278 let mut result: Vec<FileOpenEntry> = Vec::new();
279
280 if let Some(parent) = self.current_dir.parent() {
282 let parent_entry =
283 DirEntry::new(parent.to_path_buf(), "..".to_string(), EntryType::Directory);
284 result.push(FileOpenEntry {
285 fs_entry: parent_entry,
286 matches_filter: true,
287 match_score: 0,
288 });
289 }
290
291 let show_hidden = self.show_hidden;
292 let filter = self.filter.as_str();
293 result.extend(
294 self.raw_entries
295 .iter()
296 .filter(|e| Self::is_revealed(&e.name, show_hidden, filter))
297 .cloned()
298 .map(|fs_entry| FileOpenEntry {
299 fs_entry,
300 matches_filter: true,
301 match_score: 0,
302 }),
303 );
304
305 self.entries = result;
306 self.apply_filter_internal();
307 }
308
309 fn is_revealed(name: &str, show_hidden: bool, filter: &str) -> bool {
321 if show_hidden || !Self::is_hidden(name) {
322 return true;
323 }
324 !filter.is_empty() && name.to_lowercase().starts_with(&filter.to_lowercase())
325 }
326
327 pub fn set_error(&mut self, error: String) {
329 self.loading = false;
330 self.error = Some(error);
331 self.entries.clear();
332 }
333
334 fn is_hidden(name: &str) -> bool {
336 name.starts_with('.')
337 }
338
339 pub fn apply_filter(&mut self, filter: &str) {
343 self.filter = filter.to_string();
344 self.rebuild_entries();
348
349 if !filter.is_empty() {
351 self.entries.sort_by(|a, b| {
352 let a_is_parent = a.fs_entry.name == "..";
354 let b_is_parent = b.fs_entry.name == "..";
355
356 if a_is_parent && !b_is_parent {
357 return Ordering::Less;
358 }
359 if !a_is_parent && b_is_parent {
360 return Ordering::Greater;
361 }
362
363 match (a.matches_filter, b.matches_filter) {
365 (true, false) => Ordering::Less,
366 (false, true) => Ordering::Greater,
367 (true, true) => {
368 b.match_score.cmp(&a.match_score)
370 }
371 (false, false) => {
372 a.fs_entry
374 .name
375 .to_lowercase()
376 .cmp(&b.fs_entry.name.to_lowercase())
377 }
378 }
379 });
380
381 let first_match = self
383 .entries
384 .iter()
385 .position(|e| e.matches_filter && e.fs_entry.name != "..");
386 if let Some(idx) = first_match {
387 self.selected_index = Some(idx);
388 self.ensure_selected_visible();
389 } else {
390 self.selected_index = None;
391 }
392 } else {
393 self.sort_entries();
395 self.selected_index = None;
396 }
397 }
398
399 fn apply_filter_internal(&mut self) {
400 for entry in &mut self.entries {
401 if self.filter.is_empty() {
402 entry.matches_filter = true;
403 entry.match_score = 0;
404 } else {
405 let result = fuzzy_match(&self.filter, &entry.fs_entry.name);
406 entry.matches_filter = result.matched;
407 entry.match_score = result.score;
408 }
409 }
410 }
411
412 pub fn sort_entries(&mut self) {
414 let sort_mode = self.sort_mode;
415 let ascending = self.sort_ascending;
416
417 self.entries.sort_by(|a, b| {
418 let a_is_parent = a.fs_entry.name == "..";
420 let b_is_parent = b.fs_entry.name == "..";
421 match (a_is_parent, b_is_parent) {
422 (true, false) => return Ordering::Less,
423 (false, true) => return Ordering::Greater,
424 (true, true) => return Ordering::Equal,
425 _ => {}
426 }
427
428 match (a.fs_entry.is_dir(), b.fs_entry.is_dir()) {
433 (true, false) => return Ordering::Less,
434 (false, true) => return Ordering::Greater,
435 _ => {}
436 }
437
438 let ord = match sort_mode {
440 SortMode::Name => a
441 .fs_entry
442 .name
443 .to_lowercase()
444 .cmp(&b.fs_entry.name.to_lowercase()),
445 SortMode::Size => {
446 let a_size = a.fs_entry.metadata.as_ref().map(|m| m.size).unwrap_or(0);
447 let b_size = b.fs_entry.metadata.as_ref().map(|m| m.size).unwrap_or(0);
448 a_size.cmp(&b_size)
449 }
450 SortMode::Modified => {
451 let a_mod = a.fs_entry.metadata.as_ref().and_then(|m| m.modified);
452 let b_mod = b.fs_entry.metadata.as_ref().and_then(|m| m.modified);
453 match (a_mod, b_mod) {
454 (Some(a), Some(b)) => a.cmp(&b),
455 (Some(_), None) => Ordering::Less,
456 (None, Some(_)) => Ordering::Greater,
457 (None, None) => Ordering::Equal,
458 }
459 }
460 SortMode::Type => {
461 let a_ext = std::path::Path::new(&a.fs_entry.name)
462 .extension()
463 .and_then(|e| e.to_str())
464 .unwrap_or("");
465 let b_ext = std::path::Path::new(&b.fs_entry.name)
466 .extension()
467 .and_then(|e| e.to_str())
468 .unwrap_or("");
469 a_ext.to_lowercase().cmp(&b_ext.to_lowercase())
470 }
471 };
472
473 if ascending {
474 ord
475 } else {
476 ord.reverse()
477 }
478 });
479 }
480
481 pub fn set_sort_mode(&mut self, mode: SortMode) {
483 if self.sort_mode == mode {
484 self.sort_ascending = !self.sort_ascending;
486 } else {
487 self.sort_mode = mode;
488 self.sort_ascending = true;
489 }
490 self.sort_entries();
491 }
492
493 pub fn toggle_hidden(&mut self) {
495 self.show_hidden = !self.show_hidden;
496 }
498
499 pub fn toggle_detect_encoding(&mut self) {
501 self.detect_encoding = !self.detect_encoding;
502 }
503
504 pub fn select_prev(&mut self) {
506 match self.active_section {
507 FileOpenSection::Navigation => {
508 if self.selected_shortcut > 0 {
509 self.selected_shortcut -= 1;
510 }
511 }
512 FileOpenSection::Files => {
513 if let Some(idx) = self.selected_index {
514 if idx > 0 {
515 self.selected_index = Some(idx - 1);
516 self.ensure_selected_visible();
517 }
518 } else if !self.entries.is_empty() {
519 self.selected_index = Some(self.entries.len() - 1);
521 self.ensure_selected_visible();
522 }
523 }
524 }
525 }
526
527 pub fn select_next(&mut self) {
529 match self.active_section {
530 FileOpenSection::Navigation => {
531 if self.selected_shortcut + 1 < self.shortcuts.len() {
532 self.selected_shortcut += 1;
533 }
534 }
535 FileOpenSection::Files => {
536 if let Some(idx) = self.selected_index {
537 if idx + 1 < self.entries.len() {
538 self.selected_index = Some(idx + 1);
539 self.ensure_selected_visible();
540 }
541 } else if !self.entries.is_empty() {
542 self.selected_index = Some(0);
544 self.ensure_selected_visible();
545 }
546 }
547 }
548 }
549
550 pub fn page_up(&mut self, page_size: usize) {
552 if self.active_section == FileOpenSection::Files {
553 if let Some(idx) = self.selected_index {
554 self.selected_index = Some(idx.saturating_sub(page_size));
555 self.ensure_selected_visible();
556 } else if !self.entries.is_empty() {
557 self.selected_index = Some(0);
558 }
559 }
560 }
561
562 pub fn page_down(&mut self, page_size: usize) {
564 if self.active_section == FileOpenSection::Files {
565 if let Some(idx) = self.selected_index {
566 self.selected_index =
567 Some((idx + page_size).min(self.entries.len().saturating_sub(1)));
568 self.ensure_selected_visible();
569 } else if !self.entries.is_empty() {
570 self.selected_index = Some(self.entries.len().saturating_sub(1));
571 }
572 }
573 }
574
575 pub fn select_first(&mut self) {
577 match self.active_section {
578 FileOpenSection::Navigation => self.selected_shortcut = 0,
579 FileOpenSection::Files => {
580 if !self.entries.is_empty() {
581 self.selected_index = Some(0);
582 self.scroll_offset = 0;
583 }
584 }
585 }
586 }
587
588 pub fn select_last(&mut self) {
590 match self.active_section {
591 FileOpenSection::Navigation => {
592 self.selected_shortcut = self.shortcuts.len().saturating_sub(1);
593 }
594 FileOpenSection::Files => {
595 if !self.entries.is_empty() {
596 self.selected_index = Some(self.entries.len() - 1);
597 self.ensure_selected_visible();
598 }
599 }
600 }
601 }
602
603 fn ensure_selected_visible(&mut self) {
607 self.clamp_scroll_to_selection();
608 }
609
610 pub fn update_scroll_for_visible_rows(&mut self, visible_rows: usize) {
614 self.last_visible_rows = visible_rows;
615 self.clamp_scroll_to_selection();
616 }
617
618 fn clamp_scroll_to_selection(&mut self) {
622 let visible_rows = self.last_visible_rows;
623 if visible_rows == 0 {
624 return;
625 }
626 if let Some(idx) = self.selected_index {
627 if idx < self.scroll_offset {
628 self.scroll_offset = idx;
629 } else if idx >= self.scroll_offset + visible_rows {
630 self.scroll_offset = idx + 1 - visible_rows;
631 }
632 }
633 let max_offset = self.entries.len().saturating_sub(visible_rows);
634 if self.scroll_offset > max_offset {
635 self.scroll_offset = max_offset;
636 }
637 }
638
639 pub fn switch_section(&mut self) {
641 self.active_section = match self.active_section {
642 FileOpenSection::Navigation => FileOpenSection::Files,
643 FileOpenSection::Files => FileOpenSection::Navigation,
644 };
645 }
646
647 pub fn selected_entry(&self) -> Option<&FileOpenEntry> {
649 if self.active_section == FileOpenSection::Files {
650 self.selected_index.and_then(|idx| self.entries.get(idx))
651 } else {
652 None
653 }
654 }
655
656 pub fn selected_shortcut_entry(&self) -> Option<&NavigationShortcut> {
658 if self.active_section == FileOpenSection::Navigation {
659 self.shortcuts.get(self.selected_shortcut)
660 } else {
661 None
662 }
663 }
664
665 pub fn get_selected_path(&self) -> Option<PathBuf> {
667 match self.active_section {
668 FileOpenSection::Navigation => self
669 .shortcuts
670 .get(self.selected_shortcut)
671 .map(|s| s.path.clone()),
672 FileOpenSection::Files => self
673 .selected_index
674 .and_then(|idx| self.entries.get(idx))
675 .map(|e| e.fs_entry.path.clone()),
676 }
677 }
678
679 pub fn selected_is_dir(&self) -> bool {
681 match self.active_section {
682 FileOpenSection::Navigation => true, FileOpenSection::Files => self
684 .selected_index
685 .and_then(|idx| self.entries.get(idx))
686 .map(|e| e.fs_entry.is_dir())
687 .unwrap_or(false),
688 }
689 }
690
691 pub fn matching_count(&self) -> usize {
693 self.entries.iter().filter(|e| e.matches_filter).count()
694 }
695
696 pub fn visible_entries(&self, max_rows: usize) -> &[FileOpenEntry] {
698 let start = self.scroll_offset;
699 let end = (start + max_rows).min(self.entries.len());
700 &self.entries[start..end]
701 }
702}
703
704pub fn format_size(size: u64) -> String {
706 const KB: u64 = 1024;
707 const MB: u64 = KB * 1024;
708 const GB: u64 = MB * 1024;
709
710 if size >= GB {
711 format!("{:.1} GB", size as f64 / GB as f64)
712 } else if size >= MB {
713 format!("{:.1} MB", size as f64 / MB as f64)
714 } else if size >= KB {
715 format!("{:.1} KB", size as f64 / KB as f64)
716 } else {
717 format!("{} B", size)
718 }
719}
720
721pub fn format_modified(time: SystemTime) -> String {
723 let now = SystemTime::now();
724 match now.duration_since(time) {
725 Ok(duration) => {
726 let secs = duration.as_secs();
727 if secs < 60 {
728 "just now".to_string()
729 } else if secs < 3600 {
730 format!("{} min ago", secs / 60)
731 } else if secs < 86400 {
732 format!("{} hr ago", secs / 3600)
733 } else if secs < 86400 * 7 {
734 format!("{} days ago", secs / 86400)
735 } else {
736 let datetime: chrono::DateTime<chrono::Local> = time.into();
738 datetime.format("%Y-%m-%d").to_string()
739 }
740 }
741 Err(_) => {
742 let datetime: chrono::DateTime<chrono::Local> = time.into();
744 datetime.format("%Y-%m-%d").to_string()
745 }
746 }
747}
748
749#[cfg(test)]
750mod tests {
751 use super::*;
752 use crate::model::filesystem::StdFileSystem;
753
754 fn test_filesystem() -> Arc<dyn FileSystem + Send + Sync> {
755 Arc::new(StdFileSystem)
756 }
757
758 fn make_entry(name: &str, is_dir: bool) -> DirEntry {
759 DirEntry::new(
760 PathBuf::from(format!("/test/{}", name)),
761 name.to_string(),
762 if is_dir {
763 EntryType::Directory
764 } else {
765 EntryType::File
766 },
767 )
768 }
769
770 fn make_entry_with_size(name: &str, size: u64) -> DirEntry {
771 make_entry(name, false).with_metadata(crate::model::filesystem::FileMetadata::new(size))
772 }
773
774 #[test]
775 fn test_sort_by_name() {
776 let mut state = FileOpenState::new(PathBuf::from("/"), false, test_filesystem());
778 state.set_entries(vec![
779 make_entry("zebra.txt", false),
780 make_entry("alpha.txt", false),
781 make_entry("beta", true),
782 ]);
783
784 assert_eq!(state.entries[0].fs_entry.name, "beta"); assert_eq!(state.entries[1].fs_entry.name, "alpha.txt");
786 assert_eq!(state.entries[2].fs_entry.name, "zebra.txt");
787 }
788
789 #[test]
790 fn test_sort_by_size() {
791 let mut state = FileOpenState::new(PathBuf::from("/"), false, test_filesystem());
793 state.sort_mode = SortMode::Size;
794 state.set_entries(vec![
795 make_entry_with_size("big.txt", 1000),
796 make_entry_with_size("small.txt", 100),
797 make_entry_with_size("medium.txt", 500),
798 ]);
799
800 assert_eq!(state.entries[0].fs_entry.name, "small.txt");
801 assert_eq!(state.entries[1].fs_entry.name, "medium.txt");
802 assert_eq!(state.entries[2].fs_entry.name, "big.txt");
803 }
804
805 #[test]
806 fn test_filter() {
807 let mut state = FileOpenState::new(PathBuf::from("/"), false, test_filesystem());
809 state.set_entries(vec![
810 make_entry("foo.txt", false),
811 make_entry("bar.txt", false),
812 make_entry("foobar.txt", false),
813 ]);
814
815 state.apply_filter("foo");
816
817 assert_eq!(state.entries[0].fs_entry.name, "foo.txt");
821 assert!(state.entries[0].matches_filter);
822
823 assert_eq!(state.entries[1].fs_entry.name, "foobar.txt");
824 assert!(state.entries[1].matches_filter);
825
826 assert_eq!(state.entries[2].fs_entry.name, "bar.txt");
827 assert!(!state.entries[2].matches_filter);
828
829 assert_eq!(state.matching_count(), 2);
830 }
831
832 #[test]
833 fn test_filter_case_insensitive() {
834 let mut state = FileOpenState::new(PathBuf::from("/"), false, test_filesystem());
836 state.set_entries(vec![
837 make_entry("README.md", false),
838 make_entry("readme.txt", false),
839 make_entry("other.txt", false),
840 ]);
841
842 state.apply_filter("readme");
843
844 assert!(state.entries[0].matches_filter);
847 assert!(state.entries[1].matches_filter);
848
849 assert_eq!(state.entries[2].fs_entry.name, "other.txt");
850 assert!(!state.entries[2].matches_filter);
851 }
852
853 #[test]
854 fn test_hidden_files() {
855 let mut state = FileOpenState::new(PathBuf::from("/"), false, test_filesystem());
857 state.show_hidden = false;
858 state.set_entries(vec![
859 make_entry(".hidden", false),
860 make_entry("visible.txt", false),
861 ]);
862
863 assert_eq!(state.entries.len(), 1);
865 assert_eq!(state.entries[0].fs_entry.name, "visible.txt");
866 }
867
868 #[test]
872 fn test_prefix_filter_reveals_hidden() {
873 let mut state = FileOpenState::new(PathBuf::from("/"), false, test_filesystem());
875 state.show_hidden = false;
876 state.set_entries(vec![
877 make_entry(".bashrc", false),
878 make_entry(".config", true),
879 make_entry("visible.txt", false),
880 ]);
881
882 assert!(
884 !state.entries.iter().any(|e| e.fs_entry.name == ".bashrc"),
885 "hidden file should not be listed before typing a dot"
886 );
887
888 state.apply_filter(".");
890 assert!(
891 state.entries.iter().any(|e| e.fs_entry.name == ".bashrc"),
892 "'.' should reveal hidden files"
893 );
894 assert!(
895 state.entries.iter().any(|e| e.fs_entry.name == ".config"),
896 "'.' should reveal hidden directories"
897 );
898
899 state.apply_filter("");
901 assert!(
902 !state.entries.iter().any(|e| e.fs_entry.name == ".bashrc"),
903 "clearing the filter should hide dotfiles again"
904 );
905 }
906
907 #[test]
910 fn test_prefix_filter_reveals_only_matching_hidden_files() {
911 let mut state = FileOpenState::new(PathBuf::from("/"), false, test_filesystem());
912 state.show_hidden = false;
913 state.set_entries(vec![
914 make_entry(".bashrc", false),
915 make_entry(".gitignore", false),
916 make_entry("visible.txt", false),
917 ]);
918
919 state.apply_filter(".bash");
920
921 let bashrc = state
922 .entries
923 .iter()
924 .find(|e| e.fs_entry.name == ".bashrc")
925 .expect(".bashrc should be revealed by the '.bash' prefix");
926 assert!(bashrc.matches_filter, ".bashrc should match '.bash'");
927
928 assert!(
930 !state
931 .entries
932 .iter()
933 .any(|e| e.fs_entry.name == ".gitignore"),
934 ".gitignore is not prefixed by '.bash' and should stay hidden"
935 );
936 }
937
938 #[test]
943 fn test_non_dot_filter_keeps_hidden_files_hidden() {
944 let mut state = FileOpenState::new(PathBuf::from("/"), false, test_filesystem());
945 state.show_hidden = false;
946 state.set_entries(vec![
947 make_entry(".bashrc", false),
948 make_entry("visible.txt", false),
949 ]);
950
951 state.apply_filter("bash");
952 assert!(
953 !state.entries.iter().any(|e| e.fs_entry.name == ".bashrc"),
954 "a non-dot query must not reveal dotfiles"
955 );
956 }
957
958 #[test]
959 fn test_format_size() {
960 assert_eq!(format_size(500), "500 B");
961 assert_eq!(format_size(1024), "1.0 KB");
962 assert_eq!(format_size(1536), "1.5 KB");
963 assert_eq!(format_size(1048576), "1.0 MB");
964 assert_eq!(format_size(1073741824), "1.0 GB");
965 }
966
967 #[test]
968 fn test_navigation() {
969 let mut state = FileOpenState::new(PathBuf::from("/"), false, test_filesystem());
971 state.set_entries(vec![
972 make_entry("a.txt", false),
973 make_entry("b.txt", false),
974 make_entry("c.txt", false),
975 ]);
976
977 assert_eq!(state.selected_index, None);
979
980 state.select_next();
982 assert_eq!(state.selected_index, Some(0));
983
984 state.select_next();
985 assert_eq!(state.selected_index, Some(1));
986
987 state.select_next();
988 assert_eq!(state.selected_index, Some(2));
989
990 state.select_next(); assert_eq!(state.selected_index, Some(2));
992
993 state.select_prev();
994 assert_eq!(state.selected_index, Some(1));
995
996 state.select_first();
997 assert_eq!(state.selected_index, Some(0));
998
999 state.select_last();
1000 assert_eq!(state.selected_index, Some(2));
1001 }
1002
1003 #[test]
1008 fn test_scroll_follows_selection_in_small_viewport() {
1009 let mut state = FileOpenState::new(PathBuf::from("/"), false, test_filesystem());
1010 state.set_entries(
1011 (0..10)
1012 .map(|i| make_entry(&format!("f{i}"), false))
1013 .collect(),
1014 );
1015
1016 state.update_scroll_for_visible_rows(3);
1018 assert_eq!(state.scroll_offset, 0);
1019
1020 for _ in 0..5 {
1022 state.select_next();
1023 }
1024 assert_eq!(state.selected_index, Some(4));
1025 let idx = state.selected_index.unwrap();
1026 assert!(
1027 idx >= state.scroll_offset && idx < state.scroll_offset + 3,
1028 "selected idx {idx} not in viewport [{}, {})",
1029 state.scroll_offset,
1030 state.scroll_offset + 3
1031 );
1032
1033 state.select_last();
1035 let idx = state.selected_index.unwrap();
1036 assert_eq!(idx, 9);
1037 assert!(
1038 idx >= state.scroll_offset && idx < state.scroll_offset + 3,
1039 "select_last left idx {idx} outside viewport [{}, {})",
1040 state.scroll_offset,
1041 state.scroll_offset + 3
1042 );
1043
1044 state.select_first();
1046 assert_eq!(state.selected_index, Some(0));
1047 assert_eq!(state.scroll_offset, 0);
1048 }
1049
1050 #[test]
1053 fn test_scroll_reclamped_on_viewport_shrink() {
1054 let mut state = FileOpenState::new(PathBuf::from("/"), false, test_filesystem());
1055 state.set_entries(
1056 (0..20)
1057 .map(|i| make_entry(&format!("f{i}"), false))
1058 .collect(),
1059 );
1060
1061 state.update_scroll_for_visible_rows(15);
1062 for _ in 0..15 {
1063 state.select_next();
1064 }
1065 assert_eq!(state.selected_index, Some(14));
1067 assert_eq!(state.scroll_offset, 0);
1068
1069 state.update_scroll_for_visible_rows(4);
1071 let idx = state.selected_index.unwrap();
1072 assert!(
1073 idx >= state.scroll_offset && idx < state.scroll_offset + 4,
1074 "selected idx {idx} not in shrunk viewport [{}, {})",
1075 state.scroll_offset,
1076 state.scroll_offset + 4
1077 );
1078 }
1079
1080 #[test]
1081 fn test_fuzzy_filter() {
1082 let mut state = FileOpenState::new(PathBuf::from("/"), false, test_filesystem());
1084 state.set_entries(vec![
1085 make_entry("command_registry.rs", false),
1086 make_entry("commands.rs", false),
1087 make_entry("keybindings.rs", false),
1088 make_entry("mod.rs", false),
1089 ]);
1090
1091 state.apply_filter("cmdreg");
1093
1094 assert!(state.entries[0].matches_filter);
1096 assert_eq!(state.entries[0].fs_entry.name, "command_registry.rs");
1097
1098 assert_eq!(state.matching_count(), 1);
1101 }
1102
1103 #[test]
1104 fn test_fuzzy_filter_sparse_match() {
1105 let mut state = FileOpenState::new(PathBuf::from("/"), false, test_filesystem());
1107 state.set_entries(vec![
1108 make_entry("Save File", false),
1109 make_entry("Select All", false),
1110 make_entry("something_else.txt", false),
1111 ]);
1112
1113 state.apply_filter("sf");
1115
1116 assert_eq!(state.matching_count(), 1);
1117 assert!(state.entries[0].matches_filter);
1118 assert_eq!(state.entries[0].fs_entry.name, "Save File");
1119 }
1120}