1use 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#[derive(Debug, Clone, PartialEq, Eq)]
38pub enum EntryType {
39 File {
41 extension: Option<String>,
42 size: u64,
43 },
44 Directory,
46 ParentDir,
48 Symlink { target: Option<PathBuf> },
50}
51
52#[derive(Debug, Clone)]
54pub struct FileEntry {
55 pub name: String,
57 pub path: PathBuf,
59 pub entry_type: EntryType,
61}
62
63impl FileEntry {
64 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 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 pub fn is_dir(&self) -> bool {
84 matches!(self.entry_type, EntryType::Directory | EntryType::ParentDir)
85 }
86
87 pub fn is_selectable(&self) -> bool {
89 matches!(self.entry_type, EntryType::File { .. })
90 }
91}
92
93#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
95pub enum FileExplorerMode {
96 #[default]
98 Browse,
99 Search,
101}
102
103#[derive(Debug, Clone)]
105pub struct FileExplorerState {
106 pub current_dir: PathBuf,
108 pub entries: Vec<FileEntry>,
110 pub cursor_index: usize,
112 pub scroll: u16,
114 pub selected_files: HashSet<PathBuf>,
116 pub show_hidden: bool,
118 pub mode: FileExplorerMode,
120 pub search_query: String,
122 pub filtered_indices: Option<Vec<usize>>,
124}
125
126impl FileExplorerState {
127 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 #[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 if let Some(parent) = self.current_dir.parent() {
152 self.entries
153 .push(FileEntry::parent_dir(parent.to_path_buf()));
154 }
155
156 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 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 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 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 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 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 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 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 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 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 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 pub fn select_none(&mut self) {
278 self.selected_files.clear();
279 }
280
281 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 pub fn start_search(&mut self) {
290 self.mode = FileExplorerMode::Search;
291 self.search_query.clear();
292 }
293
294 pub fn cancel_search(&mut self) {
296 self.mode = FileExplorerMode::Browse;
297 self.search_query.clear();
298 self.filtered_indices = None;
299 }
300
301 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 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#[derive(Debug, Clone)]
335pub struct FileExplorerStyle {
336 pub border_style: Style,
338 pub cursor_style: Style,
340 pub dir_style: Style,
342 pub file_colors: Vec<(Vec<&'static str>, Color)>,
344 pub default_file_color: Color,
346 pub size_style: Style,
348 pub checkbox_checked: &'static str,
350 pub checkbox_unchecked: &'static str,
352 pub dir_icon: &'static str,
354 pub parent_icon: &'static str,
356 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 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
435pub struct FileExplorer<'a> {
437 state: &'a FileExplorerState,
438 style: FileExplorerStyle,
439}
440
441impl<'a> FileExplorer<'a> {
442 pub fn new(state: &'a FileExplorerState) -> Self {
444 Self {
445 state,
446 style: FileExplorerStyle::default(),
447 }
448 }
449
450 pub fn style(mut self, style: FileExplorerStyle) -> Self {
452 self.style = style;
453 self
454 }
455
456 pub fn theme(self, theme: &crate::theme::Theme) -> Self {
458 self.style(FileExplorerStyle::from(theme))
459 }
460
461 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 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 let chunks = Layout::default()
586 .direction(Direction::Vertical)
587 .constraints([
588 Constraint::Min(1), Constraint::Length(3), ])
591 .split(area);
592
593 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 let lines = self.build_lines(inner);
615 let paragraph = Paragraph::new(lines);
616 paragraph.render(inner, buf);
617
618 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
630fn 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
664pub 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()); }
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(); 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); }
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 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); }
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); }
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 }
1041}