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 pub entries: Vec<FileOpenEntry>,
65
66 pub loading: bool,
68
69 pub error: Option<String>,
71
72 pub sort_mode: SortMode,
74
75 pub sort_ascending: bool,
77
78 pub selected_index: Option<usize>,
80
81 pub scroll_offset: usize,
83
84 pub last_visible_rows: usize,
89
90 pub active_section: FileOpenSection,
92
93 pub filter: String,
95
96 pub shortcuts: Vec<NavigationShortcut>,
98
99 pub selected_shortcut: usize,
101
102 pub show_hidden: bool,
104
105 pub detect_encoding: bool,
108
109 filesystem: Arc<dyn FileSystem + Send + Sync>,
111}
112
113impl FileOpenState {
114 pub fn new(
118 dir: PathBuf,
119 show_hidden: bool,
120 filesystem: Arc<dyn FileSystem + Send + Sync>,
121 ) -> Self {
122 let shortcuts = Self::build_shortcuts_sync(&dir, &*filesystem);
124 Self {
125 current_dir: dir,
126 entries: Vec::new(),
127 loading: true,
128 error: None,
129 sort_mode: SortMode::Name,
130 sort_ascending: true,
131 selected_index: None,
132 scroll_offset: 0,
133 last_visible_rows: 0,
134 active_section: FileOpenSection::Files,
135 filter: String::new(),
136 shortcuts,
137 selected_shortcut: 0,
138 show_hidden,
139 detect_encoding: true,
140 filesystem,
141 }
142 }
143
144 fn build_shortcuts_sync(
147 current_dir: &Path,
148 filesystem: &dyn FileSystem,
149 ) -> Vec<NavigationShortcut> {
150 let mut shortcuts = Vec::new();
151
152 if let Some(parent) = current_dir.parent() {
154 shortcuts.push(NavigationShortcut {
155 label: "..".to_string(),
156 path: parent.to_path_buf(),
157 description: t!("file_browser.parent_dir").to_string(),
158 });
159 }
160
161 #[cfg(unix)]
163 {
164 shortcuts.push(NavigationShortcut {
165 label: "/".to_string(),
166 path: PathBuf::from("/"),
167 description: t!("file_browser.root_dir").to_string(),
168 });
169 }
170
171 if let Ok(home) = filesystem.home_dir() {
174 shortcuts.push(NavigationShortcut {
175 label: "~".to_string(),
176 path: home,
177 description: t!("file_browser.home_dir").to_string(),
178 });
179 }
180
181 shortcuts
182 }
183
184 pub fn build_shortcuts_async(filesystem: &dyn FileSystem) -> Vec<NavigationShortcut> {
189 let mut shortcuts = Vec::new();
190
191 if let Some(docs) = dirs::document_dir() {
193 if filesystem.exists(&docs) {
194 shortcuts.push(NavigationShortcut {
195 label: t!("file_browser.documents").to_string(),
196 path: docs,
197 description: t!("file_browser.documents_folder").to_string(),
198 });
199 }
200 }
201
202 if let Some(downloads) = dirs::download_dir() {
204 if filesystem.exists(&downloads) {
205 shortcuts.push(NavigationShortcut {
206 label: t!("file_browser.downloads").to_string(),
207 path: downloads,
208 description: t!("file_browser.downloads_folder").to_string(),
209 });
210 }
211 }
212
213 #[cfg(windows)]
218 {
219 for letter in b'A'..=b'Z' {
220 let path = PathBuf::from(format!("{}:\\", letter as char));
221 if filesystem.exists(&path) {
222 shortcuts.push(NavigationShortcut {
223 label: format!("{}:", letter as char),
224 path,
225 description: t!("file_browser.drive").to_string(),
226 });
227 }
228 }
229 }
230
231 shortcuts
232 }
233
234 pub fn merge_async_shortcuts(&mut self, async_shortcuts: Vec<NavigationShortcut>) {
237 self.shortcuts.extend(async_shortcuts);
239 }
240
241 pub fn update_shortcuts(&mut self) {
244 self.shortcuts = Self::build_shortcuts_sync(&self.current_dir, &*self.filesystem);
245 self.selected_shortcut = 0;
246 }
247
248 pub fn set_entries(&mut self, entries: Vec<DirEntry>) {
250 let mut result: Vec<FileOpenEntry> = Vec::new();
251
252 if let Some(parent) = self.current_dir.parent() {
254 let parent_entry =
255 DirEntry::new(parent.to_path_buf(), "..".to_string(), EntryType::Directory);
256 result.push(FileOpenEntry {
257 fs_entry: parent_entry,
258 matches_filter: true,
259 match_score: 0,
260 });
261 }
262
263 result.extend(
265 entries
266 .into_iter()
267 .filter(|e| self.show_hidden || !Self::is_hidden(&e.name))
268 .map(|fs_entry| FileOpenEntry {
269 fs_entry,
270 matches_filter: true,
271 match_score: 0,
272 }),
273 );
274
275 self.entries = result;
276 self.loading = false;
277 self.error = None;
278 self.apply_filter_internal();
279 self.sort_entries();
280 self.selected_index = None;
282 self.scroll_offset = 0;
283 }
284
285 pub fn set_error(&mut self, error: String) {
287 self.loading = false;
288 self.error = Some(error);
289 self.entries.clear();
290 }
291
292 fn is_hidden(name: &str) -> bool {
294 name.starts_with('.')
295 }
296
297 pub fn apply_filter(&mut self, filter: &str) {
301 self.filter = filter.to_string();
302 self.apply_filter_internal();
303
304 if !filter.is_empty() {
306 self.entries.sort_by(|a, b| {
307 let a_is_parent = a.fs_entry.name == "..";
309 let b_is_parent = b.fs_entry.name == "..";
310
311 if a_is_parent && !b_is_parent {
312 return Ordering::Less;
313 }
314 if !a_is_parent && b_is_parent {
315 return Ordering::Greater;
316 }
317
318 match (a.matches_filter, b.matches_filter) {
320 (true, false) => Ordering::Less,
321 (false, true) => Ordering::Greater,
322 (true, true) => {
323 b.match_score.cmp(&a.match_score)
325 }
326 (false, false) => {
327 a.fs_entry
329 .name
330 .to_lowercase()
331 .cmp(&b.fs_entry.name.to_lowercase())
332 }
333 }
334 });
335
336 let first_match = self
338 .entries
339 .iter()
340 .position(|e| e.matches_filter && e.fs_entry.name != "..");
341 if let Some(idx) = first_match {
342 self.selected_index = Some(idx);
343 self.ensure_selected_visible();
344 } else {
345 self.selected_index = None;
346 }
347 } else {
348 self.sort_entries();
350 self.selected_index = None;
351 }
352 }
353
354 fn apply_filter_internal(&mut self) {
355 for entry in &mut self.entries {
356 if self.filter.is_empty() {
357 entry.matches_filter = true;
358 entry.match_score = 0;
359 } else {
360 let result = fuzzy_match(&self.filter, &entry.fs_entry.name);
361 entry.matches_filter = result.matched;
362 entry.match_score = result.score;
363 }
364 }
365 }
366
367 pub fn sort_entries(&mut self) {
369 let sort_mode = self.sort_mode;
370 let ascending = self.sort_ascending;
371
372 self.entries.sort_by(|a, b| {
373 let a_is_parent = a.fs_entry.name == "..";
375 let b_is_parent = b.fs_entry.name == "..";
376 match (a_is_parent, b_is_parent) {
377 (true, false) => return Ordering::Less,
378 (false, true) => return Ordering::Greater,
379 (true, true) => return Ordering::Equal,
380 _ => {}
381 }
382
383 match (a.fs_entry.is_dir(), b.fs_entry.is_dir()) {
388 (true, false) => return Ordering::Less,
389 (false, true) => return Ordering::Greater,
390 _ => {}
391 }
392
393 let ord = match sort_mode {
395 SortMode::Name => a
396 .fs_entry
397 .name
398 .to_lowercase()
399 .cmp(&b.fs_entry.name.to_lowercase()),
400 SortMode::Size => {
401 let a_size = a.fs_entry.metadata.as_ref().map(|m| m.size).unwrap_or(0);
402 let b_size = b.fs_entry.metadata.as_ref().map(|m| m.size).unwrap_or(0);
403 a_size.cmp(&b_size)
404 }
405 SortMode::Modified => {
406 let a_mod = a.fs_entry.metadata.as_ref().and_then(|m| m.modified);
407 let b_mod = b.fs_entry.metadata.as_ref().and_then(|m| m.modified);
408 match (a_mod, b_mod) {
409 (Some(a), Some(b)) => a.cmp(&b),
410 (Some(_), None) => Ordering::Less,
411 (None, Some(_)) => Ordering::Greater,
412 (None, None) => Ordering::Equal,
413 }
414 }
415 SortMode::Type => {
416 let a_ext = std::path::Path::new(&a.fs_entry.name)
417 .extension()
418 .and_then(|e| e.to_str())
419 .unwrap_or("");
420 let b_ext = std::path::Path::new(&b.fs_entry.name)
421 .extension()
422 .and_then(|e| e.to_str())
423 .unwrap_or("");
424 a_ext.to_lowercase().cmp(&b_ext.to_lowercase())
425 }
426 };
427
428 if ascending {
429 ord
430 } else {
431 ord.reverse()
432 }
433 });
434 }
435
436 pub fn set_sort_mode(&mut self, mode: SortMode) {
438 if self.sort_mode == mode {
439 self.sort_ascending = !self.sort_ascending;
441 } else {
442 self.sort_mode = mode;
443 self.sort_ascending = true;
444 }
445 self.sort_entries();
446 }
447
448 pub fn toggle_hidden(&mut self) {
450 self.show_hidden = !self.show_hidden;
451 }
453
454 pub fn toggle_detect_encoding(&mut self) {
456 self.detect_encoding = !self.detect_encoding;
457 }
458
459 pub fn select_prev(&mut self) {
461 match self.active_section {
462 FileOpenSection::Navigation => {
463 if self.selected_shortcut > 0 {
464 self.selected_shortcut -= 1;
465 }
466 }
467 FileOpenSection::Files => {
468 if let Some(idx) = self.selected_index {
469 if idx > 0 {
470 self.selected_index = Some(idx - 1);
471 self.ensure_selected_visible();
472 }
473 } else if !self.entries.is_empty() {
474 self.selected_index = Some(self.entries.len() - 1);
476 self.ensure_selected_visible();
477 }
478 }
479 }
480 }
481
482 pub fn select_next(&mut self) {
484 match self.active_section {
485 FileOpenSection::Navigation => {
486 if self.selected_shortcut + 1 < self.shortcuts.len() {
487 self.selected_shortcut += 1;
488 }
489 }
490 FileOpenSection::Files => {
491 if let Some(idx) = self.selected_index {
492 if idx + 1 < self.entries.len() {
493 self.selected_index = Some(idx + 1);
494 self.ensure_selected_visible();
495 }
496 } else if !self.entries.is_empty() {
497 self.selected_index = Some(0);
499 self.ensure_selected_visible();
500 }
501 }
502 }
503 }
504
505 pub fn page_up(&mut self, page_size: usize) {
507 if self.active_section == FileOpenSection::Files {
508 if let Some(idx) = self.selected_index {
509 self.selected_index = Some(idx.saturating_sub(page_size));
510 self.ensure_selected_visible();
511 } else if !self.entries.is_empty() {
512 self.selected_index = Some(0);
513 }
514 }
515 }
516
517 pub fn page_down(&mut self, page_size: usize) {
519 if self.active_section == FileOpenSection::Files {
520 if let Some(idx) = self.selected_index {
521 self.selected_index =
522 Some((idx + page_size).min(self.entries.len().saturating_sub(1)));
523 self.ensure_selected_visible();
524 } else if !self.entries.is_empty() {
525 self.selected_index = Some(self.entries.len().saturating_sub(1));
526 }
527 }
528 }
529
530 pub fn select_first(&mut self) {
532 match self.active_section {
533 FileOpenSection::Navigation => self.selected_shortcut = 0,
534 FileOpenSection::Files => {
535 if !self.entries.is_empty() {
536 self.selected_index = Some(0);
537 self.scroll_offset = 0;
538 }
539 }
540 }
541 }
542
543 pub fn select_last(&mut self) {
545 match self.active_section {
546 FileOpenSection::Navigation => {
547 self.selected_shortcut = self.shortcuts.len().saturating_sub(1);
548 }
549 FileOpenSection::Files => {
550 if !self.entries.is_empty() {
551 self.selected_index = Some(self.entries.len() - 1);
552 self.ensure_selected_visible();
553 }
554 }
555 }
556 }
557
558 fn ensure_selected_visible(&mut self) {
562 self.clamp_scroll_to_selection();
563 }
564
565 pub fn update_scroll_for_visible_rows(&mut self, visible_rows: usize) {
569 self.last_visible_rows = visible_rows;
570 self.clamp_scroll_to_selection();
571 }
572
573 fn clamp_scroll_to_selection(&mut self) {
577 let visible_rows = self.last_visible_rows;
578 if visible_rows == 0 {
579 return;
580 }
581 if let Some(idx) = self.selected_index {
582 if idx < self.scroll_offset {
583 self.scroll_offset = idx;
584 } else if idx >= self.scroll_offset + visible_rows {
585 self.scroll_offset = idx + 1 - visible_rows;
586 }
587 }
588 let max_offset = self.entries.len().saturating_sub(visible_rows);
589 if self.scroll_offset > max_offset {
590 self.scroll_offset = max_offset;
591 }
592 }
593
594 pub fn switch_section(&mut self) {
596 self.active_section = match self.active_section {
597 FileOpenSection::Navigation => FileOpenSection::Files,
598 FileOpenSection::Files => FileOpenSection::Navigation,
599 };
600 }
601
602 pub fn selected_entry(&self) -> Option<&FileOpenEntry> {
604 if self.active_section == FileOpenSection::Files {
605 self.selected_index.and_then(|idx| self.entries.get(idx))
606 } else {
607 None
608 }
609 }
610
611 pub fn selected_shortcut_entry(&self) -> Option<&NavigationShortcut> {
613 if self.active_section == FileOpenSection::Navigation {
614 self.shortcuts.get(self.selected_shortcut)
615 } else {
616 None
617 }
618 }
619
620 pub fn get_selected_path(&self) -> Option<PathBuf> {
622 match self.active_section {
623 FileOpenSection::Navigation => self
624 .shortcuts
625 .get(self.selected_shortcut)
626 .map(|s| s.path.clone()),
627 FileOpenSection::Files => self
628 .selected_index
629 .and_then(|idx| self.entries.get(idx))
630 .map(|e| e.fs_entry.path.clone()),
631 }
632 }
633
634 pub fn selected_is_dir(&self) -> bool {
636 match self.active_section {
637 FileOpenSection::Navigation => true, FileOpenSection::Files => self
639 .selected_index
640 .and_then(|idx| self.entries.get(idx))
641 .map(|e| e.fs_entry.is_dir())
642 .unwrap_or(false),
643 }
644 }
645
646 pub fn matching_count(&self) -> usize {
648 self.entries.iter().filter(|e| e.matches_filter).count()
649 }
650
651 pub fn visible_entries(&self, max_rows: usize) -> &[FileOpenEntry] {
653 let start = self.scroll_offset;
654 let end = (start + max_rows).min(self.entries.len());
655 &self.entries[start..end]
656 }
657}
658
659pub fn format_size(size: u64) -> String {
661 const KB: u64 = 1024;
662 const MB: u64 = KB * 1024;
663 const GB: u64 = MB * 1024;
664
665 if size >= GB {
666 format!("{:.1} GB", size as f64 / GB as f64)
667 } else if size >= MB {
668 format!("{:.1} MB", size as f64 / MB as f64)
669 } else if size >= KB {
670 format!("{:.1} KB", size as f64 / KB as f64)
671 } else {
672 format!("{} B", size)
673 }
674}
675
676pub fn format_modified(time: SystemTime) -> String {
678 let now = SystemTime::now();
679 match now.duration_since(time) {
680 Ok(duration) => {
681 let secs = duration.as_secs();
682 if secs < 60 {
683 "just now".to_string()
684 } else if secs < 3600 {
685 format!("{} min ago", secs / 60)
686 } else if secs < 86400 {
687 format!("{} hr ago", secs / 3600)
688 } else if secs < 86400 * 7 {
689 format!("{} days ago", secs / 86400)
690 } else {
691 let datetime: chrono::DateTime<chrono::Local> = time.into();
693 datetime.format("%Y-%m-%d").to_string()
694 }
695 }
696 Err(_) => {
697 let datetime: chrono::DateTime<chrono::Local> = time.into();
699 datetime.format("%Y-%m-%d").to_string()
700 }
701 }
702}
703
704#[cfg(test)]
705mod tests {
706 use super::*;
707 use crate::model::filesystem::StdFileSystem;
708
709 fn test_filesystem() -> Arc<dyn FileSystem + Send + Sync> {
710 Arc::new(StdFileSystem)
711 }
712
713 fn make_entry(name: &str, is_dir: bool) -> DirEntry {
714 DirEntry::new(
715 PathBuf::from(format!("/test/{}", name)),
716 name.to_string(),
717 if is_dir {
718 EntryType::Directory
719 } else {
720 EntryType::File
721 },
722 )
723 }
724
725 fn make_entry_with_size(name: &str, size: u64) -> DirEntry {
726 make_entry(name, false).with_metadata(crate::model::filesystem::FileMetadata::new(size))
727 }
728
729 #[test]
730 fn test_sort_by_name() {
731 let mut state = FileOpenState::new(PathBuf::from("/"), false, test_filesystem());
733 state.set_entries(vec![
734 make_entry("zebra.txt", false),
735 make_entry("alpha.txt", false),
736 make_entry("beta", true),
737 ]);
738
739 assert_eq!(state.entries[0].fs_entry.name, "beta"); assert_eq!(state.entries[1].fs_entry.name, "alpha.txt");
741 assert_eq!(state.entries[2].fs_entry.name, "zebra.txt");
742 }
743
744 #[test]
745 fn test_sort_by_size() {
746 let mut state = FileOpenState::new(PathBuf::from("/"), false, test_filesystem());
748 state.sort_mode = SortMode::Size;
749 state.set_entries(vec![
750 make_entry_with_size("big.txt", 1000),
751 make_entry_with_size("small.txt", 100),
752 make_entry_with_size("medium.txt", 500),
753 ]);
754
755 assert_eq!(state.entries[0].fs_entry.name, "small.txt");
756 assert_eq!(state.entries[1].fs_entry.name, "medium.txt");
757 assert_eq!(state.entries[2].fs_entry.name, "big.txt");
758 }
759
760 #[test]
761 fn test_filter() {
762 let mut state = FileOpenState::new(PathBuf::from("/"), false, test_filesystem());
764 state.set_entries(vec![
765 make_entry("foo.txt", false),
766 make_entry("bar.txt", false),
767 make_entry("foobar.txt", false),
768 ]);
769
770 state.apply_filter("foo");
771
772 assert_eq!(state.entries[0].fs_entry.name, "foo.txt");
776 assert!(state.entries[0].matches_filter);
777
778 assert_eq!(state.entries[1].fs_entry.name, "foobar.txt");
779 assert!(state.entries[1].matches_filter);
780
781 assert_eq!(state.entries[2].fs_entry.name, "bar.txt");
782 assert!(!state.entries[2].matches_filter);
783
784 assert_eq!(state.matching_count(), 2);
785 }
786
787 #[test]
788 fn test_filter_case_insensitive() {
789 let mut state = FileOpenState::new(PathBuf::from("/"), false, test_filesystem());
791 state.set_entries(vec![
792 make_entry("README.md", false),
793 make_entry("readme.txt", false),
794 make_entry("other.txt", false),
795 ]);
796
797 state.apply_filter("readme");
798
799 assert!(state.entries[0].matches_filter);
802 assert!(state.entries[1].matches_filter);
803
804 assert_eq!(state.entries[2].fs_entry.name, "other.txt");
805 assert!(!state.entries[2].matches_filter);
806 }
807
808 #[test]
809 fn test_hidden_files() {
810 let mut state = FileOpenState::new(PathBuf::from("/"), false, test_filesystem());
812 state.show_hidden = false;
813 state.set_entries(vec![
814 make_entry(".hidden", false),
815 make_entry("visible.txt", false),
816 ]);
817
818 assert_eq!(state.entries.len(), 1);
820 assert_eq!(state.entries[0].fs_entry.name, "visible.txt");
821 }
822
823 #[test]
824 fn test_format_size() {
825 assert_eq!(format_size(500), "500 B");
826 assert_eq!(format_size(1024), "1.0 KB");
827 assert_eq!(format_size(1536), "1.5 KB");
828 assert_eq!(format_size(1048576), "1.0 MB");
829 assert_eq!(format_size(1073741824), "1.0 GB");
830 }
831
832 #[test]
833 fn test_navigation() {
834 let mut state = FileOpenState::new(PathBuf::from("/"), false, test_filesystem());
836 state.set_entries(vec![
837 make_entry("a.txt", false),
838 make_entry("b.txt", false),
839 make_entry("c.txt", false),
840 ]);
841
842 assert_eq!(state.selected_index, None);
844
845 state.select_next();
847 assert_eq!(state.selected_index, Some(0));
848
849 state.select_next();
850 assert_eq!(state.selected_index, Some(1));
851
852 state.select_next();
853 assert_eq!(state.selected_index, Some(2));
854
855 state.select_next(); assert_eq!(state.selected_index, Some(2));
857
858 state.select_prev();
859 assert_eq!(state.selected_index, Some(1));
860
861 state.select_first();
862 assert_eq!(state.selected_index, Some(0));
863
864 state.select_last();
865 assert_eq!(state.selected_index, Some(2));
866 }
867
868 #[test]
873 fn test_scroll_follows_selection_in_small_viewport() {
874 let mut state = FileOpenState::new(PathBuf::from("/"), false, test_filesystem());
875 state.set_entries(
876 (0..10)
877 .map(|i| make_entry(&format!("f{i}"), false))
878 .collect(),
879 );
880
881 state.update_scroll_for_visible_rows(3);
883 assert_eq!(state.scroll_offset, 0);
884
885 for _ in 0..5 {
887 state.select_next();
888 }
889 assert_eq!(state.selected_index, Some(4));
890 let idx = state.selected_index.unwrap();
891 assert!(
892 idx >= state.scroll_offset && idx < state.scroll_offset + 3,
893 "selected idx {idx} not in viewport [{}, {})",
894 state.scroll_offset,
895 state.scroll_offset + 3
896 );
897
898 state.select_last();
900 let idx = state.selected_index.unwrap();
901 assert_eq!(idx, 9);
902 assert!(
903 idx >= state.scroll_offset && idx < state.scroll_offset + 3,
904 "select_last left idx {idx} outside viewport [{}, {})",
905 state.scroll_offset,
906 state.scroll_offset + 3
907 );
908
909 state.select_first();
911 assert_eq!(state.selected_index, Some(0));
912 assert_eq!(state.scroll_offset, 0);
913 }
914
915 #[test]
918 fn test_scroll_reclamped_on_viewport_shrink() {
919 let mut state = FileOpenState::new(PathBuf::from("/"), false, test_filesystem());
920 state.set_entries(
921 (0..20)
922 .map(|i| make_entry(&format!("f{i}"), false))
923 .collect(),
924 );
925
926 state.update_scroll_for_visible_rows(15);
927 for _ in 0..15 {
928 state.select_next();
929 }
930 assert_eq!(state.selected_index, Some(14));
932 assert_eq!(state.scroll_offset, 0);
933
934 state.update_scroll_for_visible_rows(4);
936 let idx = state.selected_index.unwrap();
937 assert!(
938 idx >= state.scroll_offset && idx < state.scroll_offset + 4,
939 "selected idx {idx} not in shrunk viewport [{}, {})",
940 state.scroll_offset,
941 state.scroll_offset + 4
942 );
943 }
944
945 #[test]
946 fn test_fuzzy_filter() {
947 let mut state = FileOpenState::new(PathBuf::from("/"), false, test_filesystem());
949 state.set_entries(vec![
950 make_entry("command_registry.rs", false),
951 make_entry("commands.rs", false),
952 make_entry("keybindings.rs", false),
953 make_entry("mod.rs", false),
954 ]);
955
956 state.apply_filter("cmdreg");
958
959 assert!(state.entries[0].matches_filter);
961 assert_eq!(state.entries[0].fs_entry.name, "command_registry.rs");
962
963 assert_eq!(state.matching_count(), 1);
966 }
967
968 #[test]
969 fn test_fuzzy_filter_sparse_match() {
970 let mut state = FileOpenState::new(PathBuf::from("/"), false, test_filesystem());
972 state.set_entries(vec![
973 make_entry("Save File", false),
974 make_entry("Select All", false),
975 make_entry("something_else.txt", false),
976 ]);
977
978 state.apply_filter("sf");
980
981 assert_eq!(state.matching_count(), 1);
982 assert!(state.entries[0].matches_filter);
983 assert_eq!(state.entries[0].fs_entry.name, "Save File");
984 }
985}