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 FileExplorerStyle {
391 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
404pub struct FileExplorer<'a> {
406 state: &'a FileExplorerState,
407 style: FileExplorerStyle,
408}
409
410impl<'a> FileExplorer<'a> {
411 pub fn new(state: &'a FileExplorerState) -> Self {
413 Self {
414 state,
415 style: FileExplorerStyle::default(),
416 }
417 }
418
419 pub fn style(mut self, style: FileExplorerStyle) -> Self {
421 self.style = style;
422 self
423 }
424
425 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 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 let chunks = Layout::default()
550 .direction(Direction::Vertical)
551 .constraints([
552 Constraint::Min(1), Constraint::Length(3), ])
555 .split(area);
556
557 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 let lines = self.build_lines(inner);
579 let paragraph = Paragraph::new(lines);
580 paragraph.render(inner, buf);
581
582 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
594fn 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
628pub 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()); }
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(); 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); }
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 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); }
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); }
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 }
1005}