1use crate::input::fuzzy::fuzzy_match;
8use crate::model::filesystem::{DirEntry, EntryType};
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: DirEntry,
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<DirEntry>) {
196 let mut result: Vec<FileOpenEntry> = Vec::new();
197
198 if let Some(parent) = self.current_dir.parent() {
200 let parent_entry =
201 DirEntry::new(parent.to_path_buf(), "..".to_string(), EntryType::Directory);
202 result.push(FileOpenEntry {
203 fs_entry: parent_entry,
204 matches_filter: true,
205 match_score: 0,
206 });
207 }
208
209 result.extend(
211 entries
212 .into_iter()
213 .filter(|e| self.show_hidden || !Self::is_hidden(&e.name))
214 .map(|fs_entry| FileOpenEntry {
215 fs_entry,
216 matches_filter: true,
217 match_score: 0,
218 }),
219 );
220
221 self.entries = result;
222 self.loading = false;
223 self.error = None;
224 self.apply_filter_internal();
225 self.sort_entries();
226 self.selected_index = None;
228 self.scroll_offset = 0;
229 }
230
231 pub fn set_error(&mut self, error: String) {
233 self.loading = false;
234 self.error = Some(error);
235 self.entries.clear();
236 }
237
238 fn is_hidden(name: &str) -> bool {
240 name.starts_with('.')
241 }
242
243 pub fn apply_filter(&mut self, filter: &str) {
247 self.filter = filter.to_string();
248 self.apply_filter_internal();
249
250 if !filter.is_empty() {
252 self.entries.sort_by(|a, b| {
253 let a_is_parent = a.fs_entry.name == "..";
255 let b_is_parent = b.fs_entry.name == "..";
256
257 if a_is_parent && !b_is_parent {
258 return Ordering::Less;
259 }
260 if !a_is_parent && b_is_parent {
261 return Ordering::Greater;
262 }
263
264 match (a.matches_filter, b.matches_filter) {
266 (true, false) => Ordering::Less,
267 (false, true) => Ordering::Greater,
268 (true, true) => {
269 b.match_score.cmp(&a.match_score)
271 }
272 (false, false) => {
273 a.fs_entry
275 .name
276 .to_lowercase()
277 .cmp(&b.fs_entry.name.to_lowercase())
278 }
279 }
280 });
281
282 let first_match = self
284 .entries
285 .iter()
286 .position(|e| e.matches_filter && e.fs_entry.name != "..");
287 if let Some(idx) = first_match {
288 self.selected_index = Some(idx);
289 self.ensure_selected_visible();
290 } else {
291 self.selected_index = None;
292 }
293 } else {
294 self.sort_entries();
296 self.selected_index = None;
297 }
298 }
299
300 fn apply_filter_internal(&mut self) {
301 for entry in &mut self.entries {
302 if self.filter.is_empty() {
303 entry.matches_filter = true;
304 entry.match_score = 0;
305 } else {
306 let result = fuzzy_match(&self.filter, &entry.fs_entry.name);
307 entry.matches_filter = result.matched;
308 entry.match_score = result.score;
309 }
310 }
311 }
312
313 pub fn sort_entries(&mut self) {
315 let sort_mode = self.sort_mode;
316 let ascending = self.sort_ascending;
317
318 self.entries.sort_by(|a, b| {
319 let a_is_parent = a.fs_entry.name == "..";
321 let b_is_parent = b.fs_entry.name == "..";
322 match (a_is_parent, b_is_parent) {
323 (true, false) => return Ordering::Less,
324 (false, true) => return Ordering::Greater,
325 (true, true) => return Ordering::Equal,
326 _ => {}
327 }
328
329 match (a.fs_entry.is_dir(), b.fs_entry.is_dir()) {
334 (true, false) => return Ordering::Less,
335 (false, true) => return Ordering::Greater,
336 _ => {}
337 }
338
339 let ord = match sort_mode {
341 SortMode::Name => a
342 .fs_entry
343 .name
344 .to_lowercase()
345 .cmp(&b.fs_entry.name.to_lowercase()),
346 SortMode::Size => {
347 let a_size = a.fs_entry.metadata.as_ref().map(|m| m.size).unwrap_or(0);
348 let b_size = b.fs_entry.metadata.as_ref().map(|m| m.size).unwrap_or(0);
349 a_size.cmp(&b_size)
350 }
351 SortMode::Modified => {
352 let a_mod = a.fs_entry.metadata.as_ref().and_then(|m| m.modified);
353 let b_mod = b.fs_entry.metadata.as_ref().and_then(|m| m.modified);
354 match (a_mod, b_mod) {
355 (Some(a), Some(b)) => a.cmp(&b),
356 (Some(_), None) => Ordering::Less,
357 (None, Some(_)) => Ordering::Greater,
358 (None, None) => Ordering::Equal,
359 }
360 }
361 SortMode::Type => {
362 let a_ext = std::path::Path::new(&a.fs_entry.name)
363 .extension()
364 .and_then(|e| e.to_str())
365 .unwrap_or("");
366 let b_ext = std::path::Path::new(&b.fs_entry.name)
367 .extension()
368 .and_then(|e| e.to_str())
369 .unwrap_or("");
370 a_ext.to_lowercase().cmp(&b_ext.to_lowercase())
371 }
372 };
373
374 if ascending {
375 ord
376 } else {
377 ord.reverse()
378 }
379 });
380 }
381
382 pub fn set_sort_mode(&mut self, mode: SortMode) {
384 if self.sort_mode == mode {
385 self.sort_ascending = !self.sort_ascending;
387 } else {
388 self.sort_mode = mode;
389 self.sort_ascending = true;
390 }
391 self.sort_entries();
392 }
393
394 pub fn toggle_hidden(&mut self) {
396 self.show_hidden = !self.show_hidden;
397 }
399
400 pub fn select_prev(&mut self) {
402 match self.active_section {
403 FileOpenSection::Navigation => {
404 if self.selected_shortcut > 0 {
405 self.selected_shortcut -= 1;
406 }
407 }
408 FileOpenSection::Files => {
409 if let Some(idx) = self.selected_index {
410 if idx > 0 {
411 self.selected_index = Some(idx - 1);
412 self.ensure_selected_visible();
413 }
414 } else if !self.entries.is_empty() {
415 self.selected_index = Some(self.entries.len() - 1);
417 self.ensure_selected_visible();
418 }
419 }
420 }
421 }
422
423 pub fn select_next(&mut self) {
425 match self.active_section {
426 FileOpenSection::Navigation => {
427 if self.selected_shortcut + 1 < self.shortcuts.len() {
428 self.selected_shortcut += 1;
429 }
430 }
431 FileOpenSection::Files => {
432 if let Some(idx) = self.selected_index {
433 if idx + 1 < self.entries.len() {
434 self.selected_index = Some(idx + 1);
435 self.ensure_selected_visible();
436 }
437 } else if !self.entries.is_empty() {
438 self.selected_index = Some(0);
440 self.ensure_selected_visible();
441 }
442 }
443 }
444 }
445
446 pub fn page_up(&mut self, page_size: usize) {
448 if self.active_section == FileOpenSection::Files {
449 if let Some(idx) = self.selected_index {
450 self.selected_index = Some(idx.saturating_sub(page_size));
451 self.ensure_selected_visible();
452 } else if !self.entries.is_empty() {
453 self.selected_index = Some(0);
454 }
455 }
456 }
457
458 pub fn page_down(&mut self, page_size: usize) {
460 if self.active_section == FileOpenSection::Files {
461 if let Some(idx) = self.selected_index {
462 self.selected_index =
463 Some((idx + page_size).min(self.entries.len().saturating_sub(1)));
464 self.ensure_selected_visible();
465 } else if !self.entries.is_empty() {
466 self.selected_index = Some(self.entries.len().saturating_sub(1));
467 }
468 }
469 }
470
471 pub fn select_first(&mut self) {
473 match self.active_section {
474 FileOpenSection::Navigation => self.selected_shortcut = 0,
475 FileOpenSection::Files => {
476 if !self.entries.is_empty() {
477 self.selected_index = Some(0);
478 self.scroll_offset = 0;
479 }
480 }
481 }
482 }
483
484 pub fn select_last(&mut self) {
486 match self.active_section {
487 FileOpenSection::Navigation => {
488 self.selected_shortcut = self.shortcuts.len().saturating_sub(1);
489 }
490 FileOpenSection::Files => {
491 if !self.entries.is_empty() {
492 self.selected_index = Some(self.entries.len() - 1);
493 self.ensure_selected_visible();
494 }
495 }
496 }
497 }
498
499 fn ensure_selected_visible(&mut self) {
501 let Some(idx) = self.selected_index else {
502 return;
503 };
504 let visible_rows = 15;
507 if idx < self.scroll_offset {
508 self.scroll_offset = idx;
509 } else if idx >= self.scroll_offset + visible_rows {
510 self.scroll_offset = idx.saturating_sub(visible_rows - 1);
511 }
512 }
513
514 pub fn update_scroll_for_visible_rows(&mut self, visible_rows: usize) {
516 let Some(idx) = self.selected_index else {
517 return;
518 };
519 if idx < self.scroll_offset {
520 self.scroll_offset = idx;
521 } else if idx >= self.scroll_offset + visible_rows {
522 self.scroll_offset = idx.saturating_sub(visible_rows - 1);
523 }
524 }
525
526 pub fn switch_section(&mut self) {
528 self.active_section = match self.active_section {
529 FileOpenSection::Navigation => FileOpenSection::Files,
530 FileOpenSection::Files => FileOpenSection::Navigation,
531 };
532 }
533
534 pub fn selected_entry(&self) -> Option<&FileOpenEntry> {
536 if self.active_section == FileOpenSection::Files {
537 self.selected_index.and_then(|idx| self.entries.get(idx))
538 } else {
539 None
540 }
541 }
542
543 pub fn selected_shortcut_entry(&self) -> Option<&NavigationShortcut> {
545 if self.active_section == FileOpenSection::Navigation {
546 self.shortcuts.get(self.selected_shortcut)
547 } else {
548 None
549 }
550 }
551
552 pub fn get_selected_path(&self) -> Option<PathBuf> {
554 match self.active_section {
555 FileOpenSection::Navigation => self
556 .shortcuts
557 .get(self.selected_shortcut)
558 .map(|s| s.path.clone()),
559 FileOpenSection::Files => self
560 .selected_index
561 .and_then(|idx| self.entries.get(idx))
562 .map(|e| e.fs_entry.path.clone()),
563 }
564 }
565
566 pub fn selected_is_dir(&self) -> bool {
568 match self.active_section {
569 FileOpenSection::Navigation => true, FileOpenSection::Files => self
571 .selected_index
572 .and_then(|idx| self.entries.get(idx))
573 .map(|e| e.fs_entry.is_dir())
574 .unwrap_or(false),
575 }
576 }
577
578 pub fn matching_count(&self) -> usize {
580 self.entries.iter().filter(|e| e.matches_filter).count()
581 }
582
583 pub fn visible_entries(&self, max_rows: usize) -> &[FileOpenEntry] {
585 let start = self.scroll_offset;
586 let end = (start + max_rows).min(self.entries.len());
587 &self.entries[start..end]
588 }
589}
590
591pub fn format_size(size: u64) -> String {
593 const KB: u64 = 1024;
594 const MB: u64 = KB * 1024;
595 const GB: u64 = MB * 1024;
596
597 if size >= GB {
598 format!("{:.1} GB", size as f64 / GB as f64)
599 } else if size >= MB {
600 format!("{:.1} MB", size as f64 / MB as f64)
601 } else if size >= KB {
602 format!("{:.1} KB", size as f64 / KB as f64)
603 } else {
604 format!("{} B", size)
605 }
606}
607
608pub fn format_modified(time: SystemTime) -> String {
610 let now = SystemTime::now();
611 match now.duration_since(time) {
612 Ok(duration) => {
613 let secs = duration.as_secs();
614 if secs < 60 {
615 "just now".to_string()
616 } else if secs < 3600 {
617 format!("{} min ago", secs / 60)
618 } else if secs < 86400 {
619 format!("{} hr ago", secs / 3600)
620 } else if secs < 86400 * 7 {
621 format!("{} days ago", secs / 86400)
622 } else {
623 let datetime: chrono::DateTime<chrono::Local> = time.into();
625 datetime.format("%Y-%m-%d").to_string()
626 }
627 }
628 Err(_) => {
629 let datetime: chrono::DateTime<chrono::Local> = time.into();
631 datetime.format("%Y-%m-%d").to_string()
632 }
633 }
634}
635
636#[cfg(test)]
637mod tests {
638 use super::*;
639 fn make_entry(name: &str, is_dir: bool) -> DirEntry {
640 DirEntry::new(
641 PathBuf::from(format!("/test/{}", name)),
642 name.to_string(),
643 if is_dir {
644 EntryType::Directory
645 } else {
646 EntryType::File
647 },
648 )
649 }
650
651 fn make_entry_with_size(name: &str, size: u64) -> DirEntry {
652 make_entry(name, false).with_metadata(crate::model::filesystem::FileMetadata::new(size))
653 }
654
655 #[test]
656 fn test_sort_by_name() {
657 let mut state = FileOpenState::new(PathBuf::from("/"), false);
659 state.set_entries(vec![
660 make_entry("zebra.txt", false),
661 make_entry("alpha.txt", false),
662 make_entry("beta", true),
663 ]);
664
665 assert_eq!(state.entries[0].fs_entry.name, "beta"); assert_eq!(state.entries[1].fs_entry.name, "alpha.txt");
667 assert_eq!(state.entries[2].fs_entry.name, "zebra.txt");
668 }
669
670 #[test]
671 fn test_sort_by_size() {
672 let mut state = FileOpenState::new(PathBuf::from("/"), false);
674 state.sort_mode = SortMode::Size;
675 state.set_entries(vec![
676 make_entry_with_size("big.txt", 1000),
677 make_entry_with_size("small.txt", 100),
678 make_entry_with_size("medium.txt", 500),
679 ]);
680
681 assert_eq!(state.entries[0].fs_entry.name, "small.txt");
682 assert_eq!(state.entries[1].fs_entry.name, "medium.txt");
683 assert_eq!(state.entries[2].fs_entry.name, "big.txt");
684 }
685
686 #[test]
687 fn test_filter() {
688 let mut state = FileOpenState::new(PathBuf::from("/"), false);
690 state.set_entries(vec![
691 make_entry("foo.txt", false),
692 make_entry("bar.txt", false),
693 make_entry("foobar.txt", false),
694 ]);
695
696 state.apply_filter("foo");
697
698 assert_eq!(state.entries[0].fs_entry.name, "foo.txt");
702 assert!(state.entries[0].matches_filter);
703
704 assert_eq!(state.entries[1].fs_entry.name, "foobar.txt");
705 assert!(state.entries[1].matches_filter);
706
707 assert_eq!(state.entries[2].fs_entry.name, "bar.txt");
708 assert!(!state.entries[2].matches_filter);
709
710 assert_eq!(state.matching_count(), 2);
711 }
712
713 #[test]
714 fn test_filter_case_insensitive() {
715 let mut state = FileOpenState::new(PathBuf::from("/"), false);
717 state.set_entries(vec![
718 make_entry("README.md", false),
719 make_entry("readme.txt", false),
720 make_entry("other.txt", false),
721 ]);
722
723 state.apply_filter("readme");
724
725 assert!(state.entries[0].matches_filter);
728 assert!(state.entries[1].matches_filter);
729
730 assert_eq!(state.entries[2].fs_entry.name, "other.txt");
731 assert!(!state.entries[2].matches_filter);
732 }
733
734 #[test]
735 fn test_hidden_files() {
736 let mut state = FileOpenState::new(PathBuf::from("/"), false);
738 state.show_hidden = false;
739 state.set_entries(vec![
740 make_entry(".hidden", false),
741 make_entry("visible.txt", false),
742 ]);
743
744 assert_eq!(state.entries.len(), 1);
746 assert_eq!(state.entries[0].fs_entry.name, "visible.txt");
747 }
748
749 #[test]
750 fn test_format_size() {
751 assert_eq!(format_size(500), "500 B");
752 assert_eq!(format_size(1024), "1.0 KB");
753 assert_eq!(format_size(1536), "1.5 KB");
754 assert_eq!(format_size(1048576), "1.0 MB");
755 assert_eq!(format_size(1073741824), "1.0 GB");
756 }
757
758 #[test]
759 fn test_navigation() {
760 let mut state = FileOpenState::new(PathBuf::from("/"), false);
762 state.set_entries(vec![
763 make_entry("a.txt", false),
764 make_entry("b.txt", false),
765 make_entry("c.txt", false),
766 ]);
767
768 assert_eq!(state.selected_index, None);
770
771 state.select_next();
773 assert_eq!(state.selected_index, Some(0));
774
775 state.select_next();
776 assert_eq!(state.selected_index, Some(1));
777
778 state.select_next();
779 assert_eq!(state.selected_index, Some(2));
780
781 state.select_next(); assert_eq!(state.selected_index, Some(2));
783
784 state.select_prev();
785 assert_eq!(state.selected_index, Some(1));
786
787 state.select_first();
788 assert_eq!(state.selected_index, Some(0));
789
790 state.select_last();
791 assert_eq!(state.selected_index, Some(2));
792 }
793
794 #[test]
795 fn test_fuzzy_filter() {
796 let mut state = FileOpenState::new(PathBuf::from("/"), false);
798 state.set_entries(vec![
799 make_entry("command_registry.rs", false),
800 make_entry("commands.rs", false),
801 make_entry("keybindings.rs", false),
802 make_entry("mod.rs", false),
803 ]);
804
805 state.apply_filter("cmdreg");
807
808 assert!(state.entries[0].matches_filter);
810 assert_eq!(state.entries[0].fs_entry.name, "command_registry.rs");
811
812 assert_eq!(state.matching_count(), 1);
815 }
816
817 #[test]
818 fn test_fuzzy_filter_sparse_match() {
819 let mut state = FileOpenState::new(PathBuf::from("/"), false);
821 state.set_entries(vec![
822 make_entry("Save File", false),
823 make_entry("Select All", false),
824 make_entry("something_else.txt", false),
825 ]);
826
827 state.apply_filter("sf");
829
830 assert_eq!(state.matching_count(), 1);
831 assert!(state.entries[0].matches_filter);
832 assert_eq!(state.entries[0].fs_entry.name, "Save File");
833 }
834}