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};
9use rust_i18n::t;
10use std::cmp::Ordering;
11use std::path::{Path, PathBuf};
12use std::time::SystemTime;
13
14/// A file entry in the browser with filter match state
15#[derive(Debug, Clone)]
16pub struct FileOpenEntry {
17    /// The filesystem entry
18    pub fs_entry: DirEntry,
19    /// Whether this entry matches the current filter
20    pub matches_filter: bool,
21    /// Fuzzy match score (higher is better match, used for sorting when filter is active)
22    pub match_score: i32,
23}
24
25/// Sort mode for file list
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
27pub enum SortMode {
28    #[default]
29    Name,
30    Size,
31    Modified,
32    Type,
33}
34
35/// Which section of the file browser is active
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
37pub enum FileOpenSection {
38    /// Navigation shortcuts (parent, root, home)
39    Navigation,
40    /// Main file list
41    #[default]
42    Files,
43}
44
45/// Navigation shortcut entry
46#[derive(Debug, Clone)]
47pub struct NavigationShortcut {
48    /// Display label (e.g., "~", "..", "/")
49    pub label: String,
50    /// Full path to navigate to
51    pub path: PathBuf,
52    /// Description (e.g., "Home directory")
53    pub description: String,
54}
55
56/// State for the file open dialog
57#[derive(Debug, Clone)]
58pub struct FileOpenState {
59    /// Current directory being browsed
60    pub current_dir: PathBuf,
61
62    /// Directory entries with metadata
63    pub entries: Vec<FileOpenEntry>,
64
65    /// Whether directory is currently loading
66    pub loading: bool,
67
68    /// Error message if directory load failed
69    pub error: Option<String>,
70
71    /// Current sort mode
72    pub sort_mode: SortMode,
73
74    /// Sort direction (true = ascending)
75    pub sort_ascending: bool,
76
77    /// Selected index in the current section (None = no selection)
78    pub selected_index: Option<usize>,
79
80    /// Scroll offset for file list
81    pub scroll_offset: usize,
82
83    /// Which section is currently active
84    pub active_section: FileOpenSection,
85
86    /// Filter text (from prompt input)
87    pub filter: String,
88
89    /// Navigation shortcuts
90    pub shortcuts: Vec<NavigationShortcut>,
91
92    /// Selected shortcut index (when in Navigation section)
93    pub selected_shortcut: usize,
94
95    /// Whether to show hidden files
96    pub show_hidden: bool,
97}
98
99impl FileOpenState {
100    /// Create a new file open state for the given directory
101    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    /// Build navigation shortcuts for the given directory
121    fn build_shortcuts(current_dir: &Path) -> Vec<NavigationShortcut> {
122        let mut shortcuts = Vec::new();
123
124        // Parent directory
125        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        // Root directory
134        #[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        // Home directory
144        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        // Documents directory
153        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        // Downloads directory
162        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        // Windows: Add drive letters
171        #[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    /// Update shortcuts when directory changes
189    pub fn update_shortcuts(&mut self) {
190        self.shortcuts = Self::build_shortcuts(&self.current_dir);
191        self.selected_shortcut = 0;
192    }
193
194    /// Set entries from filesystem and apply initial sort
195    pub fn set_entries(&mut self, entries: Vec<DirEntry>) {
196        let mut result: Vec<FileOpenEntry> = Vec::new();
197
198        // Add ".." entry for parent directory navigation (unless at root)
199        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        // Add filtered entries
210        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        // No selection by default - user must type or navigate to select
227        self.selected_index = None;
228        self.scroll_offset = 0;
229    }
230
231    /// Set error state
232    pub fn set_error(&mut self, error: String) {
233        self.loading = false;
234        self.error = Some(error);
235        self.entries.clear();
236    }
237
238    /// Check if a filename is hidden (starts with .)
239    fn is_hidden(name: &str) -> bool {
240        name.starts_with('.')
241    }
242
243    /// Apply filter text to entries
244    /// When filter is active, entries are sorted by fuzzy match score (best matches first).
245    /// Non-matching entries are de-emphasized visually but stay at the bottom.
246    pub fn apply_filter(&mut self, filter: &str) {
247        self.filter = filter.to_string();
248        self.apply_filter_internal();
249
250        // When filter is non-empty, sort by match score (best matches first)
251        if !filter.is_empty() {
252            self.entries.sort_by(|a, b| {
253                // ".." always stays at top
254                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                // Matching entries before non-matching
265                match (a.matches_filter, b.matches_filter) {
266                    (true, false) => Ordering::Less,
267                    (false, true) => Ordering::Greater,
268                    (true, true) => {
269                        // Both match: sort by score descending (higher score = better match)
270                        b.match_score.cmp(&a.match_score)
271                    }
272                    (false, false) => {
273                        // Neither match: keep alphabetical order
274                        a.fs_entry
275                            .name
276                            .to_lowercase()
277                            .cmp(&b.fs_entry.name.to_lowercase())
278                    }
279                }
280            });
281
282            // Select first matching entry (skip "..")
283            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            // No filter: restore normal sort order and clear selection
295            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    /// Sort entries according to current sort mode
314    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            // ".." always stays at top
320            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            // Don't reorder based on filter match - just de-emphasize non-matching
330            // entries visually. Keep original sort order.
331
332            // Directories before files
333            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            // Apply sort mode
340            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    /// Set sort mode and re-sort
383    pub fn set_sort_mode(&mut self, mode: SortMode) {
384        if self.sort_mode == mode {
385            // Toggle direction if same mode
386            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    /// Toggle hidden files visibility
395    pub fn toggle_hidden(&mut self) {
396        self.show_hidden = !self.show_hidden;
397        // Need to reload directory to apply this change
398    }
399
400    /// Move selection up
401    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                    // No selection, select last entry
416                    self.selected_index = Some(self.entries.len() - 1);
417                    self.ensure_selected_visible();
418                }
419            }
420        }
421    }
422
423    /// Move selection down
424    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                    // No selection, select first entry
439                    self.selected_index = Some(0);
440                    self.ensure_selected_visible();
441                }
442            }
443        }
444    }
445
446    /// Page up
447    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    /// Page down
459    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    /// Jump to first entry
472    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    /// Jump to last entry
485    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    /// Ensure selected item is visible in viewport
500    fn ensure_selected_visible(&mut self) {
501        let Some(idx) = self.selected_index else {
502            return;
503        };
504        // This will be called with actual visible_rows from renderer
505        // For now, use a reasonable default
506        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    /// Update scroll offset based on visible rows
515    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    /// Switch between navigation and files sections
527    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    /// Get the currently selected entry (file or directory)
535    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    /// Get the currently selected shortcut
544    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    /// Get the path to open/navigate to based on current selection
553    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    /// Check if selected item is a directory
567    pub fn selected_is_dir(&self) -> bool {
568        match self.active_section {
569            FileOpenSection::Navigation => true, // Shortcuts are always directories
570            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    /// Count matching entries
579    pub fn matching_count(&self) -> usize {
580        self.entries.iter().filter(|e| e.matches_filter).count()
581    }
582
583    /// Get visible entries (for rendering)
584    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
591/// Format file size in human-readable form
592pub 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
608/// Format timestamp in relative or absolute form
609pub 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                // Format as date
624                let datetime: chrono::DateTime<chrono::Local> = time.into();
625                datetime.format("%Y-%m-%d").to_string()
626            }
627        }
628        Err(_) => {
629            // Time is in the future
630            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        // Use root path so no ".." entry is added
658        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"); // Dir first
666        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        // Use root path so no ".." entry is added
673        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        // Use root path so no ".." entry is added
689        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        // With fuzzy matching, matching entries are sorted by score and appear first
699        // foo.txt has a better score (starts with "foo") than foobar.txt
700        // Non-matching bar.txt appears last
701        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        // Use root path so no ".." entry is added
716        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        // Matching entries first (sorted by score), non-matching last
726        // Both README.md and readme.txt match with similar scores
727        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        // Use root path so no ".." entry is added
737        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        // Hidden file should be filtered out
745        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        // Use root path so no ".." entry is added
761        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        // Initially no selection
769        assert_eq!(state.selected_index, None);
770
771        // First down selects first entry
772        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(); // Should stay at last
782        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        // Use root path so no ".." entry is added
797        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        // Fuzzy match "cmdreg" should match "command_registry.rs"
806        state.apply_filter("cmdreg");
807
808        // command_registry.rs should match and be first
809        assert!(state.entries[0].matches_filter);
810        assert_eq!(state.entries[0].fs_entry.name, "command_registry.rs");
811
812        // commands.rs might also match "cmd" part
813        // Other files shouldn't match
814        assert_eq!(state.matching_count(), 1);
815    }
816
817    #[test]
818    fn test_fuzzy_filter_sparse_match() {
819        // Use root path so no ".." entry is added
820        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        // "sf" should match "Save File" (S and F)
828        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}