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::services::fs::{FsEntry, FsEntryType};
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: FsEntry,
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<FsEntry>) {
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 = 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        // Add filtered entries
213        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        // No selection by default - user must type or navigate to select
230        self.selected_index = None;
231        self.scroll_offset = 0;
232    }
233
234    /// Set error state
235    pub fn set_error(&mut self, error: String) {
236        self.loading = false;
237        self.error = Some(error);
238        self.entries.clear();
239    }
240
241    /// Check if a filename is hidden (starts with .)
242    fn is_hidden(name: &str) -> bool {
243        name.starts_with('.')
244    }
245
246    /// Apply filter text to entries
247    /// When filter is active, entries are sorted by fuzzy match score (best matches first).
248    /// Non-matching entries are de-emphasized visually but stay at the bottom.
249    pub fn apply_filter(&mut self, filter: &str) {
250        self.filter = filter.to_string();
251        self.apply_filter_internal();
252
253        // When filter is non-empty, sort by match score (best matches first)
254        if !filter.is_empty() {
255            self.entries.sort_by(|a, b| {
256                // ".." always stays at top
257                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                // Matching entries before non-matching
268                match (a.matches_filter, b.matches_filter) {
269                    (true, false) => Ordering::Less,
270                    (false, true) => Ordering::Greater,
271                    (true, true) => {
272                        // Both match: sort by score descending (higher score = better match)
273                        b.match_score.cmp(&a.match_score)
274                    }
275                    (false, false) => {
276                        // Neither match: keep alphabetical order
277                        a.fs_entry
278                            .name
279                            .to_lowercase()
280                            .cmp(&b.fs_entry.name.to_lowercase())
281                    }
282                }
283            });
284
285            // Select first matching entry (skip "..")
286            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            // No filter: restore normal sort order and clear selection
298            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    /// Sort entries according to current sort mode
317    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            // ".." always stays at top
323            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            // Don't reorder based on filter match - just de-emphasize non-matching
333            // entries visually. Keep original sort order.
334
335            // Directories before files
336            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            // Apply sort mode
343            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    /// Set sort mode and re-sort
396    pub fn set_sort_mode(&mut self, mode: SortMode) {
397        if self.sort_mode == mode {
398            // Toggle direction if same mode
399            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    /// Toggle hidden files visibility
408    pub fn toggle_hidden(&mut self) {
409        self.show_hidden = !self.show_hidden;
410        // Need to reload directory to apply this change
411    }
412
413    /// Move selection up
414    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                    // No selection, select last entry
429                    self.selected_index = Some(self.entries.len() - 1);
430                    self.ensure_selected_visible();
431                }
432            }
433        }
434    }
435
436    /// Move selection down
437    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                    // No selection, select first entry
452                    self.selected_index = Some(0);
453                    self.ensure_selected_visible();
454                }
455            }
456        }
457    }
458
459    /// Page up
460    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    /// Page down
472    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    /// Jump to first entry
485    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    /// Jump to last entry
498    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    /// Ensure selected item is visible in viewport
513    fn ensure_selected_visible(&mut self) {
514        let Some(idx) = self.selected_index else {
515            return;
516        };
517        // This will be called with actual visible_rows from renderer
518        // For now, use a reasonable default
519        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    /// Update scroll offset based on visible rows
528    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    /// Switch between navigation and files sections
540    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    /// Get the currently selected entry (file or directory)
548    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    /// Get the currently selected shortcut
557    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    /// Get the path to open/navigate to based on current selection
566    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    /// Check if selected item is a directory
580    pub fn selected_is_dir(&self) -> bool {
581        match self.active_section {
582            FileOpenSection::Navigation => true, // Shortcuts are always directories
583            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    /// Count matching entries
592    pub fn matching_count(&self) -> usize {
593        self.entries.iter().filter(|e| e.matches_filter).count()
594    }
595
596    /// Get visible entries (for rendering)
597    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
604/// Format file size in human-readable form
605pub 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
621/// Format timestamp in relative or absolute form
622pub 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                // Format as date
637                let datetime: chrono::DateTime<chrono::Local> = time.into();
638                datetime.format("%Y-%m-%d").to_string()
639            }
640        }
641        Err(_) => {
642            // Time is in the future
643            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        // Use root path so no ".." entry is added
682        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"); // Dir first
690        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        // Use root path so no ".." entry is added
697        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        // Use root path so no ".." entry is added
713        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        // With fuzzy matching, matching entries are sorted by score and appear first
723        // foo.txt has a better score (starts with "foo") than foobar.txt
724        // Non-matching bar.txt appears last
725        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        // Use root path so no ".." entry is added
740        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        // Matching entries first (sorted by score), non-matching last
750        // Both README.md and readme.txt match with similar scores
751        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        // Use root path so no ".." entry is added
761        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        // Hidden file should be filtered out
769        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        // Use root path so no ".." entry is added
785        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        // Initially no selection
793        assert_eq!(state.selected_index, None);
794
795        // First down selects first entry
796        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(); // Should stay at last
806        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        // Use root path so no ".." entry is added
821        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        // Fuzzy match "cmdreg" should match "command_registry.rs"
830        state.apply_filter("cmdreg");
831
832        // command_registry.rs should match and be first
833        assert!(state.entries[0].matches_filter);
834        assert_eq!(state.entries[0].fs_entry.name, "command_registry.rs");
835
836        // commands.rs might also match "cmd" part
837        // Other files shouldn't match
838        assert_eq!(state.matching_count(), 1);
839    }
840
841    #[test]
842    fn test_fuzzy_filter_sparse_match() {
843        // Use root path so no ".." entry is added
844        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        // "sf" should match "Save File" (S and F)
852        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}