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 active_section: FileOpenSection,
86
87 pub filter: String,
89
90 pub shortcuts: Vec<NavigationShortcut>,
92
93 pub selected_shortcut: usize,
95
96 pub show_hidden: bool,
98
99 pub detect_encoding: bool,
102
103 filesystem: Arc<dyn FileSystem + Send + Sync>,
105}
106
107impl FileOpenState {
108 pub fn new(
112 dir: PathBuf,
113 show_hidden: bool,
114 filesystem: Arc<dyn FileSystem + Send + Sync>,
115 ) -> Self {
116 let shortcuts = Self::build_shortcuts_sync(&dir, &*filesystem);
118 Self {
119 current_dir: dir,
120 entries: Vec::new(),
121 loading: true,
122 error: None,
123 sort_mode: SortMode::Name,
124 sort_ascending: true,
125 selected_index: None,
126 scroll_offset: 0,
127 active_section: FileOpenSection::Files,
128 filter: String::new(),
129 shortcuts,
130 selected_shortcut: 0,
131 show_hidden,
132 detect_encoding: true,
133 filesystem,
134 }
135 }
136
137 fn build_shortcuts_sync(
140 current_dir: &Path,
141 filesystem: &dyn FileSystem,
142 ) -> Vec<NavigationShortcut> {
143 let mut shortcuts = Vec::new();
144
145 if let Some(parent) = current_dir.parent() {
147 shortcuts.push(NavigationShortcut {
148 label: "..".to_string(),
149 path: parent.to_path_buf(),
150 description: t!("file_browser.parent_dir").to_string(),
151 });
152 }
153
154 #[cfg(unix)]
156 {
157 shortcuts.push(NavigationShortcut {
158 label: "/".to_string(),
159 path: PathBuf::from("/"),
160 description: t!("file_browser.root_dir").to_string(),
161 });
162 }
163
164 if let Ok(home) = filesystem.home_dir() {
167 shortcuts.push(NavigationShortcut {
168 label: "~".to_string(),
169 path: home,
170 description: t!("file_browser.home_dir").to_string(),
171 });
172 }
173
174 shortcuts
175 }
176
177 pub fn build_shortcuts_async(filesystem: &dyn FileSystem) -> Vec<NavigationShortcut> {
182 let mut shortcuts = Vec::new();
183
184 if let Some(docs) = dirs::document_dir() {
186 if filesystem.exists(&docs) {
187 shortcuts.push(NavigationShortcut {
188 label: t!("file_browser.documents").to_string(),
189 path: docs,
190 description: t!("file_browser.documents_folder").to_string(),
191 });
192 }
193 }
194
195 if let Some(downloads) = dirs::download_dir() {
197 if filesystem.exists(&downloads) {
198 shortcuts.push(NavigationShortcut {
199 label: t!("file_browser.downloads").to_string(),
200 path: downloads,
201 description: t!("file_browser.downloads_folder").to_string(),
202 });
203 }
204 }
205
206 #[cfg(windows)]
211 {
212 for letter in b'A'..=b'Z' {
213 let path = PathBuf::from(format!("{}:\\", letter as char));
214 if filesystem.exists(&path) {
215 shortcuts.push(NavigationShortcut {
216 label: format!("{}:", letter as char),
217 path,
218 description: t!("file_browser.drive").to_string(),
219 });
220 }
221 }
222 }
223
224 shortcuts
225 }
226
227 pub fn merge_async_shortcuts(&mut self, async_shortcuts: Vec<NavigationShortcut>) {
230 self.shortcuts.extend(async_shortcuts);
232 }
233
234 pub fn update_shortcuts(&mut self) {
237 self.shortcuts = Self::build_shortcuts_sync(&self.current_dir, &*self.filesystem);
238 self.selected_shortcut = 0;
239 }
240
241 pub fn set_entries(&mut self, entries: Vec<DirEntry>) {
243 let mut result: Vec<FileOpenEntry> = Vec::new();
244
245 if let Some(parent) = self.current_dir.parent() {
247 let parent_entry =
248 DirEntry::new(parent.to_path_buf(), "..".to_string(), EntryType::Directory);
249 result.push(FileOpenEntry {
250 fs_entry: parent_entry,
251 matches_filter: true,
252 match_score: 0,
253 });
254 }
255
256 result.extend(
258 entries
259 .into_iter()
260 .filter(|e| self.show_hidden || !Self::is_hidden(&e.name))
261 .map(|fs_entry| FileOpenEntry {
262 fs_entry,
263 matches_filter: true,
264 match_score: 0,
265 }),
266 );
267
268 self.entries = result;
269 self.loading = false;
270 self.error = None;
271 self.apply_filter_internal();
272 self.sort_entries();
273 self.selected_index = None;
275 self.scroll_offset = 0;
276 }
277
278 pub fn set_error(&mut self, error: String) {
280 self.loading = false;
281 self.error = Some(error);
282 self.entries.clear();
283 }
284
285 fn is_hidden(name: &str) -> bool {
287 name.starts_with('.')
288 }
289
290 pub fn apply_filter(&mut self, filter: &str) {
294 self.filter = filter.to_string();
295 self.apply_filter_internal();
296
297 if !filter.is_empty() {
299 self.entries.sort_by(|a, b| {
300 let a_is_parent = a.fs_entry.name == "..";
302 let b_is_parent = b.fs_entry.name == "..";
303
304 if a_is_parent && !b_is_parent {
305 return Ordering::Less;
306 }
307 if !a_is_parent && b_is_parent {
308 return Ordering::Greater;
309 }
310
311 match (a.matches_filter, b.matches_filter) {
313 (true, false) => Ordering::Less,
314 (false, true) => Ordering::Greater,
315 (true, true) => {
316 b.match_score.cmp(&a.match_score)
318 }
319 (false, false) => {
320 a.fs_entry
322 .name
323 .to_lowercase()
324 .cmp(&b.fs_entry.name.to_lowercase())
325 }
326 }
327 });
328
329 let first_match = self
331 .entries
332 .iter()
333 .position(|e| e.matches_filter && e.fs_entry.name != "..");
334 if let Some(idx) = first_match {
335 self.selected_index = Some(idx);
336 self.ensure_selected_visible();
337 } else {
338 self.selected_index = None;
339 }
340 } else {
341 self.sort_entries();
343 self.selected_index = None;
344 }
345 }
346
347 fn apply_filter_internal(&mut self) {
348 for entry in &mut self.entries {
349 if self.filter.is_empty() {
350 entry.matches_filter = true;
351 entry.match_score = 0;
352 } else {
353 let result = fuzzy_match(&self.filter, &entry.fs_entry.name);
354 entry.matches_filter = result.matched;
355 entry.match_score = result.score;
356 }
357 }
358 }
359
360 pub fn sort_entries(&mut self) {
362 let sort_mode = self.sort_mode;
363 let ascending = self.sort_ascending;
364
365 self.entries.sort_by(|a, b| {
366 let a_is_parent = a.fs_entry.name == "..";
368 let b_is_parent = b.fs_entry.name == "..";
369 match (a_is_parent, b_is_parent) {
370 (true, false) => return Ordering::Less,
371 (false, true) => return Ordering::Greater,
372 (true, true) => return Ordering::Equal,
373 _ => {}
374 }
375
376 match (a.fs_entry.is_dir(), b.fs_entry.is_dir()) {
381 (true, false) => return Ordering::Less,
382 (false, true) => return Ordering::Greater,
383 _ => {}
384 }
385
386 let ord = match sort_mode {
388 SortMode::Name => a
389 .fs_entry
390 .name
391 .to_lowercase()
392 .cmp(&b.fs_entry.name.to_lowercase()),
393 SortMode::Size => {
394 let a_size = a.fs_entry.metadata.as_ref().map(|m| m.size).unwrap_or(0);
395 let b_size = b.fs_entry.metadata.as_ref().map(|m| m.size).unwrap_or(0);
396 a_size.cmp(&b_size)
397 }
398 SortMode::Modified => {
399 let a_mod = a.fs_entry.metadata.as_ref().and_then(|m| m.modified);
400 let b_mod = b.fs_entry.metadata.as_ref().and_then(|m| m.modified);
401 match (a_mod, b_mod) {
402 (Some(a), Some(b)) => a.cmp(&b),
403 (Some(_), None) => Ordering::Less,
404 (None, Some(_)) => Ordering::Greater,
405 (None, None) => Ordering::Equal,
406 }
407 }
408 SortMode::Type => {
409 let a_ext = std::path::Path::new(&a.fs_entry.name)
410 .extension()
411 .and_then(|e| e.to_str())
412 .unwrap_or("");
413 let b_ext = std::path::Path::new(&b.fs_entry.name)
414 .extension()
415 .and_then(|e| e.to_str())
416 .unwrap_or("");
417 a_ext.to_lowercase().cmp(&b_ext.to_lowercase())
418 }
419 };
420
421 if ascending {
422 ord
423 } else {
424 ord.reverse()
425 }
426 });
427 }
428
429 pub fn set_sort_mode(&mut self, mode: SortMode) {
431 if self.sort_mode == mode {
432 self.sort_ascending = !self.sort_ascending;
434 } else {
435 self.sort_mode = mode;
436 self.sort_ascending = true;
437 }
438 self.sort_entries();
439 }
440
441 pub fn toggle_hidden(&mut self) {
443 self.show_hidden = !self.show_hidden;
444 }
446
447 pub fn toggle_detect_encoding(&mut self) {
449 self.detect_encoding = !self.detect_encoding;
450 }
451
452 pub fn select_prev(&mut self) {
454 match self.active_section {
455 FileOpenSection::Navigation => {
456 if self.selected_shortcut > 0 {
457 self.selected_shortcut -= 1;
458 }
459 }
460 FileOpenSection::Files => {
461 if let Some(idx) = self.selected_index {
462 if idx > 0 {
463 self.selected_index = Some(idx - 1);
464 self.ensure_selected_visible();
465 }
466 } else if !self.entries.is_empty() {
467 self.selected_index = Some(self.entries.len() - 1);
469 self.ensure_selected_visible();
470 }
471 }
472 }
473 }
474
475 pub fn select_next(&mut self) {
477 match self.active_section {
478 FileOpenSection::Navigation => {
479 if self.selected_shortcut + 1 < self.shortcuts.len() {
480 self.selected_shortcut += 1;
481 }
482 }
483 FileOpenSection::Files => {
484 if let Some(idx) = self.selected_index {
485 if idx + 1 < self.entries.len() {
486 self.selected_index = Some(idx + 1);
487 self.ensure_selected_visible();
488 }
489 } else if !self.entries.is_empty() {
490 self.selected_index = Some(0);
492 self.ensure_selected_visible();
493 }
494 }
495 }
496 }
497
498 pub fn page_up(&mut self, page_size: usize) {
500 if self.active_section == FileOpenSection::Files {
501 if let Some(idx) = self.selected_index {
502 self.selected_index = Some(idx.saturating_sub(page_size));
503 self.ensure_selected_visible();
504 } else if !self.entries.is_empty() {
505 self.selected_index = Some(0);
506 }
507 }
508 }
509
510 pub fn page_down(&mut self, page_size: usize) {
512 if self.active_section == FileOpenSection::Files {
513 if let Some(idx) = self.selected_index {
514 self.selected_index =
515 Some((idx + page_size).min(self.entries.len().saturating_sub(1)));
516 self.ensure_selected_visible();
517 } else if !self.entries.is_empty() {
518 self.selected_index = Some(self.entries.len().saturating_sub(1));
519 }
520 }
521 }
522
523 pub fn select_first(&mut self) {
525 match self.active_section {
526 FileOpenSection::Navigation => self.selected_shortcut = 0,
527 FileOpenSection::Files => {
528 if !self.entries.is_empty() {
529 self.selected_index = Some(0);
530 self.scroll_offset = 0;
531 }
532 }
533 }
534 }
535
536 pub fn select_last(&mut self) {
538 match self.active_section {
539 FileOpenSection::Navigation => {
540 self.selected_shortcut = self.shortcuts.len().saturating_sub(1);
541 }
542 FileOpenSection::Files => {
543 if !self.entries.is_empty() {
544 self.selected_index = Some(self.entries.len() - 1);
545 self.ensure_selected_visible();
546 }
547 }
548 }
549 }
550
551 fn ensure_selected_visible(&mut self) {
553 let Some(idx) = self.selected_index else {
554 return;
555 };
556 let visible_rows = 15;
559 if idx < self.scroll_offset {
560 self.scroll_offset = idx;
561 } else if idx >= self.scroll_offset + visible_rows {
562 self.scroll_offset = idx.saturating_sub(visible_rows - 1);
563 }
564 }
565
566 pub fn update_scroll_for_visible_rows(&mut self, visible_rows: usize) {
568 let Some(idx) = self.selected_index else {
569 return;
570 };
571 if idx < self.scroll_offset {
572 self.scroll_offset = idx;
573 } else if idx >= self.scroll_offset + visible_rows {
574 self.scroll_offset = idx.saturating_sub(visible_rows - 1);
575 }
576 }
577
578 pub fn switch_section(&mut self) {
580 self.active_section = match self.active_section {
581 FileOpenSection::Navigation => FileOpenSection::Files,
582 FileOpenSection::Files => FileOpenSection::Navigation,
583 };
584 }
585
586 pub fn selected_entry(&self) -> Option<&FileOpenEntry> {
588 if self.active_section == FileOpenSection::Files {
589 self.selected_index.and_then(|idx| self.entries.get(idx))
590 } else {
591 None
592 }
593 }
594
595 pub fn selected_shortcut_entry(&self) -> Option<&NavigationShortcut> {
597 if self.active_section == FileOpenSection::Navigation {
598 self.shortcuts.get(self.selected_shortcut)
599 } else {
600 None
601 }
602 }
603
604 pub fn get_selected_path(&self) -> Option<PathBuf> {
606 match self.active_section {
607 FileOpenSection::Navigation => self
608 .shortcuts
609 .get(self.selected_shortcut)
610 .map(|s| s.path.clone()),
611 FileOpenSection::Files => self
612 .selected_index
613 .and_then(|idx| self.entries.get(idx))
614 .map(|e| e.fs_entry.path.clone()),
615 }
616 }
617
618 pub fn selected_is_dir(&self) -> bool {
620 match self.active_section {
621 FileOpenSection::Navigation => true, FileOpenSection::Files => self
623 .selected_index
624 .and_then(|idx| self.entries.get(idx))
625 .map(|e| e.fs_entry.is_dir())
626 .unwrap_or(false),
627 }
628 }
629
630 pub fn matching_count(&self) -> usize {
632 self.entries.iter().filter(|e| e.matches_filter).count()
633 }
634
635 pub fn visible_entries(&self, max_rows: usize) -> &[FileOpenEntry] {
637 let start = self.scroll_offset;
638 let end = (start + max_rows).min(self.entries.len());
639 &self.entries[start..end]
640 }
641}
642
643pub fn format_size(size: u64) -> String {
645 const KB: u64 = 1024;
646 const MB: u64 = KB * 1024;
647 const GB: u64 = MB * 1024;
648
649 if size >= GB {
650 format!("{:.1} GB", size as f64 / GB as f64)
651 } else if size >= MB {
652 format!("{:.1} MB", size as f64 / MB as f64)
653 } else if size >= KB {
654 format!("{:.1} KB", size as f64 / KB as f64)
655 } else {
656 format!("{} B", size)
657 }
658}
659
660pub fn format_modified(time: SystemTime) -> String {
662 let now = SystemTime::now();
663 match now.duration_since(time) {
664 Ok(duration) => {
665 let secs = duration.as_secs();
666 if secs < 60 {
667 "just now".to_string()
668 } else if secs < 3600 {
669 format!("{} min ago", secs / 60)
670 } else if secs < 86400 {
671 format!("{} hr ago", secs / 3600)
672 } else if secs < 86400 * 7 {
673 format!("{} days ago", secs / 86400)
674 } else {
675 let datetime: chrono::DateTime<chrono::Local> = time.into();
677 datetime.format("%Y-%m-%d").to_string()
678 }
679 }
680 Err(_) => {
681 let datetime: chrono::DateTime<chrono::Local> = time.into();
683 datetime.format("%Y-%m-%d").to_string()
684 }
685 }
686}
687
688#[cfg(test)]
689mod tests {
690 use super::*;
691 use crate::model::filesystem::StdFileSystem;
692
693 fn test_filesystem() -> Arc<dyn FileSystem + Send + Sync> {
694 Arc::new(StdFileSystem)
695 }
696
697 fn make_entry(name: &str, is_dir: bool) -> DirEntry {
698 DirEntry::new(
699 PathBuf::from(format!("/test/{}", name)),
700 name.to_string(),
701 if is_dir {
702 EntryType::Directory
703 } else {
704 EntryType::File
705 },
706 )
707 }
708
709 fn make_entry_with_size(name: &str, size: u64) -> DirEntry {
710 make_entry(name, false).with_metadata(crate::model::filesystem::FileMetadata::new(size))
711 }
712
713 #[test]
714 fn test_sort_by_name() {
715 let mut state = FileOpenState::new(PathBuf::from("/"), false, test_filesystem());
717 state.set_entries(vec![
718 make_entry("zebra.txt", false),
719 make_entry("alpha.txt", false),
720 make_entry("beta", true),
721 ]);
722
723 assert_eq!(state.entries[0].fs_entry.name, "beta"); assert_eq!(state.entries[1].fs_entry.name, "alpha.txt");
725 assert_eq!(state.entries[2].fs_entry.name, "zebra.txt");
726 }
727
728 #[test]
729 fn test_sort_by_size() {
730 let mut state = FileOpenState::new(PathBuf::from("/"), false, test_filesystem());
732 state.sort_mode = SortMode::Size;
733 state.set_entries(vec![
734 make_entry_with_size("big.txt", 1000),
735 make_entry_with_size("small.txt", 100),
736 make_entry_with_size("medium.txt", 500),
737 ]);
738
739 assert_eq!(state.entries[0].fs_entry.name, "small.txt");
740 assert_eq!(state.entries[1].fs_entry.name, "medium.txt");
741 assert_eq!(state.entries[2].fs_entry.name, "big.txt");
742 }
743
744 #[test]
745 fn test_filter() {
746 let mut state = FileOpenState::new(PathBuf::from("/"), false, test_filesystem());
748 state.set_entries(vec![
749 make_entry("foo.txt", false),
750 make_entry("bar.txt", false),
751 make_entry("foobar.txt", false),
752 ]);
753
754 state.apply_filter("foo");
755
756 assert_eq!(state.entries[0].fs_entry.name, "foo.txt");
760 assert!(state.entries[0].matches_filter);
761
762 assert_eq!(state.entries[1].fs_entry.name, "foobar.txt");
763 assert!(state.entries[1].matches_filter);
764
765 assert_eq!(state.entries[2].fs_entry.name, "bar.txt");
766 assert!(!state.entries[2].matches_filter);
767
768 assert_eq!(state.matching_count(), 2);
769 }
770
771 #[test]
772 fn test_filter_case_insensitive() {
773 let mut state = FileOpenState::new(PathBuf::from("/"), false, test_filesystem());
775 state.set_entries(vec![
776 make_entry("README.md", false),
777 make_entry("readme.txt", false),
778 make_entry("other.txt", false),
779 ]);
780
781 state.apply_filter("readme");
782
783 assert!(state.entries[0].matches_filter);
786 assert!(state.entries[1].matches_filter);
787
788 assert_eq!(state.entries[2].fs_entry.name, "other.txt");
789 assert!(!state.entries[2].matches_filter);
790 }
791
792 #[test]
793 fn test_hidden_files() {
794 let mut state = FileOpenState::new(PathBuf::from("/"), false, test_filesystem());
796 state.show_hidden = false;
797 state.set_entries(vec![
798 make_entry(".hidden", false),
799 make_entry("visible.txt", false),
800 ]);
801
802 assert_eq!(state.entries.len(), 1);
804 assert_eq!(state.entries[0].fs_entry.name, "visible.txt");
805 }
806
807 #[test]
808 fn test_format_size() {
809 assert_eq!(format_size(500), "500 B");
810 assert_eq!(format_size(1024), "1.0 KB");
811 assert_eq!(format_size(1536), "1.5 KB");
812 assert_eq!(format_size(1048576), "1.0 MB");
813 assert_eq!(format_size(1073741824), "1.0 GB");
814 }
815
816 #[test]
817 fn test_navigation() {
818 let mut state = FileOpenState::new(PathBuf::from("/"), false, test_filesystem());
820 state.set_entries(vec![
821 make_entry("a.txt", false),
822 make_entry("b.txt", false),
823 make_entry("c.txt", false),
824 ]);
825
826 assert_eq!(state.selected_index, None);
828
829 state.select_next();
831 assert_eq!(state.selected_index, Some(0));
832
833 state.select_next();
834 assert_eq!(state.selected_index, Some(1));
835
836 state.select_next();
837 assert_eq!(state.selected_index, Some(2));
838
839 state.select_next(); assert_eq!(state.selected_index, Some(2));
841
842 state.select_prev();
843 assert_eq!(state.selected_index, Some(1));
844
845 state.select_first();
846 assert_eq!(state.selected_index, Some(0));
847
848 state.select_last();
849 assert_eq!(state.selected_index, Some(2));
850 }
851
852 #[test]
853 fn test_fuzzy_filter() {
854 let mut state = FileOpenState::new(PathBuf::from("/"), false, test_filesystem());
856 state.set_entries(vec![
857 make_entry("command_registry.rs", false),
858 make_entry("commands.rs", false),
859 make_entry("keybindings.rs", false),
860 make_entry("mod.rs", false),
861 ]);
862
863 state.apply_filter("cmdreg");
865
866 assert!(state.entries[0].matches_filter);
868 assert_eq!(state.entries[0].fs_entry.name, "command_registry.rs");
869
870 assert_eq!(state.matching_count(), 1);
873 }
874
875 #[test]
876 fn test_fuzzy_filter_sparse_match() {
877 let mut state = FileOpenState::new(PathBuf::from("/"), false, test_filesystem());
879 state.set_entries(vec![
880 make_entry("Save File", false),
881 make_entry("Select All", false),
882 make_entry("something_else.txt", false),
883 ]);
884
885 state.apply_filter("sf");
887
888 assert_eq!(state.matching_count(), 1);
889 assert!(state.entries[0].matches_filter);
890 assert_eq!(state.entries[0].fs_entry.name, "Save File");
891 }
892}