use std::collections::HashSet;
use std::path::PathBuf;
use ratatui::{
Frame,
buffer::Buffer,
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, Paragraph, Widget},
};
use crate::utils::display::format_size;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EntryType {
File {
extension: Option<String>,
size: u64,
},
Directory,
ParentDir,
Symlink { target: Option<PathBuf> },
}
#[derive(Debug, Clone)]
pub struct FileEntry {
pub name: String,
pub path: PathBuf,
pub entry_type: EntryType,
}
impl FileEntry {
pub fn new(name: impl Into<String>, path: PathBuf, entry_type: EntryType) -> Self {
Self {
name: name.into(),
path,
entry_type,
}
}
pub fn parent_dir(parent_path: PathBuf) -> Self {
Self {
name: "..".into(),
path: parent_path,
entry_type: EntryType::ParentDir,
}
}
pub fn is_dir(&self) -> bool {
matches!(self.entry_type, EntryType::Directory | EntryType::ParentDir)
}
pub fn is_selectable(&self) -> bool {
matches!(self.entry_type, EntryType::File { .. })
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum FileExplorerMode {
#[default]
Browse,
Search,
}
#[derive(Debug, Clone)]
pub struct FileExplorerState {
pub current_dir: PathBuf,
pub entries: Vec<FileEntry>,
pub cursor_index: usize,
pub scroll: u16,
pub selected_files: HashSet<PathBuf>,
pub show_hidden: bool,
pub mode: FileExplorerMode,
pub search_query: String,
pub filtered_indices: Option<Vec<usize>>,
}
impl FileExplorerState {
pub fn new(start_dir: PathBuf) -> Self {
Self {
current_dir: start_dir,
entries: Vec::new(),
cursor_index: 0,
scroll: 0,
selected_files: HashSet::new(),
show_hidden: false,
mode: FileExplorerMode::Browse,
search_query: String::new(),
filtered_indices: None,
}
}
#[cfg(feature = "filesystem")]
pub fn load_entries(&mut self) -> std::io::Result<()> {
self.entries.clear();
self.cursor_index = 0;
self.scroll = 0;
self.filtered_indices = None;
if let Some(parent) = self.current_dir.parent() {
self.entries
.push(FileEntry::parent_dir(parent.to_path_buf()));
}
let mut dirs = Vec::new();
let mut files = Vec::new();
for entry in std::fs::read_dir(&self.current_dir)? {
let entry = entry?;
let path = entry.path();
let name = entry.file_name().to_string_lossy().to_string();
if !self.show_hidden && name.starts_with('.') {
continue;
}
let metadata = entry.metadata()?;
let entry_type = if metadata.is_dir() {
EntryType::Directory
} else if metadata.is_symlink() {
EntryType::Symlink {
target: std::fs::read_link(&path).ok(),
}
} else {
EntryType::File {
extension: path.extension().map(|e| e.to_string_lossy().to_string()),
size: metadata.len(),
}
};
let file_entry = FileEntry::new(name, path, entry_type);
if file_entry.is_dir() {
dirs.push(file_entry);
} else {
files.push(file_entry);
}
}
dirs.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
files.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
self.entries.extend(dirs);
self.entries.extend(files);
Ok(())
}
pub fn enter_directory(&mut self, path: PathBuf) {
self.current_dir = path;
#[cfg(feature = "filesystem")]
let _ = self.load_entries();
}
pub fn go_up(&mut self) {
if let Some(parent) = self.current_dir.parent() {
self.current_dir = parent.to_path_buf();
#[cfg(feature = "filesystem")]
let _ = self.load_entries();
}
}
pub fn cursor_up(&mut self) {
let count = self.visible_count();
if count > 0 && self.cursor_index > 0 {
self.cursor_index -= 1;
}
}
pub fn cursor_down(&mut self) {
let count = self.visible_count();
if count > 0 && self.cursor_index + 1 < count {
self.cursor_index += 1;
}
}
pub fn visible_count(&self) -> usize {
self.filtered_indices
.as_ref()
.map(|i| i.len())
.unwrap_or(self.entries.len())
}
pub fn current_entry(&self) -> Option<&FileEntry> {
if let Some(ref indices) = self.filtered_indices {
indices
.get(self.cursor_index)
.and_then(|&i| self.entries.get(i))
} else {
self.entries.get(self.cursor_index)
}
}
pub fn toggle_selection(&mut self) {
if let Some(entry) = self.current_entry() {
if entry.is_selectable() {
let path = entry.path.clone();
if self.selected_files.contains(&path) {
self.selected_files.remove(&path);
} else {
self.selected_files.insert(path);
}
}
}
}
pub fn select_all(&mut self) {
for entry in &self.entries {
if entry.is_selectable() {
self.selected_files.insert(entry.path.clone());
}
}
}
pub fn select_none(&mut self) {
self.selected_files.clear();
}
pub fn toggle_hidden(&mut self) {
self.show_hidden = !self.show_hidden;
#[cfg(feature = "filesystem")]
let _ = self.load_entries();
}
pub fn start_search(&mut self) {
self.mode = FileExplorerMode::Search;
self.search_query.clear();
}
pub fn cancel_search(&mut self) {
self.mode = FileExplorerMode::Browse;
self.search_query.clear();
self.filtered_indices = None;
}
pub fn update_filter(&mut self) {
if self.search_query.is_empty() {
self.filtered_indices = None;
} else {
let query = self.search_query.to_lowercase();
self.filtered_indices = Some(
self.entries
.iter()
.enumerate()
.filter(|(_, e)| e.name.to_lowercase().contains(&query))
.map(|(i, _)| i)
.collect(),
);
self.cursor_index = 0;
}
}
pub fn ensure_visible(&mut self, viewport_height: usize) {
if viewport_height == 0 {
return;
}
if self.cursor_index < self.scroll as usize {
self.scroll = self.cursor_index as u16;
} else if self.cursor_index >= self.scroll as usize + viewport_height {
self.scroll = (self.cursor_index - viewport_height + 1) as u16;
}
}
}
#[derive(Debug, Clone)]
pub struct FileExplorerStyle {
pub border_style: Style,
pub cursor_style: Style,
pub dir_style: Style,
pub file_colors: Vec<(Vec<&'static str>, Color)>,
pub default_file_color: Color,
pub size_style: Style,
pub checkbox_checked: &'static str,
pub checkbox_unchecked: &'static str,
pub dir_icon: &'static str,
pub parent_icon: &'static str,
pub symlink_icon: &'static str,
}
impl Default for FileExplorerStyle {
fn default() -> Self {
Self {
border_style: Style::default().fg(Color::Cyan),
cursor_style: Style::default()
.fg(Color::Black)
.bg(Color::Cyan)
.add_modifier(Modifier::BOLD),
dir_style: Style::default()
.fg(Color::Blue)
.add_modifier(Modifier::BOLD),
file_colors: vec![
(vec!["rs"], Color::Yellow),
(vec!["toml", "json", "yaml", "yml"], Color::Green),
(vec!["md", "txt", "rst"], Color::White),
(vec!["py"], Color::Cyan),
(vec!["js", "ts", "tsx", "jsx"], Color::Magenta),
(vec!["sh", "bash", "zsh"], Color::Red),
],
default_file_color: Color::Gray,
size_style: Style::default().fg(Color::DarkGray),
checkbox_checked: "[x]",
checkbox_unchecked: "[ ]",
dir_icon: "[DIR]",
parent_icon: " .. ",
symlink_icon: "[LNK]",
}
}
}
impl From<&crate::theme::Theme> for FileExplorerStyle {
fn from(theme: &crate::theme::Theme) -> Self {
let p = &theme.palette;
Self {
border_style: Style::default().fg(p.border_accent),
cursor_style: Style::default()
.fg(p.highlight_fg)
.bg(p.secondary)
.add_modifier(Modifier::BOLD),
dir_style: Style::default()
.fg(Color::Blue)
.add_modifier(Modifier::BOLD),
file_colors: vec![
(vec!["rs"], Color::Yellow),
(vec!["toml", "json", "yaml", "yml"], Color::Green),
(vec!["md", "txt", "rst"], Color::White),
(vec!["py"], Color::Cyan),
(vec!["js", "ts", "tsx", "jsx"], Color::Magenta),
(vec!["sh", "bash", "zsh"], Color::Red),
],
default_file_color: Color::Gray,
size_style: Style::default().fg(Color::DarkGray),
checkbox_checked: "[x]",
checkbox_unchecked: "[ ]",
dir_icon: "[DIR]",
parent_icon: " .. ",
symlink_icon: "[LNK]",
}
}
}
impl FileExplorerStyle {
pub fn color_for_extension(&self, ext: Option<&str>) -> Color {
if let Some(ext) = ext {
for (extensions, color) in &self.file_colors {
if extensions.contains(&ext) {
return *color;
}
}
}
self.default_file_color
}
}
pub struct FileExplorer<'a> {
state: &'a FileExplorerState,
style: FileExplorerStyle,
}
impl<'a> FileExplorer<'a> {
pub fn new(state: &'a FileExplorerState) -> Self {
Self {
state,
style: FileExplorerStyle::default(),
}
}
pub fn style(mut self, style: FileExplorerStyle) -> Self {
self.style = style;
self
}
pub fn theme(self, theme: &crate::theme::Theme) -> Self {
self.style(FileExplorerStyle::from(theme))
}
fn build_lines(&self, inner: Rect) -> Vec<Line<'static>> {
let visible_height = inner.height as usize;
let scroll = self.state.scroll as usize;
let entries_to_show: Vec<(usize, &FileEntry)> =
if let Some(ref indices) = self.state.filtered_indices {
indices
.iter()
.map(|&i| (i, &self.state.entries[i]))
.collect()
} else {
self.state.entries.iter().enumerate().collect()
};
let mut lines = Vec::new();
for (display_idx, (_entry_idx, entry)) in entries_to_show
.iter()
.enumerate()
.skip(scroll)
.take(visible_height)
{
let is_cursor = display_idx == self.state.cursor_index;
let is_checked = self.state.selected_files.contains(&entry.path);
let style = if is_cursor {
self.style.cursor_style
} else {
Style::default()
};
let cursor = if is_cursor { ">" } else { " " };
let checkbox = match &entry.entry_type {
EntryType::File { .. } => {
if is_checked {
self.style.checkbox_checked
} else {
self.style.checkbox_unchecked
}
}
_ => " ",
};
let (icon, name_style) = match &entry.entry_type {
EntryType::Directory => (
self.style.dir_icon,
if is_cursor {
self.style.cursor_style
} else {
self.style.dir_style
},
),
EntryType::ParentDir => (
self.style.parent_icon,
if is_cursor {
self.style.cursor_style
} else {
self.style.dir_style
},
),
EntryType::File { extension, .. } => {
let color = self.style.color_for_extension(extension.as_deref());
(
" ",
if is_cursor {
self.style.cursor_style
} else {
Style::default().fg(color)
},
)
}
EntryType::Symlink { .. } => (
self.style.symlink_icon,
if is_cursor {
self.style.cursor_style
} else {
Style::default().fg(Color::Magenta)
},
),
};
let size_str = match &entry.entry_type {
EntryType::File { size, .. } => format_size(*size),
_ => String::new(),
};
let name_width = inner.width.saturating_sub(22) as usize;
let display_name = if entry.name.len() > name_width {
format!("{}...", &entry.name[..name_width.saturating_sub(3)])
} else {
entry.name.clone()
};
lines.push(Line::from(vec![
Span::styled(cursor.to_string(), style),
Span::styled(" ", style),
Span::styled(checkbox.to_string(), style),
Span::styled(" ", style),
Span::styled(icon.to_string(), style),
Span::styled(" ", style),
Span::styled(
format!("{:<width$}", display_name, width = name_width),
name_style,
),
Span::styled(
format!("{:>10}", size_str),
if is_cursor {
self.style.cursor_style
} else {
self.style.size_style
},
),
]));
}
lines
}
}
impl Widget for FileExplorer<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(1), Constraint::Length(3), ])
.split(area);
let selected_count = self.state.selected_files.len();
let title = if selected_count > 0 {
format!(
" {} ({} selected) ",
self.state.current_dir.display(),
selected_count
)
} else {
format!(" {} ", self.state.current_dir.display())
};
let block = Block::default()
.borders(Borders::ALL)
.border_style(self.style.border_style)
.title(title);
let inner = block.inner(chunks[0]);
block.render(chunks[0], buf);
let lines = self.build_lines(inner);
let paragraph = Paragraph::new(lines);
paragraph.render(inner, buf);
let footer = build_footer(self.state.mode);
let footer_block = Block::default()
.borders(Borders::TOP)
.border_style(Style::default().fg(Color::DarkGray));
let footer_para = Paragraph::new(footer)
.block(footer_block)
.alignment(Alignment::Center);
footer_para.render(chunks[1], buf);
}
}
fn build_footer(mode: FileExplorerMode) -> Vec<Line<'static>> {
match mode {
FileExplorerMode::Browse => vec![
Line::from(vec![
Span::styled("↑↓", Style::default().fg(Color::Green)),
Span::raw(":Move "),
Span::styled("Enter", Style::default().fg(Color::Green)),
Span::raw(":Open "),
Span::styled("Space", Style::default().fg(Color::Green)),
Span::raw(":Select "),
Span::styled("/", Style::default().fg(Color::Green)),
Span::raw(":Search "),
Span::styled(".", Style::default().fg(Color::Green)),
Span::raw(":Hidden"),
]),
Line::from(vec![
Span::styled("a", Style::default().fg(Color::Green)),
Span::raw(":All "),
Span::styled("n", Style::default().fg(Color::Green)),
Span::raw(":None "),
Span::styled("Esc", Style::default().fg(Color::Green)),
Span::raw(":Close"),
]),
],
FileExplorerMode::Search => vec![Line::from(vec![
Span::styled("Enter", Style::default().fg(Color::Green)),
Span::raw(":Confirm "),
Span::styled("Esc", Style::default().fg(Color::Green)),
Span::raw(":Cancel"),
])],
}
}
pub fn draw_search_bar(f: &mut Frame, query: &str, area: Rect) {
let search_text = Line::from(vec![
Span::styled(
"Search: ",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::styled(query.to_string(), Style::default().fg(Color::White)),
Span::styled(
"_",
Style::default()
.fg(Color::White)
.add_modifier(Modifier::SLOW_BLINK),
),
]);
let block = Block::default()
.borders(Borders::TOP)
.border_style(Style::default().fg(Color::Yellow));
let paragraph = Paragraph::new(vec![search_text]).block(block);
f.render_widget(Clear, area);
f.render_widget(paragraph, area);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_file_entry() {
let entry = FileEntry::new(
"test.rs",
PathBuf::from("/home/user/test.rs"),
EntryType::File {
extension: Some("rs".into()),
size: 1024,
},
);
assert!(!entry.is_dir());
assert!(entry.is_selectable());
let dir = FileEntry::new("src", PathBuf::from("/home/user/src"), EntryType::Directory);
assert!(dir.is_dir());
assert!(!dir.is_selectable());
}
#[test]
fn test_file_entry_parent_dir() {
let entry = FileEntry::parent_dir(PathBuf::from("/home"));
assert_eq!(entry.name, "..");
assert!(entry.is_dir());
assert!(!entry.is_selectable());
assert_eq!(entry.entry_type, EntryType::ParentDir);
}
#[test]
fn test_file_entry_symlink() {
let entry = FileEntry::new(
"link",
PathBuf::from("/home/user/link"),
EntryType::Symlink {
target: Some(PathBuf::from("/target")),
},
);
assert!(!entry.is_dir());
assert!(!entry.is_selectable()); }
#[test]
fn test_state_new() {
let state = FileExplorerState::new(PathBuf::from("/tmp"));
assert_eq!(state.current_dir, PathBuf::from("/tmp"));
assert!(state.entries.is_empty());
assert_eq!(state.cursor_index, 0);
assert!(!state.show_hidden);
assert_eq!(state.mode, FileExplorerMode::Browse);
}
#[test]
fn test_state_navigation() {
let mut state = FileExplorerState::new(PathBuf::from("/tmp"));
state.entries = vec![
FileEntry::parent_dir(PathBuf::from("/")),
FileEntry::new(
"file1.txt",
PathBuf::from("/tmp/file1.txt"),
EntryType::File {
extension: Some("txt".into()),
size: 100,
},
),
FileEntry::new(
"file2.txt",
PathBuf::from("/tmp/file2.txt"),
EntryType::File {
extension: Some("txt".into()),
size: 200,
},
),
];
assert_eq!(state.cursor_index, 0);
state.cursor_down();
assert_eq!(state.cursor_index, 1);
state.cursor_down();
assert_eq!(state.cursor_index, 2);
state.cursor_down(); assert_eq!(state.cursor_index, 2);
state.cursor_up();
assert_eq!(state.cursor_index, 1);
}
#[test]
fn test_cursor_up_at_top() {
let mut state = FileExplorerState::new(PathBuf::from("/tmp"));
state.entries = vec![FileEntry::new(
"file.txt",
PathBuf::from("/tmp/file.txt"),
EntryType::File {
extension: Some("txt".into()),
size: 100,
},
)];
state.cursor_index = 0;
state.cursor_up();
assert_eq!(state.cursor_index, 0); }
#[test]
fn test_selection() {
let mut state = FileExplorerState::new(PathBuf::from("/tmp"));
state.entries = vec![FileEntry::new(
"file.txt",
PathBuf::from("/tmp/file.txt"),
EntryType::File {
extension: Some("txt".into()),
size: 100,
},
)];
assert!(state.selected_files.is_empty());
state.toggle_selection();
assert_eq!(state.selected_files.len(), 1);
state.toggle_selection();
assert!(state.selected_files.is_empty());
}
#[test]
fn test_select_all() {
let mut state = FileExplorerState::new(PathBuf::from("/tmp"));
state.entries = vec![
FileEntry::new("dir", PathBuf::from("/tmp/dir"), EntryType::Directory),
FileEntry::new(
"file1.txt",
PathBuf::from("/tmp/file1.txt"),
EntryType::File {
extension: Some("txt".into()),
size: 100,
},
),
FileEntry::new(
"file2.txt",
PathBuf::from("/tmp/file2.txt"),
EntryType::File {
extension: Some("txt".into()),
size: 200,
},
),
];
state.select_all();
assert_eq!(state.selected_files.len(), 2);
}
#[test]
fn test_select_none() {
let mut state = FileExplorerState::new(PathBuf::from("/tmp"));
state.entries = vec![FileEntry::new(
"file.txt",
PathBuf::from("/tmp/file.txt"),
EntryType::File {
extension: Some("txt".into()),
size: 100,
},
)];
state.toggle_selection();
assert_eq!(state.selected_files.len(), 1);
state.select_none();
assert!(state.selected_files.is_empty());
}
#[test]
fn test_toggle_hidden() {
let mut state = FileExplorerState::new(PathBuf::from("/tmp"));
assert!(!state.show_hidden);
state.toggle_hidden();
assert!(state.show_hidden);
state.toggle_hidden();
assert!(!state.show_hidden);
}
#[test]
fn test_search_mode() {
let mut state = FileExplorerState::new(PathBuf::from("/tmp"));
assert_eq!(state.mode, FileExplorerMode::Browse);
state.start_search();
assert_eq!(state.mode, FileExplorerMode::Search);
assert!(state.search_query.is_empty());
state.cancel_search();
assert_eq!(state.mode, FileExplorerMode::Browse);
assert!(state.filtered_indices.is_none());
}
#[test]
fn test_update_filter() {
let mut state = FileExplorerState::new(PathBuf::from("/tmp"));
state.entries = vec![
FileEntry::new(
"test.rs",
PathBuf::from("/tmp/test.rs"),
EntryType::File {
extension: Some("rs".into()),
size: 100,
},
),
FileEntry::new(
"main.rs",
PathBuf::from("/tmp/main.rs"),
EntryType::File {
extension: Some("rs".into()),
size: 200,
},
),
FileEntry::new(
"other.txt",
PathBuf::from("/tmp/other.txt"),
EntryType::File {
extension: Some("txt".into()),
size: 300,
},
),
];
state.search_query = "test".into();
state.update_filter();
assert!(state.filtered_indices.is_some());
assert_eq!(state.filtered_indices.as_ref().unwrap().len(), 1);
assert_eq!(state.visible_count(), 1);
}
#[test]
fn test_update_filter_empty_clears() {
let mut state = FileExplorerState::new(PathBuf::from("/tmp"));
state.entries = vec![FileEntry::new(
"file.txt",
PathBuf::from("/tmp/file.txt"),
EntryType::File {
extension: Some("txt".into()),
size: 100,
},
)];
state.search_query = "file".into();
state.update_filter();
assert!(state.filtered_indices.is_some());
state.search_query = "".into();
state.update_filter();
assert!(state.filtered_indices.is_none());
}
#[test]
fn test_current_entry() {
let mut state = FileExplorerState::new(PathBuf::from("/tmp"));
state.entries = vec![
FileEntry::new(
"first.txt",
PathBuf::from("/tmp/first.txt"),
EntryType::File {
extension: Some("txt".into()),
size: 100,
},
),
FileEntry::new(
"second.txt",
PathBuf::from("/tmp/second.txt"),
EntryType::File {
extension: Some("txt".into()),
size: 200,
},
),
];
assert_eq!(state.current_entry().unwrap().name, "first.txt");
state.cursor_down();
assert_eq!(state.current_entry().unwrap().name, "second.txt");
}
#[test]
fn test_ensure_visible() {
let mut state = FileExplorerState::new(PathBuf::from("/tmp"));
state.entries = (0..20)
.map(|i| {
FileEntry::new(
format!("file{}.txt", i),
PathBuf::from(format!("/tmp/file{}.txt", i)),
EntryType::File {
extension: Some("txt".into()),
size: 100,
},
)
})
.collect();
state.cursor_index = 15;
state.ensure_visible(10);
assert!(state.scroll >= 6); }
#[test]
fn test_ensure_visible_zero_viewport() {
let mut state = FileExplorerState::new(PathBuf::from("/tmp"));
state.cursor_index = 5;
state.scroll = 3;
state.ensure_visible(0);
assert_eq!(state.scroll, 3); }
#[test]
fn test_style_color_for_extension() {
let style = FileExplorerStyle::default();
assert_eq!(style.color_for_extension(Some("rs")), Color::Yellow);
assert_eq!(style.color_for_extension(Some("json")), Color::Green);
assert_eq!(style.color_for_extension(Some("unknown")), Color::Gray);
assert_eq!(style.color_for_extension(None), Color::Gray);
}
#[test]
fn test_style_color_for_various_extensions() {
let style = FileExplorerStyle::default();
assert_eq!(style.color_for_extension(Some("toml")), Color::Green);
assert_eq!(style.color_for_extension(Some("yaml")), Color::Green);
assert_eq!(style.color_for_extension(Some("md")), Color::White);
assert_eq!(style.color_for_extension(Some("py")), Color::Cyan);
assert_eq!(style.color_for_extension(Some("js")), Color::Magenta);
assert_eq!(style.color_for_extension(Some("sh")), Color::Red);
}
#[test]
fn test_file_explorer_render() {
let mut state = FileExplorerState::new(PathBuf::from("/tmp"));
state.entries = vec![
FileEntry::new("dir", PathBuf::from("/tmp/dir"), EntryType::Directory),
FileEntry::new(
"file.txt",
PathBuf::from("/tmp/file.txt"),
EntryType::File {
extension: Some("txt".into()),
size: 1024,
},
),
];
let explorer = FileExplorer::new(&state);
let mut buf = Buffer::empty(Rect::new(0, 0, 60, 20));
explorer.render(Rect::new(0, 0, 60, 20), &mut buf);
}
}