1use crate::input::fuzzy::fuzzy_match;
8use crate::services::fs::{FsEntry, FsEntryType};
9use rust_i18n::t;
10use std::cmp::Ordering;
11use std::path::{Path, PathBuf};
12use std::time::SystemTime;
13
14#[derive(Debug, Clone)]
16pub struct FileOpenEntry {
17 pub fs_entry: FsEntry,
19 pub matches_filter: bool,
21 pub match_score: i32,
23}
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
27pub enum SortMode {
28 #[default]
29 Name,
30 Size,
31 Modified,
32 Type,
33}
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
37pub enum FileOpenSection {
38 Navigation,
40 #[default]
42 Files,
43}
44
45#[derive(Debug, Clone)]
47pub struct NavigationShortcut {
48 pub label: String,
50 pub path: PathBuf,
52 pub description: String,
54}
55
56#[derive(Debug, Clone)]
58pub struct FileOpenState {
59 pub current_dir: PathBuf,
61
62 pub entries: Vec<FileOpenEntry>,
64
65 pub loading: bool,
67
68 pub error: Option<String>,
70
71 pub sort_mode: SortMode,
73
74 pub sort_ascending: bool,
76
77 pub selected_index: Option<usize>,
79
80 pub scroll_offset: usize,
82
83 pub active_section: FileOpenSection,
85
86 pub filter: String,
88
89 pub shortcuts: Vec<NavigationShortcut>,
91
92 pub selected_shortcut: usize,
94
95 pub show_hidden: bool,
97}
98
99impl FileOpenState {
100 pub fn new(dir: PathBuf, show_hidden: bool) -> Self {
102 let shortcuts = Self::build_shortcuts(&dir);
103 Self {
104 current_dir: dir,
105 entries: Vec::new(),
106 loading: true,
107 error: None,
108 sort_mode: SortMode::Name,
109 sort_ascending: true,
110 selected_index: None,
111 scroll_offset: 0,
112 active_section: FileOpenSection::Files,
113 filter: String::new(),
114 shortcuts,
115 selected_shortcut: 0,
116 show_hidden,
117 }
118 }
119
120 fn build_shortcuts(current_dir: &Path) -> Vec<NavigationShortcut> {
122 let mut shortcuts = Vec::new();
123
124 if let Some(parent) = current_dir.parent() {
126 shortcuts.push(NavigationShortcut {
127 label: "..".to_string(),
128 path: parent.to_path_buf(),
129 description: t!("file_browser.parent_dir").to_string(),
130 });
131 }
132
133 #[cfg(unix)]
135 {
136 shortcuts.push(NavigationShortcut {
137 label: "/".to_string(),
138 path: PathBuf::from("/"),
139 description: t!("file_browser.root_dir").to_string(),
140 });
141 }
142
143 if let Some(home) = dirs::home_dir() {
145 shortcuts.push(NavigationShortcut {
146 label: "~".to_string(),
147 path: home,
148 description: t!("file_browser.home_dir").to_string(),
149 });
150 }
151
152 if let Some(docs) = dirs::document_dir() {
154 shortcuts.push(NavigationShortcut {
155 label: t!("file_browser.documents").to_string(),
156 path: docs,
157 description: t!("file_browser.documents_folder").to_string(),
158 });
159 }
160
161 if let Some(downloads) = dirs::download_dir() {
163 shortcuts.push(NavigationShortcut {
164 label: t!("file_browser.downloads").to_string(),
165 path: downloads,
166 description: t!("file_browser.downloads_folder").to_string(),
167 });
168 }
169
170 #[cfg(windows)]
172 {
173 for letter in b'A'..=b'Z' {
174 let path = PathBuf::from(format!("{}:\\", letter as char));
175 if path.exists() {
176 shortcuts.push(NavigationShortcut {
177 label: format!("{}:", letter as char),
178 path,
179 description: t!("file_browser.drive").to_string(),
180 });
181 }
182 }
183 }
184
185 shortcuts
186 }
187
188 pub fn update_shortcuts(&mut self) {
190 self.shortcuts = Self::build_shortcuts(&self.current_dir);
191 self.selected_shortcut = 0;
192 }
193
194 pub fn set_entries(&mut self, entries: Vec<FsEntry>) {
196 let mut result: Vec<FileOpenEntry> = Vec::new();
197
198 if let Some(parent) = self.current_dir.parent() {
200 let parent_entry = FsEntry::new(
201 parent.to_path_buf(),
202 "..".to_string(),
203 FsEntryType::Directory,
204 );
205 result.push(FileOpenEntry {
206 fs_entry: parent_entry,
207 matches_filter: true,
208 match_score: 0,
209 });
210 }
211
212 result.extend(
214 entries
215 .into_iter()
216 .filter(|e| self.show_hidden || !Self::is_hidden(&e.name))
217 .map(|fs_entry| FileOpenEntry {
218 fs_entry,
219 matches_filter: true,
220 match_score: 0,
221 }),
222 );
223
224 self.entries = result;
225 self.loading = false;
226 self.error = None;
227 self.apply_filter_internal();
228 self.sort_entries();
229 self.selected_index = None;
231 self.scroll_offset = 0;
232 }
233
234 pub fn set_error(&mut self, error: String) {
236 self.loading = false;
237 self.error = Some(error);
238 self.entries.clear();
239 }
240
241 fn is_hidden(name: &str) -> bool {
243 name.starts_with('.')
244 }
245
246 pub fn apply_filter(&mut self, filter: &str) {
250 self.filter = filter.to_string();
251 self.apply_filter_internal();
252
253 if !filter.is_empty() {
255 self.entries.sort_by(|a, b| {
256 let a_is_parent = a.fs_entry.name == "..";
258 let b_is_parent = b.fs_entry.name == "..";
259
260 if a_is_parent && !b_is_parent {
261 return Ordering::Less;
262 }
263 if !a_is_parent && b_is_parent {
264 return Ordering::Greater;
265 }
266
267 match (a.matches_filter, b.matches_filter) {
269 (true, false) => Ordering::Less,
270 (false, true) => Ordering::Greater,
271 (true, true) => {
272 b.match_score.cmp(&a.match_score)
274 }
275 (false, false) => {
276 a.fs_entry
278 .name
279 .to_lowercase()
280 .cmp(&b.fs_entry.name.to_lowercase())
281 }
282 }
283 });
284
285 let first_match = self
287 .entries
288 .iter()
289 .position(|e| e.matches_filter && e.fs_entry.name != "..");
290 if let Some(idx) = first_match {
291 self.selected_index = Some(idx);
292 self.ensure_selected_visible();
293 } else {
294 self.selected_index = None;
295 }
296 } else {
297 self.sort_entries();
299 self.selected_index = None;
300 }
301 }
302
303 fn apply_filter_internal(&mut self) {
304 for entry in &mut self.entries {
305 if self.filter.is_empty() {
306 entry.matches_filter = true;
307 entry.match_score = 0;
308 } else {
309 let result = fuzzy_match(&self.filter, &entry.fs_entry.name);
310 entry.matches_filter = result.matched;
311 entry.match_score = result.score;
312 }
313 }
314 }
315
316 pub fn sort_entries(&mut self) {
318 let sort_mode = self.sort_mode;
319 let ascending = self.sort_ascending;
320
321 self.entries.sort_by(|a, b| {
322 let a_is_parent = a.fs_entry.name == "..";
324 let b_is_parent = b.fs_entry.name == "..";
325 match (a_is_parent, b_is_parent) {
326 (true, false) => return Ordering::Less,
327 (false, true) => return Ordering::Greater,
328 (true, true) => return Ordering::Equal,
329 _ => {}
330 }
331
332 match (a.fs_entry.is_dir(), b.fs_entry.is_dir()) {
337 (true, false) => return Ordering::Less,
338 (false, true) => return Ordering::Greater,
339 _ => {}
340 }
341
342 let ord = match sort_mode {
344 SortMode::Name => a
345 .fs_entry
346 .name
347 .to_lowercase()
348 .cmp(&b.fs_entry.name.to_lowercase()),
349 SortMode::Size => {
350 let a_size = a
351 .fs_entry
352 .metadata
353 .as_ref()
354 .and_then(|m| m.size)
355 .unwrap_or(0);
356 let b_size = b
357 .fs_entry
358 .metadata
359 .as_ref()
360 .and_then(|m| m.size)
361 .unwrap_or(0);
362 a_size.cmp(&b_size)
363 }
364 SortMode::Modified => {
365 let a_mod = a.fs_entry.metadata.as_ref().and_then(|m| m.modified);
366 let b_mod = b.fs_entry.metadata.as_ref().and_then(|m| m.modified);
367 match (a_mod, b_mod) {
368 (Some(a), Some(b)) => a.cmp(&b),
369 (Some(_), None) => Ordering::Less,
370 (None, Some(_)) => Ordering::Greater,
371 (None, None) => Ordering::Equal,
372 }
373 }
374 SortMode::Type => {
375 let a_ext = std::path::Path::new(&a.fs_entry.name)
376 .extension()
377 .and_then(|e| e.to_str())
378 .unwrap_or("");
379 let b_ext = std::path::Path::new(&b.fs_entry.name)
380 .extension()
381 .and_then(|e| e.to_str())
382 .unwrap_or("");
383 a_ext.to_lowercase().cmp(&b_ext.to_lowercase())
384 }
385 };
386
387 if ascending {
388 ord
389 } else {
390 ord.reverse()
391 }
392 });
393 }
394
395 pub fn set_sort_mode(&mut self, mode: SortMode) {
397 if self.sort_mode == mode {
398 self.sort_ascending = !self.sort_ascending;
400 } else {
401 self.sort_mode = mode;
402 self.sort_ascending = true;
403 }
404 self.sort_entries();
405 }
406
407 pub fn toggle_hidden(&mut self) {
409 self.show_hidden = !self.show_hidden;
410 }
412
413 pub fn select_prev(&mut self) {
415 match self.active_section {
416 FileOpenSection::Navigation => {
417 if self.selected_shortcut > 0 {
418 self.selected_shortcut -= 1;
419 }
420 }
421 FileOpenSection::Files => {
422 if let Some(idx) = self.selected_index {
423 if idx > 0 {
424 self.selected_index = Some(idx - 1);
425 self.ensure_selected_visible();
426 }
427 } else if !self.entries.is_empty() {
428 self.selected_index = Some(self.entries.len() - 1);
430 self.ensure_selected_visible();
431 }
432 }
433 }
434 }
435
436 pub fn select_next(&mut self) {
438 match self.active_section {
439 FileOpenSection::Navigation => {
440 if self.selected_shortcut + 1 < self.shortcuts.len() {
441 self.selected_shortcut += 1;
442 }
443 }
444 FileOpenSection::Files => {
445 if let Some(idx) = self.selected_index {
446 if idx + 1 < self.entries.len() {
447 self.selected_index = Some(idx + 1);
448 self.ensure_selected_visible();
449 }
450 } else if !self.entries.is_empty() {
451 self.selected_index = Some(0);
453 self.ensure_selected_visible();
454 }
455 }
456 }
457 }
458
459 pub fn page_up(&mut self, page_size: usize) {
461 if self.active_section == FileOpenSection::Files {
462 if let Some(idx) = self.selected_index {
463 self.selected_index = Some(idx.saturating_sub(page_size));
464 self.ensure_selected_visible();
465 } else if !self.entries.is_empty() {
466 self.selected_index = Some(0);
467 }
468 }
469 }
470
471 pub fn page_down(&mut self, page_size: usize) {
473 if self.active_section == FileOpenSection::Files {
474 if let Some(idx) = self.selected_index {
475 self.selected_index =
476 Some((idx + page_size).min(self.entries.len().saturating_sub(1)));
477 self.ensure_selected_visible();
478 } else if !self.entries.is_empty() {
479 self.selected_index = Some(self.entries.len().saturating_sub(1));
480 }
481 }
482 }
483
484 pub fn select_first(&mut self) {
486 match self.active_section {
487 FileOpenSection::Navigation => self.selected_shortcut = 0,
488 FileOpenSection::Files => {
489 if !self.entries.is_empty() {
490 self.selected_index = Some(0);
491 self.scroll_offset = 0;
492 }
493 }
494 }
495 }
496
497 pub fn select_last(&mut self) {
499 match self.active_section {
500 FileOpenSection::Navigation => {
501 self.selected_shortcut = self.shortcuts.len().saturating_sub(1);
502 }
503 FileOpenSection::Files => {
504 if !self.entries.is_empty() {
505 self.selected_index = Some(self.entries.len() - 1);
506 self.ensure_selected_visible();
507 }
508 }
509 }
510 }
511
512 fn ensure_selected_visible(&mut self) {
514 let Some(idx) = self.selected_index else {
515 return;
516 };
517 let visible_rows = 15;
520 if idx < self.scroll_offset {
521 self.scroll_offset = idx;
522 } else if idx >= self.scroll_offset + visible_rows {
523 self.scroll_offset = idx.saturating_sub(visible_rows - 1);
524 }
525 }
526
527 pub fn update_scroll_for_visible_rows(&mut self, visible_rows: usize) {
529 let Some(idx) = self.selected_index else {
530 return;
531 };
532 if idx < self.scroll_offset {
533 self.scroll_offset = idx;
534 } else if idx >= self.scroll_offset + visible_rows {
535 self.scroll_offset = idx.saturating_sub(visible_rows - 1);
536 }
537 }
538
539 pub fn switch_section(&mut self) {
541 self.active_section = match self.active_section {
542 FileOpenSection::Navigation => FileOpenSection::Files,
543 FileOpenSection::Files => FileOpenSection::Navigation,
544 };
545 }
546
547 pub fn selected_entry(&self) -> Option<&FileOpenEntry> {
549 if self.active_section == FileOpenSection::Files {
550 self.selected_index.and_then(|idx| self.entries.get(idx))
551 } else {
552 None
553 }
554 }
555
556 pub fn selected_shortcut_entry(&self) -> Option<&NavigationShortcut> {
558 if self.active_section == FileOpenSection::Navigation {
559 self.shortcuts.get(self.selected_shortcut)
560 } else {
561 None
562 }
563 }
564
565 pub fn get_selected_path(&self) -> Option<PathBuf> {
567 match self.active_section {
568 FileOpenSection::Navigation => self
569 .shortcuts
570 .get(self.selected_shortcut)
571 .map(|s| s.path.clone()),
572 FileOpenSection::Files => self
573 .selected_index
574 .and_then(|idx| self.entries.get(idx))
575 .map(|e| e.fs_entry.path.clone()),
576 }
577 }
578
579 pub fn selected_is_dir(&self) -> bool {
581 match self.active_section {
582 FileOpenSection::Navigation => true, FileOpenSection::Files => self
584 .selected_index
585 .and_then(|idx| self.entries.get(idx))
586 .map(|e| e.fs_entry.is_dir())
587 .unwrap_or(false),
588 }
589 }
590
591 pub fn matching_count(&self) -> usize {
593 self.entries.iter().filter(|e| e.matches_filter).count()
594 }
595
596 pub fn visible_entries(&self, max_rows: usize) -> &[FileOpenEntry] {
598 let start = self.scroll_offset;
599 let end = (start + max_rows).min(self.entries.len());
600 &self.entries[start..end]
601 }
602}
603
604pub fn format_size(size: u64) -> String {
606 const KB: u64 = 1024;
607 const MB: u64 = KB * 1024;
608 const GB: u64 = MB * 1024;
609
610 if size >= GB {
611 format!("{:.1} GB", size as f64 / GB as f64)
612 } else if size >= MB {
613 format!("{:.1} MB", size as f64 / MB as f64)
614 } else if size >= KB {
615 format!("{:.1} KB", size as f64 / KB as f64)
616 } else {
617 format!("{} B", size)
618 }
619}
620
621pub fn format_modified(time: SystemTime) -> String {
623 let now = SystemTime::now();
624 match now.duration_since(time) {
625 Ok(duration) => {
626 let secs = duration.as_secs();
627 if secs < 60 {
628 "just now".to_string()
629 } else if secs < 3600 {
630 format!("{} min ago", secs / 60)
631 } else if secs < 86400 {
632 format!("{} hr ago", secs / 3600)
633 } else if secs < 86400 * 7 {
634 format!("{} days ago", secs / 86400)
635 } else {
636 let datetime: chrono::DateTime<chrono::Local> = time.into();
638 datetime.format("%Y-%m-%d").to_string()
639 }
640 }
641 Err(_) => {
642 let datetime: chrono::DateTime<chrono::Local> = time.into();
644 datetime.format("%Y-%m-%d").to_string()
645 }
646 }
647}
648
649#[cfg(test)]
650mod tests {
651 use super::*;
652 use crate::services::fs::{FsEntryType, FsMetadata};
653
654 fn make_entry(name: &str, is_dir: bool) -> FsEntry {
655 FsEntry {
656 path: PathBuf::from(format!("/test/{}", name)),
657 name: name.to_string(),
658 entry_type: if is_dir {
659 FsEntryType::Directory
660 } else {
661 FsEntryType::File
662 },
663 metadata: None,
664 symlink_target_is_dir: false,
665 }
666 }
667
668 fn make_entry_with_size(name: &str, size: u64) -> FsEntry {
669 let mut entry = make_entry(name, false);
670 entry.metadata = Some(FsMetadata {
671 size: Some(size),
672 modified: None,
673 is_hidden: false,
674 is_readonly: false,
675 });
676 entry
677 }
678
679 #[test]
680 fn test_sort_by_name() {
681 let mut state = FileOpenState::new(PathBuf::from("/"), false);
683 state.set_entries(vec![
684 make_entry("zebra.txt", false),
685 make_entry("alpha.txt", false),
686 make_entry("beta", true),
687 ]);
688
689 assert_eq!(state.entries[0].fs_entry.name, "beta"); assert_eq!(state.entries[1].fs_entry.name, "alpha.txt");
691 assert_eq!(state.entries[2].fs_entry.name, "zebra.txt");
692 }
693
694 #[test]
695 fn test_sort_by_size() {
696 let mut state = FileOpenState::new(PathBuf::from("/"), false);
698 state.sort_mode = SortMode::Size;
699 state.set_entries(vec![
700 make_entry_with_size("big.txt", 1000),
701 make_entry_with_size("small.txt", 100),
702 make_entry_with_size("medium.txt", 500),
703 ]);
704
705 assert_eq!(state.entries[0].fs_entry.name, "small.txt");
706 assert_eq!(state.entries[1].fs_entry.name, "medium.txt");
707 assert_eq!(state.entries[2].fs_entry.name, "big.txt");
708 }
709
710 #[test]
711 fn test_filter() {
712 let mut state = FileOpenState::new(PathBuf::from("/"), false);
714 state.set_entries(vec![
715 make_entry("foo.txt", false),
716 make_entry("bar.txt", false),
717 make_entry("foobar.txt", false),
718 ]);
719
720 state.apply_filter("foo");
721
722 assert_eq!(state.entries[0].fs_entry.name, "foo.txt");
726 assert!(state.entries[0].matches_filter);
727
728 assert_eq!(state.entries[1].fs_entry.name, "foobar.txt");
729 assert!(state.entries[1].matches_filter);
730
731 assert_eq!(state.entries[2].fs_entry.name, "bar.txt");
732 assert!(!state.entries[2].matches_filter);
733
734 assert_eq!(state.matching_count(), 2);
735 }
736
737 #[test]
738 fn test_filter_case_insensitive() {
739 let mut state = FileOpenState::new(PathBuf::from("/"), false);
741 state.set_entries(vec![
742 make_entry("README.md", false),
743 make_entry("readme.txt", false),
744 make_entry("other.txt", false),
745 ]);
746
747 state.apply_filter("readme");
748
749 assert!(state.entries[0].matches_filter);
752 assert!(state.entries[1].matches_filter);
753
754 assert_eq!(state.entries[2].fs_entry.name, "other.txt");
755 assert!(!state.entries[2].matches_filter);
756 }
757
758 #[test]
759 fn test_hidden_files() {
760 let mut state = FileOpenState::new(PathBuf::from("/"), false);
762 state.show_hidden = false;
763 state.set_entries(vec![
764 make_entry(".hidden", false),
765 make_entry("visible.txt", false),
766 ]);
767
768 assert_eq!(state.entries.len(), 1);
770 assert_eq!(state.entries[0].fs_entry.name, "visible.txt");
771 }
772
773 #[test]
774 fn test_format_size() {
775 assert_eq!(format_size(500), "500 B");
776 assert_eq!(format_size(1024), "1.0 KB");
777 assert_eq!(format_size(1536), "1.5 KB");
778 assert_eq!(format_size(1048576), "1.0 MB");
779 assert_eq!(format_size(1073741824), "1.0 GB");
780 }
781
782 #[test]
783 fn test_navigation() {
784 let mut state = FileOpenState::new(PathBuf::from("/"), false);
786 state.set_entries(vec![
787 make_entry("a.txt", false),
788 make_entry("b.txt", false),
789 make_entry("c.txt", false),
790 ]);
791
792 assert_eq!(state.selected_index, None);
794
795 state.select_next();
797 assert_eq!(state.selected_index, Some(0));
798
799 state.select_next();
800 assert_eq!(state.selected_index, Some(1));
801
802 state.select_next();
803 assert_eq!(state.selected_index, Some(2));
804
805 state.select_next(); assert_eq!(state.selected_index, Some(2));
807
808 state.select_prev();
809 assert_eq!(state.selected_index, Some(1));
810
811 state.select_first();
812 assert_eq!(state.selected_index, Some(0));
813
814 state.select_last();
815 assert_eq!(state.selected_index, Some(2));
816 }
817
818 #[test]
819 fn test_fuzzy_filter() {
820 let mut state = FileOpenState::new(PathBuf::from("/"), false);
822 state.set_entries(vec![
823 make_entry("command_registry.rs", false),
824 make_entry("commands.rs", false),
825 make_entry("keybindings.rs", false),
826 make_entry("mod.rs", false),
827 ]);
828
829 state.apply_filter("cmdreg");
831
832 assert!(state.entries[0].matches_filter);
834 assert_eq!(state.entries[0].fs_entry.name, "command_registry.rs");
835
836 assert_eq!(state.matching_count(), 1);
839 }
840
841 #[test]
842 fn test_fuzzy_filter_sparse_match() {
843 let mut state = FileOpenState::new(PathBuf::from("/"), false);
845 state.set_entries(vec![
846 make_entry("Save File", false),
847 make_entry("Select All", false),
848 make_entry("something_else.txt", false),
849 ]);
850
851 state.apply_filter("sf");
853
854 assert_eq!(state.matching_count(), 1);
855 assert!(state.entries[0].matches_filter);
856 assert_eq!(state.entries[0].fs_entry.name, "Save File");
857 }
858}