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    /// Number of file rows visible in the viewport, as reported by the
85    /// renderer on the previous frame. Used by selection actions to clamp
86    /// `scroll_offset` to the actual viewport instead of a fixed default —
87    /// the renderer is the only place that knows the real viewport height.
88    pub last_visible_rows: usize,
89
90    /// Which section is currently active
91    pub active_section: FileOpenSection,
92
93    /// Filter text (from prompt input)
94    pub filter: String,
95
96    /// Navigation shortcuts
97    pub shortcuts: Vec<NavigationShortcut>,
98
99    /// Selected shortcut index (when in Navigation section)
100    pub selected_shortcut: usize,
101
102    /// Whether to show hidden files
103    pub show_hidden: bool,
104
105    /// Whether to auto-detect encoding when opening files (true by default)
106    /// When false, user will be prompted to select encoding after file selection
107    pub detect_encoding: bool,
108
109    /// Filesystem for checking path existence (used for drive letter detection on Windows)
110    filesystem: Arc<dyn FileSystem + Send + Sync>,
111}
112
113impl FileOpenState {
114    /// Create a new file open state for the given directory.
115    /// Only builds basic shortcuts synchronously; async shortcuts (documents, downloads,
116    /// Windows drives) should be loaded separately via the async bridge.
117    pub fn new(
118        dir: PathBuf,
119        show_hidden: bool,
120        filesystem: Arc<dyn FileSystem + Send + Sync>,
121    ) -> Self {
122        // Only build sync shortcuts immediately - async shortcuts are loaded in background
123        let shortcuts = Self::build_shortcuts_sync(&dir, &*filesystem);
124        Self {
125            current_dir: dir,
126            entries: Vec::new(),
127            loading: true,
128            error: None,
129            sort_mode: SortMode::Name,
130            sort_ascending: true,
131            selected_index: None,
132            scroll_offset: 0,
133            last_visible_rows: 0,
134            active_section: FileOpenSection::Files,
135            filter: String::new(),
136            shortcuts,
137            selected_shortcut: 0,
138            show_hidden,
139            detect_encoding: true,
140            filesystem,
141        }
142    }
143
144    /// Build basic navigation shortcuts synchronously (no slow filesystem checks).
145    /// These shortcuts are available immediately when the dialog opens.
146    fn build_shortcuts_sync(
147        current_dir: &Path,
148        filesystem: &dyn FileSystem,
149    ) -> Vec<NavigationShortcut> {
150        let mut shortcuts = Vec::new();
151
152        // Parent directory
153        if let Some(parent) = current_dir.parent() {
154            shortcuts.push(NavigationShortcut {
155                label: "..".to_string(),
156                path: parent.to_path_buf(),
157                description: t!("file_browser.parent_dir").to_string(),
158            });
159        }
160
161        // Root directory
162        #[cfg(unix)]
163        {
164            shortcuts.push(NavigationShortcut {
165                label: "/".to_string(),
166                path: PathBuf::from("/"),
167                description: t!("file_browser.root_dir").to_string(),
168            });
169        }
170
171        // Home directory - use filesystem trait for remote filesystem support
172        // home_dir() is typically fast (reads env vars or registry)
173        if let Ok(home) = filesystem.home_dir() {
174            shortcuts.push(NavigationShortcut {
175                label: "~".to_string(),
176                path: home,
177                description: t!("file_browser.home_dir").to_string(),
178            });
179        }
180
181        shortcuts
182    }
183
184    /// Build additional shortcuts that require filesystem existence checks.
185    /// This is called asynchronously to avoid blocking the UI.
186    /// On Windows, this includes drive letter detection which can hang on unreachable network drives.
187    /// See issue #903.
188    pub fn build_shortcuts_async(filesystem: &dyn FileSystem) -> Vec<NavigationShortcut> {
189        let mut shortcuts = Vec::new();
190
191        // Documents directory - check existence via filesystem trait
192        if let Some(docs) = dirs::document_dir() {
193            if filesystem.exists(&docs) {
194                shortcuts.push(NavigationShortcut {
195                    label: t!("file_browser.documents").to_string(),
196                    path: docs,
197                    description: t!("file_browser.documents_folder").to_string(),
198                });
199            }
200        }
201
202        // Downloads directory - check existence via filesystem trait
203        if let Some(downloads) = dirs::download_dir() {
204            if filesystem.exists(&downloads) {
205                shortcuts.push(NavigationShortcut {
206                    label: t!("file_browser.downloads").to_string(),
207                    path: downloads,
208                    description: t!("file_browser.downloads_folder").to_string(),
209                });
210            }
211        }
212
213        // Windows: Add drive letters
214        // Note: This uses the FileSystem trait's exists() method to allow mocking in tests.
215        // On Windows with unreachable network drives, exists() can block for network timeout.
216        // This is now called asynchronously to prevent UI freezing (issue #903).
217        #[cfg(windows)]
218        {
219            for letter in b'A'..=b'Z' {
220                let path = PathBuf::from(format!("{}:\\", letter as char));
221                if filesystem.exists(&path) {
222                    shortcuts.push(NavigationShortcut {
223                        label: format!("{}:", letter as char),
224                        path,
225                        description: t!("file_browser.drive").to_string(),
226                    });
227                }
228            }
229        }
230
231        shortcuts
232    }
233
234    /// Merge asynchronously-loaded shortcuts into the shortcuts list.
235    /// Called when async shortcut loading completes.
236    pub fn merge_async_shortcuts(&mut self, async_shortcuts: Vec<NavigationShortcut>) {
237        // Append async shortcuts to the existing list
238        self.shortcuts.extend(async_shortcuts);
239    }
240
241    /// Update shortcuts when directory changes (sync part only).
242    /// Async shortcuts should be loaded separately via load_file_open_shortcuts_async.
243    pub fn update_shortcuts(&mut self) {
244        self.shortcuts = Self::build_shortcuts_sync(&self.current_dir, &*self.filesystem);
245        self.selected_shortcut = 0;
246    }
247
248    /// Set entries from filesystem and apply initial sort
249    pub fn set_entries(&mut self, entries: Vec<DirEntry>) {
250        let mut result: Vec<FileOpenEntry> = Vec::new();
251
252        // Add ".." entry for parent directory navigation (unless at root)
253        if let Some(parent) = self.current_dir.parent() {
254            let parent_entry =
255                DirEntry::new(parent.to_path_buf(), "..".to_string(), EntryType::Directory);
256            result.push(FileOpenEntry {
257                fs_entry: parent_entry,
258                matches_filter: true,
259                match_score: 0,
260            });
261        }
262
263        // Add filtered entries
264        result.extend(
265            entries
266                .into_iter()
267                .filter(|e| self.show_hidden || !Self::is_hidden(&e.name))
268                .map(|fs_entry| FileOpenEntry {
269                    fs_entry,
270                    matches_filter: true,
271                    match_score: 0,
272                }),
273        );
274
275        self.entries = result;
276        self.loading = false;
277        self.error = None;
278        self.apply_filter_internal();
279        self.sort_entries();
280        // No selection by default - user must type or navigate to select
281        self.selected_index = None;
282        self.scroll_offset = 0;
283    }
284
285    /// Set error state
286    pub fn set_error(&mut self, error: String) {
287        self.loading = false;
288        self.error = Some(error);
289        self.entries.clear();
290    }
291
292    /// Check if a filename is hidden (starts with .)
293    fn is_hidden(name: &str) -> bool {
294        name.starts_with('.')
295    }
296
297    /// Apply filter text to entries
298    /// When filter is active, entries are sorted by fuzzy match score (best matches first).
299    /// Non-matching entries are de-emphasized visually but stay at the bottom.
300    pub fn apply_filter(&mut self, filter: &str) {
301        self.filter = filter.to_string();
302        self.apply_filter_internal();
303
304        // When filter is non-empty, sort by match score (best matches first)
305        if !filter.is_empty() {
306            self.entries.sort_by(|a, b| {
307                // ".." always stays at top
308                let a_is_parent = a.fs_entry.name == "..";
309                let b_is_parent = b.fs_entry.name == "..";
310
311                if a_is_parent && !b_is_parent {
312                    return Ordering::Less;
313                }
314                if !a_is_parent && b_is_parent {
315                    return Ordering::Greater;
316                }
317
318                // Matching entries before non-matching
319                match (a.matches_filter, b.matches_filter) {
320                    (true, false) => Ordering::Less,
321                    (false, true) => Ordering::Greater,
322                    (true, true) => {
323                        // Both match: sort by score descending (higher score = better match)
324                        b.match_score.cmp(&a.match_score)
325                    }
326                    (false, false) => {
327                        // Neither match: keep alphabetical order
328                        a.fs_entry
329                            .name
330                            .to_lowercase()
331                            .cmp(&b.fs_entry.name.to_lowercase())
332                    }
333                }
334            });
335
336            // Select first matching entry (skip "..")
337            let first_match = self
338                .entries
339                .iter()
340                .position(|e| e.matches_filter && e.fs_entry.name != "..");
341            if let Some(idx) = first_match {
342                self.selected_index = Some(idx);
343                self.ensure_selected_visible();
344            } else {
345                self.selected_index = None;
346            }
347        } else {
348            // No filter: restore normal sort order and clear selection
349            self.sort_entries();
350            self.selected_index = None;
351        }
352    }
353
354    fn apply_filter_internal(&mut self) {
355        for entry in &mut self.entries {
356            if self.filter.is_empty() {
357                entry.matches_filter = true;
358                entry.match_score = 0;
359            } else {
360                let result = fuzzy_match(&self.filter, &entry.fs_entry.name);
361                entry.matches_filter = result.matched;
362                entry.match_score = result.score;
363            }
364        }
365    }
366
367    /// Sort entries according to current sort mode
368    pub fn sort_entries(&mut self) {
369        let sort_mode = self.sort_mode;
370        let ascending = self.sort_ascending;
371
372        self.entries.sort_by(|a, b| {
373            // ".." always stays at top
374            let a_is_parent = a.fs_entry.name == "..";
375            let b_is_parent = b.fs_entry.name == "..";
376            match (a_is_parent, b_is_parent) {
377                (true, false) => return Ordering::Less,
378                (false, true) => return Ordering::Greater,
379                (true, true) => return Ordering::Equal,
380                _ => {}
381            }
382
383            // Don't reorder based on filter match - just de-emphasize non-matching
384            // entries visually. Keep original sort order.
385
386            // Directories before files
387            match (a.fs_entry.is_dir(), b.fs_entry.is_dir()) {
388                (true, false) => return Ordering::Less,
389                (false, true) => return Ordering::Greater,
390                _ => {}
391            }
392
393            // Apply sort mode
394            let ord = match sort_mode {
395                SortMode::Name => a
396                    .fs_entry
397                    .name
398                    .to_lowercase()
399                    .cmp(&b.fs_entry.name.to_lowercase()),
400                SortMode::Size => {
401                    let a_size = a.fs_entry.metadata.as_ref().map(|m| m.size).unwrap_or(0);
402                    let b_size = b.fs_entry.metadata.as_ref().map(|m| m.size).unwrap_or(0);
403                    a_size.cmp(&b_size)
404                }
405                SortMode::Modified => {
406                    let a_mod = a.fs_entry.metadata.as_ref().and_then(|m| m.modified);
407                    let b_mod = b.fs_entry.metadata.as_ref().and_then(|m| m.modified);
408                    match (a_mod, b_mod) {
409                        (Some(a), Some(b)) => a.cmp(&b),
410                        (Some(_), None) => Ordering::Less,
411                        (None, Some(_)) => Ordering::Greater,
412                        (None, None) => Ordering::Equal,
413                    }
414                }
415                SortMode::Type => {
416                    let a_ext = std::path::Path::new(&a.fs_entry.name)
417                        .extension()
418                        .and_then(|e| e.to_str())
419                        .unwrap_or("");
420                    let b_ext = std::path::Path::new(&b.fs_entry.name)
421                        .extension()
422                        .and_then(|e| e.to_str())
423                        .unwrap_or("");
424                    a_ext.to_lowercase().cmp(&b_ext.to_lowercase())
425                }
426            };
427
428            if ascending {
429                ord
430            } else {
431                ord.reverse()
432            }
433        });
434    }
435
436    /// Set sort mode and re-sort
437    pub fn set_sort_mode(&mut self, mode: SortMode) {
438        if self.sort_mode == mode {
439            // Toggle direction if same mode
440            self.sort_ascending = !self.sort_ascending;
441        } else {
442            self.sort_mode = mode;
443            self.sort_ascending = true;
444        }
445        self.sort_entries();
446    }
447
448    /// Toggle hidden files visibility
449    pub fn toggle_hidden(&mut self) {
450        self.show_hidden = !self.show_hidden;
451        // Need to reload directory to apply this change
452    }
453
454    /// Toggle encoding detection mode
455    pub fn toggle_detect_encoding(&mut self) {
456        self.detect_encoding = !self.detect_encoding;
457    }
458
459    /// Move selection up
460    pub fn select_prev(&mut self) {
461        match self.active_section {
462            FileOpenSection::Navigation => {
463                if self.selected_shortcut > 0 {
464                    self.selected_shortcut -= 1;
465                }
466            }
467            FileOpenSection::Files => {
468                if let Some(idx) = self.selected_index {
469                    if idx > 0 {
470                        self.selected_index = Some(idx - 1);
471                        self.ensure_selected_visible();
472                    }
473                } else if !self.entries.is_empty() {
474                    // No selection, select last entry
475                    self.selected_index = Some(self.entries.len() - 1);
476                    self.ensure_selected_visible();
477                }
478            }
479        }
480    }
481
482    /// Move selection down
483    pub fn select_next(&mut self) {
484        match self.active_section {
485            FileOpenSection::Navigation => {
486                if self.selected_shortcut + 1 < self.shortcuts.len() {
487                    self.selected_shortcut += 1;
488                }
489            }
490            FileOpenSection::Files => {
491                if let Some(idx) = self.selected_index {
492                    if idx + 1 < self.entries.len() {
493                        self.selected_index = Some(idx + 1);
494                        self.ensure_selected_visible();
495                    }
496                } else if !self.entries.is_empty() {
497                    // No selection, select first entry
498                    self.selected_index = Some(0);
499                    self.ensure_selected_visible();
500                }
501            }
502        }
503    }
504
505    /// Page up
506    pub fn page_up(&mut self, page_size: usize) {
507        if self.active_section == FileOpenSection::Files {
508            if let Some(idx) = self.selected_index {
509                self.selected_index = Some(idx.saturating_sub(page_size));
510                self.ensure_selected_visible();
511            } else if !self.entries.is_empty() {
512                self.selected_index = Some(0);
513            }
514        }
515    }
516
517    /// Page down
518    pub fn page_down(&mut self, page_size: usize) {
519        if self.active_section == FileOpenSection::Files {
520            if let Some(idx) = self.selected_index {
521                self.selected_index =
522                    Some((idx + page_size).min(self.entries.len().saturating_sub(1)));
523                self.ensure_selected_visible();
524            } else if !self.entries.is_empty() {
525                self.selected_index = Some(self.entries.len().saturating_sub(1));
526            }
527        }
528    }
529
530    /// Jump to first entry
531    pub fn select_first(&mut self) {
532        match self.active_section {
533            FileOpenSection::Navigation => self.selected_shortcut = 0,
534            FileOpenSection::Files => {
535                if !self.entries.is_empty() {
536                    self.selected_index = Some(0);
537                    self.scroll_offset = 0;
538                }
539            }
540        }
541    }
542
543    /// Jump to last entry
544    pub fn select_last(&mut self) {
545        match self.active_section {
546            FileOpenSection::Navigation => {
547                self.selected_shortcut = self.shortcuts.len().saturating_sub(1);
548            }
549            FileOpenSection::Files => {
550                if !self.entries.is_empty() {
551                    self.selected_index = Some(self.entries.len() - 1);
552                    self.ensure_selected_visible();
553                }
554            }
555        }
556    }
557
558    /// Ensure selected item is visible in viewport, using the most recent
559    /// viewport height reported by the renderer. Called from input handlers,
560    /// where the actual viewport height isn't known directly.
561    fn ensure_selected_visible(&mut self) {
562        self.clamp_scroll_to_selection();
563    }
564
565    /// Reconcile `scroll_offset` with the actual viewport height. The
566    /// renderer calls this once per frame; selection actions call
567    /// `ensure_selected_visible` which uses the cached value.
568    pub fn update_scroll_for_visible_rows(&mut self, visible_rows: usize) {
569        self.last_visible_rows = visible_rows;
570        self.clamp_scroll_to_selection();
571    }
572
573    /// Clamp `scroll_offset` so the selection stays within the viewport
574    /// `[scroll_offset, scroll_offset + last_visible_rows)`, and so that we
575    /// never scroll past the last entry.
576    fn clamp_scroll_to_selection(&mut self) {
577        let visible_rows = self.last_visible_rows;
578        if visible_rows == 0 {
579            return;
580        }
581        if let Some(idx) = self.selected_index {
582            if idx < self.scroll_offset {
583                self.scroll_offset = idx;
584            } else if idx >= self.scroll_offset + visible_rows {
585                self.scroll_offset = idx + 1 - visible_rows;
586            }
587        }
588        let max_offset = self.entries.len().saturating_sub(visible_rows);
589        if self.scroll_offset > max_offset {
590            self.scroll_offset = max_offset;
591        }
592    }
593
594    /// Switch between navigation and files sections
595    pub fn switch_section(&mut self) {
596        self.active_section = match self.active_section {
597            FileOpenSection::Navigation => FileOpenSection::Files,
598            FileOpenSection::Files => FileOpenSection::Navigation,
599        };
600    }
601
602    /// Get the currently selected entry (file or directory)
603    pub fn selected_entry(&self) -> Option<&FileOpenEntry> {
604        if self.active_section == FileOpenSection::Files {
605            self.selected_index.and_then(|idx| self.entries.get(idx))
606        } else {
607            None
608        }
609    }
610
611    /// Get the currently selected shortcut
612    pub fn selected_shortcut_entry(&self) -> Option<&NavigationShortcut> {
613        if self.active_section == FileOpenSection::Navigation {
614            self.shortcuts.get(self.selected_shortcut)
615        } else {
616            None
617        }
618    }
619
620    /// Get the path to open/navigate to based on current selection
621    pub fn get_selected_path(&self) -> Option<PathBuf> {
622        match self.active_section {
623            FileOpenSection::Navigation => self
624                .shortcuts
625                .get(self.selected_shortcut)
626                .map(|s| s.path.clone()),
627            FileOpenSection::Files => self
628                .selected_index
629                .and_then(|idx| self.entries.get(idx))
630                .map(|e| e.fs_entry.path.clone()),
631        }
632    }
633
634    /// Check if selected item is a directory
635    pub fn selected_is_dir(&self) -> bool {
636        match self.active_section {
637            FileOpenSection::Navigation => true, // Shortcuts are always directories
638            FileOpenSection::Files => self
639                .selected_index
640                .and_then(|idx| self.entries.get(idx))
641                .map(|e| e.fs_entry.is_dir())
642                .unwrap_or(false),
643        }
644    }
645
646    /// Count matching entries
647    pub fn matching_count(&self) -> usize {
648        self.entries.iter().filter(|e| e.matches_filter).count()
649    }
650
651    /// Get visible entries (for rendering)
652    pub fn visible_entries(&self, max_rows: usize) -> &[FileOpenEntry] {
653        let start = self.scroll_offset;
654        let end = (start + max_rows).min(self.entries.len());
655        &self.entries[start..end]
656    }
657}
658
659/// Format file size in human-readable form
660pub fn format_size(size: u64) -> String {
661    const KB: u64 = 1024;
662    const MB: u64 = KB * 1024;
663    const GB: u64 = MB * 1024;
664
665    if size >= GB {
666        format!("{:.1} GB", size as f64 / GB as f64)
667    } else if size >= MB {
668        format!("{:.1} MB", size as f64 / MB as f64)
669    } else if size >= KB {
670        format!("{:.1} KB", size as f64 / KB as f64)
671    } else {
672        format!("{} B", size)
673    }
674}
675
676/// Format timestamp in relative or absolute form
677pub fn format_modified(time: SystemTime) -> String {
678    let now = SystemTime::now();
679    match now.duration_since(time) {
680        Ok(duration) => {
681            let secs = duration.as_secs();
682            if secs < 60 {
683                "just now".to_string()
684            } else if secs < 3600 {
685                format!("{} min ago", secs / 60)
686            } else if secs < 86400 {
687                format!("{} hr ago", secs / 3600)
688            } else if secs < 86400 * 7 {
689                format!("{} days ago", secs / 86400)
690            } else {
691                // Format as date
692                let datetime: chrono::DateTime<chrono::Local> = time.into();
693                datetime.format("%Y-%m-%d").to_string()
694            }
695        }
696        Err(_) => {
697            // Time is in the future
698            let datetime: chrono::DateTime<chrono::Local> = time.into();
699            datetime.format("%Y-%m-%d").to_string()
700        }
701    }
702}
703
704#[cfg(test)]
705mod tests {
706    use super::*;
707    use crate::model::filesystem::StdFileSystem;
708
709    fn test_filesystem() -> Arc<dyn FileSystem + Send + Sync> {
710        Arc::new(StdFileSystem)
711    }
712
713    fn make_entry(name: &str, is_dir: bool) -> DirEntry {
714        DirEntry::new(
715            PathBuf::from(format!("/test/{}", name)),
716            name.to_string(),
717            if is_dir {
718                EntryType::Directory
719            } else {
720                EntryType::File
721            },
722        )
723    }
724
725    fn make_entry_with_size(name: &str, size: u64) -> DirEntry {
726        make_entry(name, false).with_metadata(crate::model::filesystem::FileMetadata::new(size))
727    }
728
729    #[test]
730    fn test_sort_by_name() {
731        // Use root path so no ".." entry is added
732        let mut state = FileOpenState::new(PathBuf::from("/"), false, test_filesystem());
733        state.set_entries(vec![
734            make_entry("zebra.txt", false),
735            make_entry("alpha.txt", false),
736            make_entry("beta", true),
737        ]);
738
739        assert_eq!(state.entries[0].fs_entry.name, "beta"); // Dir first
740        assert_eq!(state.entries[1].fs_entry.name, "alpha.txt");
741        assert_eq!(state.entries[2].fs_entry.name, "zebra.txt");
742    }
743
744    #[test]
745    fn test_sort_by_size() {
746        // Use root path so no ".." entry is added
747        let mut state = FileOpenState::new(PathBuf::from("/"), false, test_filesystem());
748        state.sort_mode = SortMode::Size;
749        state.set_entries(vec![
750            make_entry_with_size("big.txt", 1000),
751            make_entry_with_size("small.txt", 100),
752            make_entry_with_size("medium.txt", 500),
753        ]);
754
755        assert_eq!(state.entries[0].fs_entry.name, "small.txt");
756        assert_eq!(state.entries[1].fs_entry.name, "medium.txt");
757        assert_eq!(state.entries[2].fs_entry.name, "big.txt");
758    }
759
760    #[test]
761    fn test_filter() {
762        // Use root path so no ".." entry is added
763        let mut state = FileOpenState::new(PathBuf::from("/"), false, test_filesystem());
764        state.set_entries(vec![
765            make_entry("foo.txt", false),
766            make_entry("bar.txt", false),
767            make_entry("foobar.txt", false),
768        ]);
769
770        state.apply_filter("foo");
771
772        // With fuzzy matching, matching entries are sorted by score and appear first
773        // foo.txt has a better score (starts with "foo") than foobar.txt
774        // Non-matching bar.txt appears last
775        assert_eq!(state.entries[0].fs_entry.name, "foo.txt");
776        assert!(state.entries[0].matches_filter);
777
778        assert_eq!(state.entries[1].fs_entry.name, "foobar.txt");
779        assert!(state.entries[1].matches_filter);
780
781        assert_eq!(state.entries[2].fs_entry.name, "bar.txt");
782        assert!(!state.entries[2].matches_filter);
783
784        assert_eq!(state.matching_count(), 2);
785    }
786
787    #[test]
788    fn test_filter_case_insensitive() {
789        // Use root path so no ".." entry is added
790        let mut state = FileOpenState::new(PathBuf::from("/"), false, test_filesystem());
791        state.set_entries(vec![
792            make_entry("README.md", false),
793            make_entry("readme.txt", false),
794            make_entry("other.txt", false),
795        ]);
796
797        state.apply_filter("readme");
798
799        // Matching entries first (sorted by score), non-matching last
800        // Both README.md and readme.txt match with similar scores
801        assert!(state.entries[0].matches_filter);
802        assert!(state.entries[1].matches_filter);
803
804        assert_eq!(state.entries[2].fs_entry.name, "other.txt");
805        assert!(!state.entries[2].matches_filter);
806    }
807
808    #[test]
809    fn test_hidden_files() {
810        // Use root path so no ".." entry is added
811        let mut state = FileOpenState::new(PathBuf::from("/"), false, test_filesystem());
812        state.show_hidden = false;
813        state.set_entries(vec![
814            make_entry(".hidden", false),
815            make_entry("visible.txt", false),
816        ]);
817
818        // Hidden file should be filtered out
819        assert_eq!(state.entries.len(), 1);
820        assert_eq!(state.entries[0].fs_entry.name, "visible.txt");
821    }
822
823    #[test]
824    fn test_format_size() {
825        assert_eq!(format_size(500), "500 B");
826        assert_eq!(format_size(1024), "1.0 KB");
827        assert_eq!(format_size(1536), "1.5 KB");
828        assert_eq!(format_size(1048576), "1.0 MB");
829        assert_eq!(format_size(1073741824), "1.0 GB");
830    }
831
832    #[test]
833    fn test_navigation() {
834        // Use root path so no ".." entry is added
835        let mut state = FileOpenState::new(PathBuf::from("/"), false, test_filesystem());
836        state.set_entries(vec![
837            make_entry("a.txt", false),
838            make_entry("b.txt", false),
839            make_entry("c.txt", false),
840        ]);
841
842        // Initially no selection
843        assert_eq!(state.selected_index, None);
844
845        // First down selects first entry
846        state.select_next();
847        assert_eq!(state.selected_index, Some(0));
848
849        state.select_next();
850        assert_eq!(state.selected_index, Some(1));
851
852        state.select_next();
853        assert_eq!(state.selected_index, Some(2));
854
855        state.select_next(); // Should stay at last
856        assert_eq!(state.selected_index, Some(2));
857
858        state.select_prev();
859        assert_eq!(state.selected_index, Some(1));
860
861        state.select_first();
862        assert_eq!(state.selected_index, Some(0));
863
864        state.select_last();
865        assert_eq!(state.selected_index, Some(2));
866    }
867
868    /// Regression test for #245: when the terminal is small enough that the
869    /// file list shows fewer than the previous hardcoded 15 rows, moving the
870    /// selection past the bottom of the viewport must scroll the list so the
871    /// selected entry stays visible.
872    #[test]
873    fn test_scroll_follows_selection_in_small_viewport() {
874        let mut state = FileOpenState::new(PathBuf::from("/"), false, test_filesystem());
875        state.set_entries(
876            (0..10)
877                .map(|i| make_entry(&format!("f{i}"), false))
878                .collect(),
879        );
880
881        // Renderer reports a 3-row viewport (very small terminal).
882        state.update_scroll_for_visible_rows(3);
883        assert_eq!(state.scroll_offset, 0);
884
885        // Walk down to index 4. Selection must remain inside [scroll_offset, scroll_offset + 3).
886        for _ in 0..5 {
887            state.select_next();
888        }
889        assert_eq!(state.selected_index, Some(4));
890        let idx = state.selected_index.unwrap();
891        assert!(
892            idx >= state.scroll_offset && idx < state.scroll_offset + 3,
893            "selected idx {idx} not in viewport [{}, {})",
894            state.scroll_offset,
895            state.scroll_offset + 3
896        );
897
898        // Jumping to the last entry must also keep it on screen.
899        state.select_last();
900        let idx = state.selected_index.unwrap();
901        assert_eq!(idx, 9);
902        assert!(
903            idx >= state.scroll_offset && idx < state.scroll_offset + 3,
904            "select_last left idx {idx} outside viewport [{}, {})",
905            state.scroll_offset,
906            state.scroll_offset + 3
907        );
908
909        // Moving back up shrinks the offset so we don't leave a blank tail.
910        state.select_first();
911        assert_eq!(state.selected_index, Some(0));
912        assert_eq!(state.scroll_offset, 0);
913    }
914
915    /// When the viewport shrinks (terminal resize), the next renderer pass
916    /// must re-clamp `scroll_offset` so the selection stays visible.
917    #[test]
918    fn test_scroll_reclamped_on_viewport_shrink() {
919        let mut state = FileOpenState::new(PathBuf::from("/"), false, test_filesystem());
920        state.set_entries(
921            (0..20)
922                .map(|i| make_entry(&format!("f{i}"), false))
923                .collect(),
924        );
925
926        state.update_scroll_for_visible_rows(15);
927        for _ in 0..15 {
928            state.select_next();
929        }
930        // Selection at idx 14 fits in a 15-row viewport with offset 0.
931        assert_eq!(state.selected_index, Some(14));
932        assert_eq!(state.scroll_offset, 0);
933
934        // Terminal shrinks to 4 rows; the next render reports the new height.
935        state.update_scroll_for_visible_rows(4);
936        let idx = state.selected_index.unwrap();
937        assert!(
938            idx >= state.scroll_offset && idx < state.scroll_offset + 4,
939            "selected idx {idx} not in shrunk viewport [{}, {})",
940            state.scroll_offset,
941            state.scroll_offset + 4
942        );
943    }
944
945    #[test]
946    fn test_fuzzy_filter() {
947        // Use root path so no ".." entry is added
948        let mut state = FileOpenState::new(PathBuf::from("/"), false, test_filesystem());
949        state.set_entries(vec![
950            make_entry("command_registry.rs", false),
951            make_entry("commands.rs", false),
952            make_entry("keybindings.rs", false),
953            make_entry("mod.rs", false),
954        ]);
955
956        // Fuzzy match "cmdreg" should match "command_registry.rs"
957        state.apply_filter("cmdreg");
958
959        // command_registry.rs should match and be first
960        assert!(state.entries[0].matches_filter);
961        assert_eq!(state.entries[0].fs_entry.name, "command_registry.rs");
962
963        // commands.rs might also match "cmd" part
964        // Other files shouldn't match
965        assert_eq!(state.matching_count(), 1);
966    }
967
968    #[test]
969    fn test_fuzzy_filter_sparse_match() {
970        // Use root path so no ".." entry is added
971        let mut state = FileOpenState::new(PathBuf::from("/"), false, test_filesystem());
972        state.set_entries(vec![
973            make_entry("Save File", false),
974            make_entry("Select All", false),
975            make_entry("something_else.txt", false),
976        ]);
977
978        // "sf" should match "Save File" (S and F)
979        state.apply_filter("sf");
980
981        assert_eq!(state.matching_count(), 1);
982        assert!(state.entries[0].matches_filter);
983        assert_eq!(state.entries[0].fs_entry.name, "Save File");
984    }
985}