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