Skip to main content

ratatui_interact/components/
file_explorer.rs

1//! File explorer widget
2//!
3//! A file browser with directory navigation, file type icons, and multi-selection.
4//!
5//! # Example
6//!
7//! ```rust,ignore
8//! use ratatui_interact::components::{FileExplorer, FileExplorerState, FileEntry, EntryType};
9//! use std::path::PathBuf;
10//!
11//! // Create state
12//! let mut state = FileExplorerState::new(PathBuf::from("/home/user"));
13//!
14//! // Load entries (typically done in your app)
15//! state.load_entries().unwrap();
16//!
17//! // Create explorer
18//! let explorer = FileExplorer::new(&state)
19//!     .title_format(|path| format!("Browse: {}", path.display()));
20//! ```
21
22use std::collections::HashSet;
23use std::path::PathBuf;
24
25use ratatui::{
26    Frame,
27    buffer::Buffer,
28    layout::{Alignment, Constraint, Direction, Layout, Rect},
29    style::{Color, Modifier, Style},
30    text::{Line, Span},
31    widgets::{Block, Borders, Clear, Paragraph, Widget},
32};
33
34use crate::utils::display::format_size;
35
36/// Type of file system entry
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub enum EntryType {
39    /// Regular file with extension and size
40    File {
41        extension: Option<String>,
42        size: u64,
43    },
44    /// Directory
45    Directory,
46    /// Parent directory (..)
47    ParentDir,
48    /// Symbolic link with target
49    Symlink { target: Option<PathBuf> },
50}
51
52/// A file system entry
53#[derive(Debug, Clone)]
54pub struct FileEntry {
55    /// Display name
56    pub name: String,
57    /// Full path
58    pub path: PathBuf,
59    /// Entry type
60    pub entry_type: EntryType,
61}
62
63impl FileEntry {
64    /// Create a new file entry
65    pub fn new(name: impl Into<String>, path: PathBuf, entry_type: EntryType) -> Self {
66        Self {
67            name: name.into(),
68            path,
69            entry_type,
70        }
71    }
72
73    /// Create a parent directory entry
74    pub fn parent_dir(parent_path: PathBuf) -> Self {
75        Self {
76            name: "..".into(),
77            path: parent_path,
78            entry_type: EntryType::ParentDir,
79        }
80    }
81
82    /// Check if this is a directory (including parent dir)
83    pub fn is_dir(&self) -> bool {
84        matches!(self.entry_type, EntryType::Directory | EntryType::ParentDir)
85    }
86
87    /// Check if this is selectable (files only, not directories)
88    pub fn is_selectable(&self) -> bool {
89        matches!(self.entry_type, EntryType::File { .. })
90    }
91}
92
93/// Mode for the file explorer
94#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
95pub enum FileExplorerMode {
96    /// Normal browsing mode
97    #[default]
98    Browse,
99    /// Search/filter mode
100    Search,
101}
102
103/// State for the file explorer widget
104#[derive(Debug, Clone)]
105pub struct FileExplorerState {
106    /// Current directory
107    pub current_dir: PathBuf,
108    /// List of entries in current directory
109    pub entries: Vec<FileEntry>,
110    /// Current cursor position
111    pub cursor_index: usize,
112    /// Scroll offset
113    pub scroll: u16,
114    /// Selected files (for multi-select)
115    pub selected_files: HashSet<PathBuf>,
116    /// Whether to show hidden files
117    pub show_hidden: bool,
118    /// Current mode
119    pub mode: FileExplorerMode,
120    /// Search/filter query
121    pub search_query: String,
122    /// Filtered entry indices (None = show all)
123    pub filtered_indices: Option<Vec<usize>>,
124}
125
126impl FileExplorerState {
127    /// Create a new file explorer state
128    pub fn new(start_dir: PathBuf) -> Self {
129        Self {
130            current_dir: start_dir,
131            entries: Vec::new(),
132            cursor_index: 0,
133            scroll: 0,
134            selected_files: HashSet::new(),
135            show_hidden: false,
136            mode: FileExplorerMode::Browse,
137            search_query: String::new(),
138            filtered_indices: None,
139        }
140    }
141
142    /// Load entries from the current directory
143    #[cfg(feature = "filesystem")]
144    pub fn load_entries(&mut self) -> std::io::Result<()> {
145        self.entries.clear();
146        self.cursor_index = 0;
147        self.scroll = 0;
148        self.filtered_indices = None;
149
150        // Add parent directory if not at root
151        if let Some(parent) = self.current_dir.parent() {
152            self.entries
153                .push(FileEntry::parent_dir(parent.to_path_buf()));
154        }
155
156        // Read directory entries
157        let mut dirs = Vec::new();
158        let mut files = Vec::new();
159
160        for entry in std::fs::read_dir(&self.current_dir)? {
161            let entry = entry?;
162            let path = entry.path();
163            let name = entry.file_name().to_string_lossy().to_string();
164
165            // Skip hidden files if not showing them
166            if !self.show_hidden && name.starts_with('.') {
167                continue;
168            }
169
170            let metadata = entry.metadata()?;
171            let entry_type = if metadata.is_dir() {
172                EntryType::Directory
173            } else if metadata.is_symlink() {
174                EntryType::Symlink {
175                    target: std::fs::read_link(&path).ok(),
176                }
177            } else {
178                EntryType::File {
179                    extension: path.extension().map(|e| e.to_string_lossy().to_string()),
180                    size: metadata.len(),
181                }
182            };
183
184            let file_entry = FileEntry::new(name, path, entry_type);
185            if file_entry.is_dir() {
186                dirs.push(file_entry);
187            } else {
188                files.push(file_entry);
189            }
190        }
191
192        // Sort: directories first (alphabetically), then files (alphabetically)
193        dirs.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
194        files.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
195
196        self.entries.extend(dirs);
197        self.entries.extend(files);
198
199        Ok(())
200    }
201
202    /// Navigate into a directory
203    pub fn enter_directory(&mut self, path: PathBuf) {
204        self.current_dir = path;
205        #[cfg(feature = "filesystem")]
206        let _ = self.load_entries();
207    }
208
209    /// Navigate up to parent directory
210    pub fn go_up(&mut self) {
211        if let Some(parent) = self.current_dir.parent() {
212            self.current_dir = parent.to_path_buf();
213            #[cfg(feature = "filesystem")]
214            let _ = self.load_entries();
215        }
216    }
217
218    /// Move cursor up
219    pub fn cursor_up(&mut self) {
220        let count = self.visible_count();
221        if count > 0 && self.cursor_index > 0 {
222            self.cursor_index -= 1;
223        }
224    }
225
226    /// Move cursor down
227    pub fn cursor_down(&mut self) {
228        let count = self.visible_count();
229        if count > 0 && self.cursor_index + 1 < count {
230            self.cursor_index += 1;
231        }
232    }
233
234    /// Get the number of visible entries
235    pub fn visible_count(&self) -> usize {
236        self.filtered_indices
237            .as_ref()
238            .map(|i| i.len())
239            .unwrap_or(self.entries.len())
240    }
241
242    /// Get the currently selected entry
243    pub fn current_entry(&self) -> Option<&FileEntry> {
244        if let Some(ref indices) = self.filtered_indices {
245            indices
246                .get(self.cursor_index)
247                .and_then(|&i| self.entries.get(i))
248        } else {
249            self.entries.get(self.cursor_index)
250        }
251    }
252
253    /// Toggle selection of current file
254    pub fn toggle_selection(&mut self) {
255        if let Some(entry) = self.current_entry() {
256            if entry.is_selectable() {
257                let path = entry.path.clone();
258                if self.selected_files.contains(&path) {
259                    self.selected_files.remove(&path);
260                } else {
261                    self.selected_files.insert(path);
262                }
263            }
264        }
265    }
266
267    /// Select all files
268    pub fn select_all(&mut self) {
269        for entry in &self.entries {
270            if entry.is_selectable() {
271                self.selected_files.insert(entry.path.clone());
272            }
273        }
274    }
275
276    /// Clear all selections
277    pub fn select_none(&mut self) {
278        self.selected_files.clear();
279    }
280
281    /// Toggle hidden files visibility
282    pub fn toggle_hidden(&mut self) {
283        self.show_hidden = !self.show_hidden;
284        #[cfg(feature = "filesystem")]
285        let _ = self.load_entries();
286    }
287
288    /// Enter search mode
289    pub fn start_search(&mut self) {
290        self.mode = FileExplorerMode::Search;
291        self.search_query.clear();
292    }
293
294    /// Exit search mode
295    pub fn cancel_search(&mut self) {
296        self.mode = FileExplorerMode::Browse;
297        self.search_query.clear();
298        self.filtered_indices = None;
299    }
300
301    /// Update search filter
302    pub fn update_filter(&mut self) {
303        if self.search_query.is_empty() {
304            self.filtered_indices = None;
305        } else {
306            let query = self.search_query.to_lowercase();
307            self.filtered_indices = Some(
308                self.entries
309                    .iter()
310                    .enumerate()
311                    .filter(|(_, e)| e.name.to_lowercase().contains(&query))
312                    .map(|(i, _)| i)
313                    .collect(),
314            );
315            self.cursor_index = 0;
316        }
317    }
318
319    /// Ensure cursor is visible
320    pub fn ensure_visible(&mut self, viewport_height: usize) {
321        if viewport_height == 0 {
322            return;
323        }
324
325        if self.cursor_index < self.scroll as usize {
326            self.scroll = self.cursor_index as u16;
327        } else if self.cursor_index >= self.scroll as usize + viewport_height {
328            self.scroll = (self.cursor_index - viewport_height + 1) as u16;
329        }
330    }
331}
332
333/// Style configuration for file explorer
334#[derive(Debug, Clone)]
335pub struct FileExplorerStyle {
336    /// Border style
337    pub border_style: Style,
338    /// Style for selected (cursor) item
339    pub cursor_style: Style,
340    /// Style for directory names
341    pub dir_style: Style,
342    /// Style for file names (by extension)
343    pub file_colors: Vec<(Vec<&'static str>, Color)>,
344    /// Default file color
345    pub default_file_color: Color,
346    /// Style for file sizes
347    pub size_style: Style,
348    /// Checkbox checked
349    pub checkbox_checked: &'static str,
350    /// Checkbox unchecked
351    pub checkbox_unchecked: &'static str,
352    /// Directory icon
353    pub dir_icon: &'static str,
354    /// Parent directory icon
355    pub parent_icon: &'static str,
356    /// Symlink icon
357    pub symlink_icon: &'static str,
358}
359
360impl Default for FileExplorerStyle {
361    fn default() -> Self {
362        Self {
363            border_style: Style::default().fg(Color::Cyan),
364            cursor_style: Style::default()
365                .fg(Color::Black)
366                .bg(Color::Cyan)
367                .add_modifier(Modifier::BOLD),
368            dir_style: Style::default()
369                .fg(Color::Blue)
370                .add_modifier(Modifier::BOLD),
371            file_colors: vec![
372                (vec!["rs"], Color::Yellow),
373                (vec!["toml", "json", "yaml", "yml"], Color::Green),
374                (vec!["md", "txt", "rst"], Color::White),
375                (vec!["py"], Color::Cyan),
376                (vec!["js", "ts", "tsx", "jsx"], Color::Magenta),
377                (vec!["sh", "bash", "zsh"], Color::Red),
378            ],
379            default_file_color: Color::Gray,
380            size_style: Style::default().fg(Color::DarkGray),
381            checkbox_checked: "[x]",
382            checkbox_unchecked: "[ ]",
383            dir_icon: "[DIR]",
384            parent_icon: " .. ",
385            symlink_icon: "[LNK]",
386        }
387    }
388}
389
390impl FileExplorerStyle {
391    /// Get color for a file extension
392    pub fn color_for_extension(&self, ext: Option<&str>) -> Color {
393        if let Some(ext) = ext {
394            for (extensions, color) in &self.file_colors {
395                if extensions.contains(&ext) {
396                    return *color;
397                }
398            }
399        }
400        self.default_file_color
401    }
402}
403
404/// File explorer widget
405pub struct FileExplorer<'a> {
406    state: &'a FileExplorerState,
407    style: FileExplorerStyle,
408}
409
410impl<'a> FileExplorer<'a> {
411    /// Create a new file explorer widget
412    pub fn new(state: &'a FileExplorerState) -> Self {
413        Self {
414            state,
415            style: FileExplorerStyle::default(),
416        }
417    }
418
419    /// Set the style
420    pub fn style(mut self, style: FileExplorerStyle) -> Self {
421        self.style = style;
422        self
423    }
424
425    /// Build file list lines
426    fn build_lines(&self, inner: Rect) -> Vec<Line<'static>> {
427        let visible_height = inner.height as usize;
428        let scroll = self.state.scroll as usize;
429
430        let entries_to_show: Vec<(usize, &FileEntry)> =
431            if let Some(ref indices) = self.state.filtered_indices {
432                indices
433                    .iter()
434                    .map(|&i| (i, &self.state.entries[i]))
435                    .collect()
436            } else {
437                self.state.entries.iter().enumerate().collect()
438            };
439
440        let mut lines = Vec::new();
441
442        for (display_idx, (_entry_idx, entry)) in entries_to_show
443            .iter()
444            .enumerate()
445            .skip(scroll)
446            .take(visible_height)
447        {
448            let is_cursor = display_idx == self.state.cursor_index;
449            let is_checked = self.state.selected_files.contains(&entry.path);
450
451            let style = if is_cursor {
452                self.style.cursor_style
453            } else {
454                Style::default()
455            };
456
457            let cursor = if is_cursor { ">" } else { " " };
458            let checkbox = match &entry.entry_type {
459                EntryType::File { .. } => {
460                    if is_checked {
461                        self.style.checkbox_checked
462                    } else {
463                        self.style.checkbox_unchecked
464                    }
465                }
466                _ => "   ",
467            };
468
469            let (icon, name_style) = match &entry.entry_type {
470                EntryType::Directory => (
471                    self.style.dir_icon,
472                    if is_cursor {
473                        self.style.cursor_style
474                    } else {
475                        self.style.dir_style
476                    },
477                ),
478                EntryType::ParentDir => (
479                    self.style.parent_icon,
480                    if is_cursor {
481                        self.style.cursor_style
482                    } else {
483                        self.style.dir_style
484                    },
485                ),
486                EntryType::File { extension, .. } => {
487                    let color = self.style.color_for_extension(extension.as_deref());
488                    (
489                        "     ",
490                        if is_cursor {
491                            self.style.cursor_style
492                        } else {
493                            Style::default().fg(color)
494                        },
495                    )
496                }
497                EntryType::Symlink { .. } => (
498                    self.style.symlink_icon,
499                    if is_cursor {
500                        self.style.cursor_style
501                    } else {
502                        Style::default().fg(Color::Magenta)
503                    },
504                ),
505            };
506
507            let size_str = match &entry.entry_type {
508                EntryType::File { size, .. } => format_size(*size),
509                _ => String::new(),
510            };
511
512            // Calculate name width
513            let name_width = inner.width.saturating_sub(22) as usize;
514            let display_name = if entry.name.len() > name_width {
515                format!("{}...", &entry.name[..name_width.saturating_sub(3)])
516            } else {
517                entry.name.clone()
518            };
519
520            lines.push(Line::from(vec![
521                Span::styled(cursor.to_string(), style),
522                Span::styled(" ", style),
523                Span::styled(checkbox.to_string(), style),
524                Span::styled(" ", style),
525                Span::styled(icon.to_string(), style),
526                Span::styled(" ", style),
527                Span::styled(
528                    format!("{:<width$}", display_name, width = name_width),
529                    name_style,
530                ),
531                Span::styled(
532                    format!("{:>10}", size_str),
533                    if is_cursor {
534                        self.style.cursor_style
535                    } else {
536                        self.style.size_style
537                    },
538                ),
539            ]));
540        }
541
542        lines
543    }
544}
545
546impl Widget for FileExplorer<'_> {
547    fn render(self, area: Rect, buf: &mut Buffer) {
548        // Main layout
549        let chunks = Layout::default()
550            .direction(Direction::Vertical)
551            .constraints([
552                Constraint::Min(1),    // File list
553                Constraint::Length(3), // Footer
554            ])
555            .split(area);
556
557        // Title with path and selection count
558        let selected_count = self.state.selected_files.len();
559        let title = if selected_count > 0 {
560            format!(
561                " {} ({} selected) ",
562                self.state.current_dir.display(),
563                selected_count
564            )
565        } else {
566            format!(" {} ", self.state.current_dir.display())
567        };
568
569        let block = Block::default()
570            .borders(Borders::ALL)
571            .border_style(self.style.border_style)
572            .title(title);
573
574        let inner = block.inner(chunks[0]);
575        block.render(chunks[0], buf);
576
577        // File list
578        let lines = self.build_lines(inner);
579        let paragraph = Paragraph::new(lines);
580        paragraph.render(inner, buf);
581
582        // Footer
583        let footer = build_footer(self.state.mode);
584        let footer_block = Block::default()
585            .borders(Borders::TOP)
586            .border_style(Style::default().fg(Color::DarkGray));
587        let footer_para = Paragraph::new(footer)
588            .block(footer_block)
589            .alignment(Alignment::Center);
590        footer_para.render(chunks[1], buf);
591    }
592}
593
594/// Build footer lines based on current mode
595fn build_footer(mode: FileExplorerMode) -> Vec<Line<'static>> {
596    match mode {
597        FileExplorerMode::Browse => vec![
598            Line::from(vec![
599                Span::styled("↑↓", Style::default().fg(Color::Green)),
600                Span::raw(":Move "),
601                Span::styled("Enter", Style::default().fg(Color::Green)),
602                Span::raw(":Open "),
603                Span::styled("Space", Style::default().fg(Color::Green)),
604                Span::raw(":Select "),
605                Span::styled("/", Style::default().fg(Color::Green)),
606                Span::raw(":Search "),
607                Span::styled(".", Style::default().fg(Color::Green)),
608                Span::raw(":Hidden"),
609            ]),
610            Line::from(vec![
611                Span::styled("a", Style::default().fg(Color::Green)),
612                Span::raw(":All "),
613                Span::styled("n", Style::default().fg(Color::Green)),
614                Span::raw(":None "),
615                Span::styled("Esc", Style::default().fg(Color::Green)),
616                Span::raw(":Close"),
617            ]),
618        ],
619        FileExplorerMode::Search => vec![Line::from(vec![
620            Span::styled("Enter", Style::default().fg(Color::Green)),
621            Span::raw(":Confirm "),
622            Span::styled("Esc", Style::default().fg(Color::Green)),
623            Span::raw(":Cancel"),
624        ])],
625    }
626}
627
628/// Draw a search bar overlay
629pub fn draw_search_bar(f: &mut Frame, query: &str, area: Rect) {
630    let search_text = Line::from(vec![
631        Span::styled(
632            "Search: ",
633            Style::default()
634                .fg(Color::Yellow)
635                .add_modifier(Modifier::BOLD),
636        ),
637        Span::styled(query.to_string(), Style::default().fg(Color::White)),
638        Span::styled(
639            "_",
640            Style::default()
641                .fg(Color::White)
642                .add_modifier(Modifier::SLOW_BLINK),
643        ),
644    ]);
645
646    let block = Block::default()
647        .borders(Borders::TOP)
648        .border_style(Style::default().fg(Color::Yellow));
649
650    let paragraph = Paragraph::new(vec![search_text]).block(block);
651
652    f.render_widget(Clear, area);
653    f.render_widget(paragraph, area);
654}
655
656#[cfg(test)]
657mod tests {
658    use super::*;
659
660    #[test]
661    fn test_file_entry() {
662        let entry = FileEntry::new(
663            "test.rs",
664            PathBuf::from("/home/user/test.rs"),
665            EntryType::File {
666                extension: Some("rs".into()),
667                size: 1024,
668            },
669        );
670        assert!(!entry.is_dir());
671        assert!(entry.is_selectable());
672
673        let dir = FileEntry::new("src", PathBuf::from("/home/user/src"), EntryType::Directory);
674        assert!(dir.is_dir());
675        assert!(!dir.is_selectable());
676    }
677
678    #[test]
679    fn test_file_entry_parent_dir() {
680        let entry = FileEntry::parent_dir(PathBuf::from("/home"));
681        assert_eq!(entry.name, "..");
682        assert!(entry.is_dir());
683        assert!(!entry.is_selectable());
684        assert_eq!(entry.entry_type, EntryType::ParentDir);
685    }
686
687    #[test]
688    fn test_file_entry_symlink() {
689        let entry = FileEntry::new(
690            "link",
691            PathBuf::from("/home/user/link"),
692            EntryType::Symlink {
693                target: Some(PathBuf::from("/target")),
694            },
695        );
696        assert!(!entry.is_dir());
697        assert!(!entry.is_selectable()); // Symlinks are not selectable
698    }
699
700    #[test]
701    fn test_state_new() {
702        let state = FileExplorerState::new(PathBuf::from("/tmp"));
703        assert_eq!(state.current_dir, PathBuf::from("/tmp"));
704        assert!(state.entries.is_empty());
705        assert_eq!(state.cursor_index, 0);
706        assert!(!state.show_hidden);
707        assert_eq!(state.mode, FileExplorerMode::Browse);
708    }
709
710    #[test]
711    fn test_state_navigation() {
712        let mut state = FileExplorerState::new(PathBuf::from("/tmp"));
713        state.entries = vec![
714            FileEntry::parent_dir(PathBuf::from("/")),
715            FileEntry::new(
716                "file1.txt",
717                PathBuf::from("/tmp/file1.txt"),
718                EntryType::File {
719                    extension: Some("txt".into()),
720                    size: 100,
721                },
722            ),
723            FileEntry::new(
724                "file2.txt",
725                PathBuf::from("/tmp/file2.txt"),
726                EntryType::File {
727                    extension: Some("txt".into()),
728                    size: 200,
729                },
730            ),
731        ];
732
733        assert_eq!(state.cursor_index, 0);
734        state.cursor_down();
735        assert_eq!(state.cursor_index, 1);
736        state.cursor_down();
737        assert_eq!(state.cursor_index, 2);
738        state.cursor_down(); // Should not go past end
739        assert_eq!(state.cursor_index, 2);
740        state.cursor_up();
741        assert_eq!(state.cursor_index, 1);
742    }
743
744    #[test]
745    fn test_cursor_up_at_top() {
746        let mut state = FileExplorerState::new(PathBuf::from("/tmp"));
747        state.entries = vec![FileEntry::new(
748            "file.txt",
749            PathBuf::from("/tmp/file.txt"),
750            EntryType::File {
751                extension: Some("txt".into()),
752                size: 100,
753            },
754        )];
755        state.cursor_index = 0;
756        state.cursor_up();
757        assert_eq!(state.cursor_index, 0); // Should not go negative
758    }
759
760    #[test]
761    fn test_selection() {
762        let mut state = FileExplorerState::new(PathBuf::from("/tmp"));
763        state.entries = vec![FileEntry::new(
764            "file.txt",
765            PathBuf::from("/tmp/file.txt"),
766            EntryType::File {
767                extension: Some("txt".into()),
768                size: 100,
769            },
770        )];
771
772        assert!(state.selected_files.is_empty());
773        state.toggle_selection();
774        assert_eq!(state.selected_files.len(), 1);
775        state.toggle_selection();
776        assert!(state.selected_files.is_empty());
777    }
778
779    #[test]
780    fn test_select_all() {
781        let mut state = FileExplorerState::new(PathBuf::from("/tmp"));
782        state.entries = vec![
783            FileEntry::new("dir", PathBuf::from("/tmp/dir"), EntryType::Directory),
784            FileEntry::new(
785                "file1.txt",
786                PathBuf::from("/tmp/file1.txt"),
787                EntryType::File {
788                    extension: Some("txt".into()),
789                    size: 100,
790                },
791            ),
792            FileEntry::new(
793                "file2.txt",
794                PathBuf::from("/tmp/file2.txt"),
795                EntryType::File {
796                    extension: Some("txt".into()),
797                    size: 200,
798                },
799            ),
800        ];
801
802        state.select_all();
803        // Only files should be selected, not directories
804        assert_eq!(state.selected_files.len(), 2);
805    }
806
807    #[test]
808    fn test_select_none() {
809        let mut state = FileExplorerState::new(PathBuf::from("/tmp"));
810        state.entries = vec![FileEntry::new(
811            "file.txt",
812            PathBuf::from("/tmp/file.txt"),
813            EntryType::File {
814                extension: Some("txt".into()),
815                size: 100,
816            },
817        )];
818
819        state.toggle_selection();
820        assert_eq!(state.selected_files.len(), 1);
821        state.select_none();
822        assert!(state.selected_files.is_empty());
823    }
824
825    #[test]
826    fn test_toggle_hidden() {
827        let mut state = FileExplorerState::new(PathBuf::from("/tmp"));
828        assert!(!state.show_hidden);
829        state.toggle_hidden();
830        assert!(state.show_hidden);
831        state.toggle_hidden();
832        assert!(!state.show_hidden);
833    }
834
835    #[test]
836    fn test_search_mode() {
837        let mut state = FileExplorerState::new(PathBuf::from("/tmp"));
838        assert_eq!(state.mode, FileExplorerMode::Browse);
839
840        state.start_search();
841        assert_eq!(state.mode, FileExplorerMode::Search);
842        assert!(state.search_query.is_empty());
843
844        state.cancel_search();
845        assert_eq!(state.mode, FileExplorerMode::Browse);
846        assert!(state.filtered_indices.is_none());
847    }
848
849    #[test]
850    fn test_update_filter() {
851        let mut state = FileExplorerState::new(PathBuf::from("/tmp"));
852        state.entries = vec![
853            FileEntry::new(
854                "test.rs",
855                PathBuf::from("/tmp/test.rs"),
856                EntryType::File {
857                    extension: Some("rs".into()),
858                    size: 100,
859                },
860            ),
861            FileEntry::new(
862                "main.rs",
863                PathBuf::from("/tmp/main.rs"),
864                EntryType::File {
865                    extension: Some("rs".into()),
866                    size: 200,
867                },
868            ),
869            FileEntry::new(
870                "other.txt",
871                PathBuf::from("/tmp/other.txt"),
872                EntryType::File {
873                    extension: Some("txt".into()),
874                    size: 300,
875                },
876            ),
877        ];
878
879        state.search_query = "test".into();
880        state.update_filter();
881
882        assert!(state.filtered_indices.is_some());
883        assert_eq!(state.filtered_indices.as_ref().unwrap().len(), 1);
884        assert_eq!(state.visible_count(), 1);
885    }
886
887    #[test]
888    fn test_update_filter_empty_clears() {
889        let mut state = FileExplorerState::new(PathBuf::from("/tmp"));
890        state.entries = vec![FileEntry::new(
891            "file.txt",
892            PathBuf::from("/tmp/file.txt"),
893            EntryType::File {
894                extension: Some("txt".into()),
895                size: 100,
896            },
897        )];
898
899        state.search_query = "file".into();
900        state.update_filter();
901        assert!(state.filtered_indices.is_some());
902
903        state.search_query = "".into();
904        state.update_filter();
905        assert!(state.filtered_indices.is_none());
906    }
907
908    #[test]
909    fn test_current_entry() {
910        let mut state = FileExplorerState::new(PathBuf::from("/tmp"));
911        state.entries = vec![
912            FileEntry::new(
913                "first.txt",
914                PathBuf::from("/tmp/first.txt"),
915                EntryType::File {
916                    extension: Some("txt".into()),
917                    size: 100,
918                },
919            ),
920            FileEntry::new(
921                "second.txt",
922                PathBuf::from("/tmp/second.txt"),
923                EntryType::File {
924                    extension: Some("txt".into()),
925                    size: 200,
926                },
927            ),
928        ];
929
930        assert_eq!(state.current_entry().unwrap().name, "first.txt");
931        state.cursor_down();
932        assert_eq!(state.current_entry().unwrap().name, "second.txt");
933    }
934
935    #[test]
936    fn test_ensure_visible() {
937        let mut state = FileExplorerState::new(PathBuf::from("/tmp"));
938        state.entries = (0..20)
939            .map(|i| {
940                FileEntry::new(
941                    format!("file{}.txt", i),
942                    PathBuf::from(format!("/tmp/file{}.txt", i)),
943                    EntryType::File {
944                        extension: Some("txt".into()),
945                        size: 100,
946                    },
947                )
948            })
949            .collect();
950
951        state.cursor_index = 15;
952        state.ensure_visible(10);
953        assert!(state.scroll >= 6); // 15 - 10 + 1 = 6
954    }
955
956    #[test]
957    fn test_ensure_visible_zero_viewport() {
958        let mut state = FileExplorerState::new(PathBuf::from("/tmp"));
959        state.cursor_index = 5;
960        state.scroll = 3;
961        state.ensure_visible(0);
962        assert_eq!(state.scroll, 3); // Should not change
963    }
964
965    #[test]
966    fn test_style_color_for_extension() {
967        let style = FileExplorerStyle::default();
968        assert_eq!(style.color_for_extension(Some("rs")), Color::Yellow);
969        assert_eq!(style.color_for_extension(Some("json")), Color::Green);
970        assert_eq!(style.color_for_extension(Some("unknown")), Color::Gray);
971        assert_eq!(style.color_for_extension(None), Color::Gray);
972    }
973
974    #[test]
975    fn test_style_color_for_various_extensions() {
976        let style = FileExplorerStyle::default();
977        assert_eq!(style.color_for_extension(Some("toml")), Color::Green);
978        assert_eq!(style.color_for_extension(Some("yaml")), Color::Green);
979        assert_eq!(style.color_for_extension(Some("md")), Color::White);
980        assert_eq!(style.color_for_extension(Some("py")), Color::Cyan);
981        assert_eq!(style.color_for_extension(Some("js")), Color::Magenta);
982        assert_eq!(style.color_for_extension(Some("sh")), Color::Red);
983    }
984
985    #[test]
986    fn test_file_explorer_render() {
987        let mut state = FileExplorerState::new(PathBuf::from("/tmp"));
988        state.entries = vec![
989            FileEntry::new("dir", PathBuf::from("/tmp/dir"), EntryType::Directory),
990            FileEntry::new(
991                "file.txt",
992                PathBuf::from("/tmp/file.txt"),
993                EntryType::File {
994                    extension: Some("txt".into()),
995                    size: 1024,
996                },
997            ),
998        ];
999
1000        let explorer = FileExplorer::new(&state);
1001        let mut buf = Buffer::empty(Rect::new(0, 0, 60, 20));
1002        explorer.render(Rect::new(0, 0, 60, 20), &mut buf);
1003        // Should not panic
1004    }
1005}