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 From<&crate::theme::Theme> for FileExplorerStyle {
391    fn from(theme: &crate::theme::Theme) -> Self {
392        let p = &theme.palette;
393        Self {
394            border_style: Style::default().fg(p.border_accent),
395            cursor_style: Style::default()
396                .fg(p.highlight_fg)
397                .bg(p.secondary)
398                .add_modifier(Modifier::BOLD),
399            dir_style: Style::default()
400                .fg(Color::Blue)
401                .add_modifier(Modifier::BOLD),
402            file_colors: vec![
403                (vec!["rs"], Color::Yellow),
404                (vec!["toml", "json", "yaml", "yml"], Color::Green),
405                (vec!["md", "txt", "rst"], Color::White),
406                (vec!["py"], Color::Cyan),
407                (vec!["js", "ts", "tsx", "jsx"], Color::Magenta),
408                (vec!["sh", "bash", "zsh"], Color::Red),
409            ],
410            default_file_color: Color::Gray,
411            size_style: Style::default().fg(Color::DarkGray),
412            checkbox_checked: "[x]",
413            checkbox_unchecked: "[ ]",
414            dir_icon: "[DIR]",
415            parent_icon: " .. ",
416            symlink_icon: "[LNK]",
417        }
418    }
419}
420
421impl FileExplorerStyle {
422    /// Get color for a file extension
423    pub fn color_for_extension(&self, ext: Option<&str>) -> Color {
424        if let Some(ext) = ext {
425            for (extensions, color) in &self.file_colors {
426                if extensions.contains(&ext) {
427                    return *color;
428                }
429            }
430        }
431        self.default_file_color
432    }
433}
434
435/// File explorer widget
436pub struct FileExplorer<'a> {
437    state: &'a FileExplorerState,
438    style: FileExplorerStyle,
439}
440
441impl<'a> FileExplorer<'a> {
442    /// Create a new file explorer widget
443    pub fn new(state: &'a FileExplorerState) -> Self {
444        Self {
445            state,
446            style: FileExplorerStyle::default(),
447        }
448    }
449
450    /// Set the style
451    pub fn style(mut self, style: FileExplorerStyle) -> Self {
452        self.style = style;
453        self
454    }
455
456    /// Apply a theme to derive the style
457    pub fn theme(self, theme: &crate::theme::Theme) -> Self {
458        self.style(FileExplorerStyle::from(theme))
459    }
460
461    /// Build file list lines
462    fn build_lines(&self, inner: Rect) -> Vec<Line<'static>> {
463        let visible_height = inner.height as usize;
464        let scroll = self.state.scroll as usize;
465
466        let entries_to_show: Vec<(usize, &FileEntry)> =
467            if let Some(ref indices) = self.state.filtered_indices {
468                indices
469                    .iter()
470                    .map(|&i| (i, &self.state.entries[i]))
471                    .collect()
472            } else {
473                self.state.entries.iter().enumerate().collect()
474            };
475
476        let mut lines = Vec::new();
477
478        for (display_idx, (_entry_idx, entry)) in entries_to_show
479            .iter()
480            .enumerate()
481            .skip(scroll)
482            .take(visible_height)
483        {
484            let is_cursor = display_idx == self.state.cursor_index;
485            let is_checked = self.state.selected_files.contains(&entry.path);
486
487            let style = if is_cursor {
488                self.style.cursor_style
489            } else {
490                Style::default()
491            };
492
493            let cursor = if is_cursor { ">" } else { " " };
494            let checkbox = match &entry.entry_type {
495                EntryType::File { .. } => {
496                    if is_checked {
497                        self.style.checkbox_checked
498                    } else {
499                        self.style.checkbox_unchecked
500                    }
501                }
502                _ => "   ",
503            };
504
505            let (icon, name_style) = match &entry.entry_type {
506                EntryType::Directory => (
507                    self.style.dir_icon,
508                    if is_cursor {
509                        self.style.cursor_style
510                    } else {
511                        self.style.dir_style
512                    },
513                ),
514                EntryType::ParentDir => (
515                    self.style.parent_icon,
516                    if is_cursor {
517                        self.style.cursor_style
518                    } else {
519                        self.style.dir_style
520                    },
521                ),
522                EntryType::File { extension, .. } => {
523                    let color = self.style.color_for_extension(extension.as_deref());
524                    (
525                        "     ",
526                        if is_cursor {
527                            self.style.cursor_style
528                        } else {
529                            Style::default().fg(color)
530                        },
531                    )
532                }
533                EntryType::Symlink { .. } => (
534                    self.style.symlink_icon,
535                    if is_cursor {
536                        self.style.cursor_style
537                    } else {
538                        Style::default().fg(Color::Magenta)
539                    },
540                ),
541            };
542
543            let size_str = match &entry.entry_type {
544                EntryType::File { size, .. } => format_size(*size),
545                _ => String::new(),
546            };
547
548            // Calculate name width
549            let name_width = inner.width.saturating_sub(22) as usize;
550            let display_name = if entry.name.len() > name_width {
551                format!("{}...", &entry.name[..name_width.saturating_sub(3)])
552            } else {
553                entry.name.clone()
554            };
555
556            lines.push(Line::from(vec![
557                Span::styled(cursor.to_string(), style),
558                Span::styled(" ", style),
559                Span::styled(checkbox.to_string(), style),
560                Span::styled(" ", style),
561                Span::styled(icon.to_string(), style),
562                Span::styled(" ", style),
563                Span::styled(
564                    format!("{:<width$}", display_name, width = name_width),
565                    name_style,
566                ),
567                Span::styled(
568                    format!("{:>10}", size_str),
569                    if is_cursor {
570                        self.style.cursor_style
571                    } else {
572                        self.style.size_style
573                    },
574                ),
575            ]));
576        }
577
578        lines
579    }
580}
581
582impl Widget for FileExplorer<'_> {
583    fn render(self, area: Rect, buf: &mut Buffer) {
584        // Main layout
585        let chunks = Layout::default()
586            .direction(Direction::Vertical)
587            .constraints([
588                Constraint::Min(1),    // File list
589                Constraint::Length(3), // Footer
590            ])
591            .split(area);
592
593        // Title with path and selection count
594        let selected_count = self.state.selected_files.len();
595        let title = if selected_count > 0 {
596            format!(
597                " {} ({} selected) ",
598                self.state.current_dir.display(),
599                selected_count
600            )
601        } else {
602            format!(" {} ", self.state.current_dir.display())
603        };
604
605        let block = Block::default()
606            .borders(Borders::ALL)
607            .border_style(self.style.border_style)
608            .title(title);
609
610        let inner = block.inner(chunks[0]);
611        block.render(chunks[0], buf);
612
613        // File list
614        let lines = self.build_lines(inner);
615        let paragraph = Paragraph::new(lines);
616        paragraph.render(inner, buf);
617
618        // Footer
619        let footer = build_footer(self.state.mode);
620        let footer_block = Block::default()
621            .borders(Borders::TOP)
622            .border_style(Style::default().fg(Color::DarkGray));
623        let footer_para = Paragraph::new(footer)
624            .block(footer_block)
625            .alignment(Alignment::Center);
626        footer_para.render(chunks[1], buf);
627    }
628}
629
630/// Build footer lines based on current mode
631fn build_footer(mode: FileExplorerMode) -> Vec<Line<'static>> {
632    match mode {
633        FileExplorerMode::Browse => vec![
634            Line::from(vec![
635                Span::styled("↑↓", Style::default().fg(Color::Green)),
636                Span::raw(":Move "),
637                Span::styled("Enter", Style::default().fg(Color::Green)),
638                Span::raw(":Open "),
639                Span::styled("Space", Style::default().fg(Color::Green)),
640                Span::raw(":Select "),
641                Span::styled("/", Style::default().fg(Color::Green)),
642                Span::raw(":Search "),
643                Span::styled(".", Style::default().fg(Color::Green)),
644                Span::raw(":Hidden"),
645            ]),
646            Line::from(vec![
647                Span::styled("a", Style::default().fg(Color::Green)),
648                Span::raw(":All "),
649                Span::styled("n", Style::default().fg(Color::Green)),
650                Span::raw(":None "),
651                Span::styled("Esc", Style::default().fg(Color::Green)),
652                Span::raw(":Close"),
653            ]),
654        ],
655        FileExplorerMode::Search => vec![Line::from(vec![
656            Span::styled("Enter", Style::default().fg(Color::Green)),
657            Span::raw(":Confirm "),
658            Span::styled("Esc", Style::default().fg(Color::Green)),
659            Span::raw(":Cancel"),
660        ])],
661    }
662}
663
664/// Draw a search bar overlay
665pub fn draw_search_bar(f: &mut Frame, query: &str, area: Rect) {
666    let search_text = Line::from(vec![
667        Span::styled(
668            "Search: ",
669            Style::default()
670                .fg(Color::Yellow)
671                .add_modifier(Modifier::BOLD),
672        ),
673        Span::styled(query.to_string(), Style::default().fg(Color::White)),
674        Span::styled(
675            "_",
676            Style::default()
677                .fg(Color::White)
678                .add_modifier(Modifier::SLOW_BLINK),
679        ),
680    ]);
681
682    let block = Block::default()
683        .borders(Borders::TOP)
684        .border_style(Style::default().fg(Color::Yellow));
685
686    let paragraph = Paragraph::new(vec![search_text]).block(block);
687
688    f.render_widget(Clear, area);
689    f.render_widget(paragraph, area);
690}
691
692#[cfg(test)]
693mod tests {
694    use super::*;
695
696    #[test]
697    fn test_file_entry() {
698        let entry = FileEntry::new(
699            "test.rs",
700            PathBuf::from("/home/user/test.rs"),
701            EntryType::File {
702                extension: Some("rs".into()),
703                size: 1024,
704            },
705        );
706        assert!(!entry.is_dir());
707        assert!(entry.is_selectable());
708
709        let dir = FileEntry::new("src", PathBuf::from("/home/user/src"), EntryType::Directory);
710        assert!(dir.is_dir());
711        assert!(!dir.is_selectable());
712    }
713
714    #[test]
715    fn test_file_entry_parent_dir() {
716        let entry = FileEntry::parent_dir(PathBuf::from("/home"));
717        assert_eq!(entry.name, "..");
718        assert!(entry.is_dir());
719        assert!(!entry.is_selectable());
720        assert_eq!(entry.entry_type, EntryType::ParentDir);
721    }
722
723    #[test]
724    fn test_file_entry_symlink() {
725        let entry = FileEntry::new(
726            "link",
727            PathBuf::from("/home/user/link"),
728            EntryType::Symlink {
729                target: Some(PathBuf::from("/target")),
730            },
731        );
732        assert!(!entry.is_dir());
733        assert!(!entry.is_selectable()); // Symlinks are not selectable
734    }
735
736    #[test]
737    fn test_state_new() {
738        let state = FileExplorerState::new(PathBuf::from("/tmp"));
739        assert_eq!(state.current_dir, PathBuf::from("/tmp"));
740        assert!(state.entries.is_empty());
741        assert_eq!(state.cursor_index, 0);
742        assert!(!state.show_hidden);
743        assert_eq!(state.mode, FileExplorerMode::Browse);
744    }
745
746    #[test]
747    fn test_state_navigation() {
748        let mut state = FileExplorerState::new(PathBuf::from("/tmp"));
749        state.entries = vec![
750            FileEntry::parent_dir(PathBuf::from("/")),
751            FileEntry::new(
752                "file1.txt",
753                PathBuf::from("/tmp/file1.txt"),
754                EntryType::File {
755                    extension: Some("txt".into()),
756                    size: 100,
757                },
758            ),
759            FileEntry::new(
760                "file2.txt",
761                PathBuf::from("/tmp/file2.txt"),
762                EntryType::File {
763                    extension: Some("txt".into()),
764                    size: 200,
765                },
766            ),
767        ];
768
769        assert_eq!(state.cursor_index, 0);
770        state.cursor_down();
771        assert_eq!(state.cursor_index, 1);
772        state.cursor_down();
773        assert_eq!(state.cursor_index, 2);
774        state.cursor_down(); // Should not go past end
775        assert_eq!(state.cursor_index, 2);
776        state.cursor_up();
777        assert_eq!(state.cursor_index, 1);
778    }
779
780    #[test]
781    fn test_cursor_up_at_top() {
782        let mut state = FileExplorerState::new(PathBuf::from("/tmp"));
783        state.entries = vec![FileEntry::new(
784            "file.txt",
785            PathBuf::from("/tmp/file.txt"),
786            EntryType::File {
787                extension: Some("txt".into()),
788                size: 100,
789            },
790        )];
791        state.cursor_index = 0;
792        state.cursor_up();
793        assert_eq!(state.cursor_index, 0); // Should not go negative
794    }
795
796    #[test]
797    fn test_selection() {
798        let mut state = FileExplorerState::new(PathBuf::from("/tmp"));
799        state.entries = vec![FileEntry::new(
800            "file.txt",
801            PathBuf::from("/tmp/file.txt"),
802            EntryType::File {
803                extension: Some("txt".into()),
804                size: 100,
805            },
806        )];
807
808        assert!(state.selected_files.is_empty());
809        state.toggle_selection();
810        assert_eq!(state.selected_files.len(), 1);
811        state.toggle_selection();
812        assert!(state.selected_files.is_empty());
813    }
814
815    #[test]
816    fn test_select_all() {
817        let mut state = FileExplorerState::new(PathBuf::from("/tmp"));
818        state.entries = vec![
819            FileEntry::new("dir", PathBuf::from("/tmp/dir"), EntryType::Directory),
820            FileEntry::new(
821                "file1.txt",
822                PathBuf::from("/tmp/file1.txt"),
823                EntryType::File {
824                    extension: Some("txt".into()),
825                    size: 100,
826                },
827            ),
828            FileEntry::new(
829                "file2.txt",
830                PathBuf::from("/tmp/file2.txt"),
831                EntryType::File {
832                    extension: Some("txt".into()),
833                    size: 200,
834                },
835            ),
836        ];
837
838        state.select_all();
839        // Only files should be selected, not directories
840        assert_eq!(state.selected_files.len(), 2);
841    }
842
843    #[test]
844    fn test_select_none() {
845        let mut state = FileExplorerState::new(PathBuf::from("/tmp"));
846        state.entries = vec![FileEntry::new(
847            "file.txt",
848            PathBuf::from("/tmp/file.txt"),
849            EntryType::File {
850                extension: Some("txt".into()),
851                size: 100,
852            },
853        )];
854
855        state.toggle_selection();
856        assert_eq!(state.selected_files.len(), 1);
857        state.select_none();
858        assert!(state.selected_files.is_empty());
859    }
860
861    #[test]
862    fn test_toggle_hidden() {
863        let mut state = FileExplorerState::new(PathBuf::from("/tmp"));
864        assert!(!state.show_hidden);
865        state.toggle_hidden();
866        assert!(state.show_hidden);
867        state.toggle_hidden();
868        assert!(!state.show_hidden);
869    }
870
871    #[test]
872    fn test_search_mode() {
873        let mut state = FileExplorerState::new(PathBuf::from("/tmp"));
874        assert_eq!(state.mode, FileExplorerMode::Browse);
875
876        state.start_search();
877        assert_eq!(state.mode, FileExplorerMode::Search);
878        assert!(state.search_query.is_empty());
879
880        state.cancel_search();
881        assert_eq!(state.mode, FileExplorerMode::Browse);
882        assert!(state.filtered_indices.is_none());
883    }
884
885    #[test]
886    fn test_update_filter() {
887        let mut state = FileExplorerState::new(PathBuf::from("/tmp"));
888        state.entries = vec![
889            FileEntry::new(
890                "test.rs",
891                PathBuf::from("/tmp/test.rs"),
892                EntryType::File {
893                    extension: Some("rs".into()),
894                    size: 100,
895                },
896            ),
897            FileEntry::new(
898                "main.rs",
899                PathBuf::from("/tmp/main.rs"),
900                EntryType::File {
901                    extension: Some("rs".into()),
902                    size: 200,
903                },
904            ),
905            FileEntry::new(
906                "other.txt",
907                PathBuf::from("/tmp/other.txt"),
908                EntryType::File {
909                    extension: Some("txt".into()),
910                    size: 300,
911                },
912            ),
913        ];
914
915        state.search_query = "test".into();
916        state.update_filter();
917
918        assert!(state.filtered_indices.is_some());
919        assert_eq!(state.filtered_indices.as_ref().unwrap().len(), 1);
920        assert_eq!(state.visible_count(), 1);
921    }
922
923    #[test]
924    fn test_update_filter_empty_clears() {
925        let mut state = FileExplorerState::new(PathBuf::from("/tmp"));
926        state.entries = vec![FileEntry::new(
927            "file.txt",
928            PathBuf::from("/tmp/file.txt"),
929            EntryType::File {
930                extension: Some("txt".into()),
931                size: 100,
932            },
933        )];
934
935        state.search_query = "file".into();
936        state.update_filter();
937        assert!(state.filtered_indices.is_some());
938
939        state.search_query = "".into();
940        state.update_filter();
941        assert!(state.filtered_indices.is_none());
942    }
943
944    #[test]
945    fn test_current_entry() {
946        let mut state = FileExplorerState::new(PathBuf::from("/tmp"));
947        state.entries = vec![
948            FileEntry::new(
949                "first.txt",
950                PathBuf::from("/tmp/first.txt"),
951                EntryType::File {
952                    extension: Some("txt".into()),
953                    size: 100,
954                },
955            ),
956            FileEntry::new(
957                "second.txt",
958                PathBuf::from("/tmp/second.txt"),
959                EntryType::File {
960                    extension: Some("txt".into()),
961                    size: 200,
962                },
963            ),
964        ];
965
966        assert_eq!(state.current_entry().unwrap().name, "first.txt");
967        state.cursor_down();
968        assert_eq!(state.current_entry().unwrap().name, "second.txt");
969    }
970
971    #[test]
972    fn test_ensure_visible() {
973        let mut state = FileExplorerState::new(PathBuf::from("/tmp"));
974        state.entries = (0..20)
975            .map(|i| {
976                FileEntry::new(
977                    format!("file{}.txt", i),
978                    PathBuf::from(format!("/tmp/file{}.txt", i)),
979                    EntryType::File {
980                        extension: Some("txt".into()),
981                        size: 100,
982                    },
983                )
984            })
985            .collect();
986
987        state.cursor_index = 15;
988        state.ensure_visible(10);
989        assert!(state.scroll >= 6); // 15 - 10 + 1 = 6
990    }
991
992    #[test]
993    fn test_ensure_visible_zero_viewport() {
994        let mut state = FileExplorerState::new(PathBuf::from("/tmp"));
995        state.cursor_index = 5;
996        state.scroll = 3;
997        state.ensure_visible(0);
998        assert_eq!(state.scroll, 3); // Should not change
999    }
1000
1001    #[test]
1002    fn test_style_color_for_extension() {
1003        let style = FileExplorerStyle::default();
1004        assert_eq!(style.color_for_extension(Some("rs")), Color::Yellow);
1005        assert_eq!(style.color_for_extension(Some("json")), Color::Green);
1006        assert_eq!(style.color_for_extension(Some("unknown")), Color::Gray);
1007        assert_eq!(style.color_for_extension(None), Color::Gray);
1008    }
1009
1010    #[test]
1011    fn test_style_color_for_various_extensions() {
1012        let style = FileExplorerStyle::default();
1013        assert_eq!(style.color_for_extension(Some("toml")), Color::Green);
1014        assert_eq!(style.color_for_extension(Some("yaml")), Color::Green);
1015        assert_eq!(style.color_for_extension(Some("md")), Color::White);
1016        assert_eq!(style.color_for_extension(Some("py")), Color::Cyan);
1017        assert_eq!(style.color_for_extension(Some("js")), Color::Magenta);
1018        assert_eq!(style.color_for_extension(Some("sh")), Color::Red);
1019    }
1020
1021    #[test]
1022    fn test_file_explorer_render() {
1023        let mut state = FileExplorerState::new(PathBuf::from("/tmp"));
1024        state.entries = vec![
1025            FileEntry::new("dir", PathBuf::from("/tmp/dir"), EntryType::Directory),
1026            FileEntry::new(
1027                "file.txt",
1028                PathBuf::from("/tmp/file.txt"),
1029                EntryType::File {
1030                    extension: Some("txt".into()),
1031                    size: 1024,
1032                },
1033            ),
1034        ];
1035
1036        let explorer = FileExplorer::new(&state);
1037        let mut buf = Buffer::empty(Rect::new(0, 0, 60, 20));
1038        explorer.render(Rect::new(0, 0, 60, 20), &mut buf);
1039        // Should not panic
1040    }
1041}