Skip to main content

fresh/app/
file_open.rs

1//! File open dialog state and logic
2//!
3//! This module provides a plugin-free file browser for the Open File command.
4//! It renders a structured popup above the prompt with sortable columns,
5//! navigation shortcuts, and filtering.
6
7use 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/// A file entry in the browser with filter match state
16#[derive(Debug, Clone)]
17pub struct FileOpenEntry {
18    /// The filesystem entry
19    pub fs_entry: DirEntry,
20    /// Whether this entry matches the current filter
21    pub matches_filter: bool,
22    /// Fuzzy match score (higher is better match, used for sorting when filter is active)
23    pub match_score: i32,
24}
25
26/// Sort mode for file list
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
28pub enum SortMode {
29    #[default]
30    Name,
31    Size,
32    Modified,
33    Type,
34}
35
36/// Which section of the file browser is active
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
38pub enum FileOpenSection {
39    /// Navigation shortcuts (parent, root, home)
40    Navigation,
41    /// Main file list
42    #[default]
43    Files,
44}
45
46/// Navigation shortcut entry
47#[derive(Debug, Clone)]
48pub struct NavigationShortcut {
49    /// Display label (e.g., "~", "..", "/")
50    pub label: String,
51    /// Full path to navigate to
52    pub path: PathBuf,
53    /// Description (e.g., "Home directory")
54    pub description: String,
55}
56
57/// State for the file open dialog
58#[derive(Clone)]
59pub struct FileOpenState {
60    /// Current directory being browsed
61    pub current_dir: PathBuf,
62
63    /// Directory entries with metadata
64    pub entries: Vec<FileOpenEntry>,
65
66    /// Whether directory is currently loading
67    pub loading: bool,
68
69    /// Error message if directory load failed
70    pub error: Option<String>,
71
72    /// Current sort mode
73    pub sort_mode: SortMode,
74
75    /// Sort direction (true = ascending)
76    pub sort_ascending: bool,
77
78    /// Selected index in the current section (None = no selection)
79    pub selected_index: Option<usize>,
80
81    /// Scroll offset for file list
82    pub scroll_offset: usize,
83
84    /// Which section is currently active
85    pub active_section: FileOpenSection,
86
87    /// Filter text (from prompt input)
88    pub filter: String,
89
90    /// Navigation shortcuts
91    pub shortcuts: Vec<NavigationShortcut>,
92
93    /// Selected shortcut index (when in Navigation section)
94    pub selected_shortcut: usize,
95
96    /// Whether to show hidden files
97    pub show_hidden: bool,
98
99    /// Whether to auto-detect encoding when opening files (true by default)
100    /// When false, user will be prompted to select encoding after file selection
101    pub detect_encoding: bool,
102
103    /// Filesystem for checking path existence (used for drive letter detection on Windows)
104    filesystem: Arc<dyn FileSystem + Send + Sync>,
105}
106
107impl FileOpenState {
108    /// Create a new file open state for the given directory.
109    /// Only builds basic shortcuts synchronously; async shortcuts (documents, downloads,
110    /// Windows drives) should be loaded separately via the async bridge.
111    pub fn new(
112        dir: PathBuf,
113        show_hidden: bool,
114        filesystem: Arc<dyn FileSystem + Send + Sync>,
115    ) -> Self {
116        // Only build sync shortcuts immediately - async shortcuts are loaded in background
117        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    /// Build basic navigation shortcuts synchronously (no slow filesystem checks).
138    /// These shortcuts are available immediately when the dialog opens.
139    fn build_shortcuts_sync(
140        current_dir: &Path,
141        filesystem: &dyn FileSystem,
142    ) -> Vec<NavigationShortcut> {
143        let mut shortcuts = Vec::new();
144
145        // Parent directory
146        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        // Root directory
155        #[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        // Home directory - use filesystem trait for remote filesystem support
165        // home_dir() is typically fast (reads env vars or registry)
166        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    /// Build additional shortcuts that require filesystem existence checks.
178    /// This is called asynchronously to avoid blocking the UI.
179    /// On Windows, this includes drive letter detection which can hang on unreachable network drives.
180    /// See issue #903.
181    pub fn build_shortcuts_async(filesystem: &dyn FileSystem) -> Vec<NavigationShortcut> {
182        let mut shortcuts = Vec::new();
183
184        // Documents directory - check existence via filesystem trait
185        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        // Downloads directory - check existence via filesystem trait
196        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        // Windows: Add drive letters
207        // Note: This uses the FileSystem trait's exists() method to allow mocking in tests.
208        // On Windows with unreachable network drives, exists() can block for network timeout.
209        // This is now called asynchronously to prevent UI freezing (issue #903).
210        #[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    /// Merge asynchronously-loaded shortcuts into the shortcuts list.
228    /// Called when async shortcut loading completes.
229    pub fn merge_async_shortcuts(&mut self, async_shortcuts: Vec<NavigationShortcut>) {
230        // Append async shortcuts to the existing list
231        self.shortcuts.extend(async_shortcuts);
232    }
233
234    /// Update shortcuts when directory changes (sync part only).
235    /// Async shortcuts should be loaded separately via load_file_open_shortcuts_async.
236    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    /// Set entries from filesystem and apply initial sort
242    pub fn set_entries(&mut self, entries: Vec<DirEntry>) {
243        let mut result: Vec<FileOpenEntry> = Vec::new();
244
245        // Add ".." entry for parent directory navigation (unless at root)
246        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        // Add filtered entries
257        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        // No selection by default - user must type or navigate to select
274        self.selected_index = None;
275        self.scroll_offset = 0;
276    }
277
278    /// Set error state
279    pub fn set_error(&mut self, error: String) {
280        self.loading = false;
281        self.error = Some(error);
282        self.entries.clear();
283    }
284
285    /// Check if a filename is hidden (starts with .)
286    fn is_hidden(name: &str) -> bool {
287        name.starts_with('.')
288    }
289
290    /// Apply filter text to entries
291    /// When filter is active, entries are sorted by fuzzy match score (best matches first).
292    /// Non-matching entries are de-emphasized visually but stay at the bottom.
293    pub fn apply_filter(&mut self, filter: &str) {
294        self.filter = filter.to_string();
295        self.apply_filter_internal();
296
297        // When filter is non-empty, sort by match score (best matches first)
298        if !filter.is_empty() {
299            self.entries.sort_by(|a, b| {
300                // ".." always stays at top
301                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                // Matching entries before non-matching
312                match (a.matches_filter, b.matches_filter) {
313                    (true, false) => Ordering::Less,
314                    (false, true) => Ordering::Greater,
315                    (true, true) => {
316                        // Both match: sort by score descending (higher score = better match)
317                        b.match_score.cmp(&a.match_score)
318                    }
319                    (false, false) => {
320                        // Neither match: keep alphabetical order
321                        a.fs_entry
322                            .name
323                            .to_lowercase()
324                            .cmp(&b.fs_entry.name.to_lowercase())
325                    }
326                }
327            });
328
329            // Select first matching entry (skip "..")
330            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            // No filter: restore normal sort order and clear selection
342            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    /// Sort entries according to current sort mode
361    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            // ".." always stays at top
367            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            // Don't reorder based on filter match - just de-emphasize non-matching
377            // entries visually. Keep original sort order.
378
379            // Directories before files
380            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            // Apply sort mode
387            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    /// Set sort mode and re-sort
430    pub fn set_sort_mode(&mut self, mode: SortMode) {
431        if self.sort_mode == mode {
432            // Toggle direction if same mode
433            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    /// Toggle hidden files visibility
442    pub fn toggle_hidden(&mut self) {
443        self.show_hidden = !self.show_hidden;
444        // Need to reload directory to apply this change
445    }
446
447    /// Toggle encoding detection mode
448    pub fn toggle_detect_encoding(&mut self) {
449        self.detect_encoding = !self.detect_encoding;
450    }
451
452    /// Move selection up
453    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                    // No selection, select last entry
468                    self.selected_index = Some(self.entries.len() - 1);
469                    self.ensure_selected_visible();
470                }
471            }
472        }
473    }
474
475    /// Move selection down
476    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                    // No selection, select first entry
491                    self.selected_index = Some(0);
492                    self.ensure_selected_visible();
493                }
494            }
495        }
496    }
497
498    /// Page up
499    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    /// Page down
511    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    /// Jump to first entry
524    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    /// Jump to last entry
537    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    /// Ensure selected item is visible in viewport
552    fn ensure_selected_visible(&mut self) {
553        let Some(idx) = self.selected_index else {
554            return;
555        };
556        // This will be called with actual visible_rows from renderer
557        // For now, use a reasonable default
558        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    /// Update scroll offset based on visible rows
567    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    /// Switch between navigation and files sections
579    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    /// Get the currently selected entry (file or directory)
587    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    /// Get the currently selected shortcut
596    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    /// Get the path to open/navigate to based on current selection
605    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    /// Check if selected item is a directory
619    pub fn selected_is_dir(&self) -> bool {
620        match self.active_section {
621            FileOpenSection::Navigation => true, // Shortcuts are always directories
622            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    /// Count matching entries
631    pub fn matching_count(&self) -> usize {
632        self.entries.iter().filter(|e| e.matches_filter).count()
633    }
634
635    /// Get visible entries (for rendering)
636    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
643/// Format file size in human-readable form
644pub 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
660/// Format timestamp in relative or absolute form
661pub 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                // Format as date
676                let datetime: chrono::DateTime<chrono::Local> = time.into();
677                datetime.format("%Y-%m-%d").to_string()
678            }
679        }
680        Err(_) => {
681            // Time is in the future
682            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        // Use root path so no ".." entry is added
716        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"); // Dir first
724        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        // Use root path so no ".." entry is added
731        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        // Use root path so no ".." entry is added
747        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        // With fuzzy matching, matching entries are sorted by score and appear first
757        // foo.txt has a better score (starts with "foo") than foobar.txt
758        // Non-matching bar.txt appears last
759        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        // Use root path so no ".." entry is added
774        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        // Matching entries first (sorted by score), non-matching last
784        // Both README.md and readme.txt match with similar scores
785        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        // Use root path so no ".." entry is added
795        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        // Hidden file should be filtered out
803        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        // Use root path so no ".." entry is added
819        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        // Initially no selection
827        assert_eq!(state.selected_index, None);
828
829        // First down selects first entry
830        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(); // Should stay at last
840        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        // Use root path so no ".." entry is added
855        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        // Fuzzy match "cmdreg" should match "command_registry.rs"
864        state.apply_filter("cmdreg");
865
866        // command_registry.rs should match and be first
867        assert!(state.entries[0].matches_filter);
868        assert_eq!(state.entries[0].fs_entry.name, "command_registry.rs");
869
870        // commands.rs might also match "cmd" part
871        // Other files shouldn't match
872        assert_eq!(state.matching_count(), 1);
873    }
874
875    #[test]
876    fn test_fuzzy_filter_sparse_match() {
877        // Use root path so no ".." entry is added
878        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        // "sf" should match "Save File" (S and F)
886        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}