use std::path::{Path, PathBuf};
use crate::components::Text;
use crate::core::{Color, Element};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FileType {
File,
Directory,
Symlink,
Hidden,
}
impl FileType {
pub fn icon(&self) -> &'static str {
match self {
FileType::File => "📄",
FileType::Directory => "📁",
FileType::Symlink => "🔗",
FileType::Hidden => "👁",
}
}
pub fn simple_icon(&self) -> &'static str {
match self {
FileType::File => "-",
FileType::Directory => "d",
FileType::Symlink => "l",
FileType::Hidden => ".",
}
}
}
#[derive(Debug, Clone)]
pub struct FileEntry {
pub name: String,
pub path: PathBuf,
pub file_type: FileType,
pub size: Option<u64>,
pub is_hidden: bool,
}
impl FileEntry {
pub fn new(name: impl Into<String>, path: PathBuf, file_type: FileType) -> Self {
let name = name.into();
let is_hidden = name.starts_with('.');
Self {
name,
path,
file_type,
size: None,
is_hidden,
}
}
pub fn directory(name: impl Into<String>, path: PathBuf) -> Self {
Self::new(name, path, FileType::Directory)
}
pub fn file(name: impl Into<String>, path: PathBuf) -> Self {
Self::new(name, path, FileType::File)
}
pub fn with_size(mut self, size: u64) -> Self {
self.size = Some(size);
self
}
pub fn is_directory(&self) -> bool {
self.file_type == FileType::Directory
}
pub fn is_file(&self) -> bool {
self.file_type == FileType::File
}
}
#[derive(Debug, Clone, Default)]
pub enum FileFilter {
#[default]
All,
DirectoriesOnly,
FilesOnly,
Extensions(Vec<String>),
Custom(String),
}
impl FileFilter {
pub fn matches(&self, entry: &FileEntry) -> bool {
match self {
FileFilter::All => true,
FileFilter::DirectoriesOnly => entry.is_directory(),
FileFilter::FilesOnly => entry.is_file(),
FileFilter::Extensions(exts) => {
if entry.is_directory() {
true
} else {
exts.iter().any(|ext| entry.name.ends_with(ext))
}
}
FileFilter::Custom(_) => true, }
}
}
#[derive(Debug, Clone)]
pub struct FilePickerStyle {
pub show_icons: bool,
pub emoji_icons: bool,
pub show_sizes: bool,
pub show_hidden: bool,
pub dir_color: Color,
pub file_color: Color,
pub selected_color: Color,
pub cursor_color: Color,
}
impl Default for FilePickerStyle {
fn default() -> Self {
Self {
show_icons: true,
emoji_icons: false,
show_sizes: false,
show_hidden: false,
dir_color: Color::Blue,
file_color: Color::White,
selected_color: Color::Green,
cursor_color: Color::Cyan,
}
}
}
impl FilePickerStyle {
pub fn new() -> Self {
Self::default()
}
pub fn show_icons(mut self, show: bool) -> Self {
self.show_icons = show;
self
}
pub fn emoji_icons(mut self, emoji: bool) -> Self {
self.emoji_icons = emoji;
self
}
pub fn show_sizes(mut self, show: bool) -> Self {
self.show_sizes = show;
self
}
pub fn show_hidden(mut self, show: bool) -> Self {
self.show_hidden = show;
self
}
pub fn dir_color(mut self, color: Color) -> Self {
self.dir_color = color;
self
}
pub fn file_color(mut self, color: Color) -> Self {
self.file_color = color;
self
}
pub fn minimal() -> Self {
Self::new().show_icons(false).show_sizes(false)
}
pub fn detailed() -> Self {
Self::new()
.show_icons(true)
.show_sizes(true)
.emoji_icons(true)
}
}
#[derive(Debug, Clone)]
pub struct FilePickerState {
current_dir: PathBuf,
entries: Vec<FileEntry>,
cursor: usize,
selected: Vec<PathBuf>,
filter: FileFilter,
style: FilePickerStyle,
search: String,
multi_select: bool,
history: Vec<PathBuf>,
}
impl Default for FilePickerState {
fn default() -> Self {
Self::new(PathBuf::from("."))
}
}
impl FilePickerState {
pub fn new(path: PathBuf) -> Self {
Self {
current_dir: path,
entries: Vec::new(),
cursor: 0,
selected: Vec::new(),
filter: FileFilter::All,
style: FilePickerStyle::default(),
search: String::new(),
multi_select: false,
history: Vec::new(),
}
}
pub fn filter(mut self, filter: FileFilter) -> Self {
self.filter = filter;
self
}
pub fn style(mut self, style: FilePickerStyle) -> Self {
self.style = style;
self
}
pub fn multi_select(mut self, enabled: bool) -> Self {
self.multi_select = enabled;
self
}
pub fn current_dir(&self) -> &Path {
&self.current_dir
}
pub fn entries(&self) -> &[FileEntry] {
&self.entries
}
pub fn visible_entries(&self) -> Vec<&FileEntry> {
self.entries
.iter()
.filter(|e| {
if !self.filter.matches(e) {
return false;
}
if !self.style.show_hidden && e.is_hidden {
return false;
}
if !self.search.is_empty() {
return e.name.to_lowercase().contains(&self.search.to_lowercase());
}
true
})
.collect()
}
pub fn cursor(&self) -> usize {
self.cursor
}
pub fn focused(&self) -> Option<&FileEntry> {
let visible = self.visible_entries();
visible.get(self.cursor).copied()
}
pub fn selected(&self) -> &[PathBuf] {
&self.selected
}
pub fn is_selected(&self, path: &Path) -> bool {
self.selected.iter().any(|p| p == path)
}
pub fn search(&self) -> &str {
&self.search
}
pub fn set_search(&mut self, search: impl Into<String>) {
self.search = search.into();
self.cursor = 0;
}
pub fn clear_search(&mut self) {
self.search.clear();
}
pub fn set_entries(&mut self, entries: Vec<FileEntry>) {
self.entries = entries;
self.cursor = 0;
}
pub fn cursor_up(&mut self) {
let visible_count = self.visible_entries().len();
if visible_count > 0 && self.cursor > 0 {
self.cursor -= 1;
}
}
pub fn cursor_down(&mut self) {
let visible_count = self.visible_entries().len();
if visible_count > 0 && self.cursor < visible_count - 1 {
self.cursor += 1;
}
}
pub fn cursor_first(&mut self) {
self.cursor = 0;
}
pub fn cursor_last(&mut self) {
let visible_count = self.visible_entries().len();
if visible_count > 0 {
self.cursor = visible_count - 1;
}
}
pub fn page_up(&mut self, page_size: usize) {
self.cursor = self.cursor.saturating_sub(page_size);
}
pub fn page_down(&mut self, page_size: usize) {
let visible_count = self.visible_entries().len();
if visible_count > 0 {
self.cursor = (self.cursor + page_size).min(visible_count - 1);
}
}
pub fn toggle_selection(&mut self) {
if let Some(entry) = self.focused() {
let path = entry.path.clone();
if self.is_selected(&path) {
self.selected.retain(|p| p != &path);
} else {
if !self.multi_select {
self.selected.clear();
}
self.selected.push(path);
}
}
}
pub fn select(&mut self) {
if let Some(entry) = self.focused() {
let path = entry.path.clone();
if !self.is_selected(&path) {
if !self.multi_select {
self.selected.clear();
}
self.selected.push(path);
}
}
}
pub fn clear_selection(&mut self) {
self.selected.clear();
}
pub fn enter_directory(&mut self) -> Option<PathBuf> {
let new_dir = {
let entry = self.focused()?;
if !entry.is_directory() {
return None;
}
entry.path.clone()
};
self.history.push(self.current_dir.clone());
self.current_dir = new_dir.clone();
self.cursor = 0;
self.search.clear();
Some(new_dir)
}
pub fn go_parent(&mut self) -> Option<PathBuf> {
if let Some(parent) = self.current_dir.parent() {
self.history.push(self.current_dir.clone());
let parent_path = parent.to_path_buf();
self.current_dir = parent_path.clone();
self.cursor = 0;
self.search.clear();
return Some(parent_path);
}
None
}
pub fn go_back(&mut self) -> Option<PathBuf> {
if let Some(prev) = self.history.pop() {
self.current_dir = prev.clone();
self.cursor = 0;
self.search.clear();
return Some(prev);
}
None
}
pub fn navigate_to(&mut self, path: PathBuf) {
self.history.push(self.current_dir.clone());
self.current_dir = path;
self.cursor = 0;
self.search.clear();
}
pub fn get_style(&self) -> &FilePickerStyle {
&self.style
}
pub fn toggle_hidden(&mut self) {
self.style.show_hidden = !self.style.show_hidden;
self.cursor = 0;
}
}
#[derive(Debug)]
pub struct FilePicker<'a> {
state: &'a FilePickerState,
max_visible: usize,
show_path: bool,
show_status: bool,
}
impl<'a> FilePicker<'a> {
pub fn new(state: &'a FilePickerState) -> Self {
Self {
state,
max_visible: 10,
show_path: true,
show_status: true,
}
}
pub fn max_visible(mut self, max: usize) -> Self {
self.max_visible = max;
self
}
pub fn show_path(mut self, show: bool) -> Self {
self.show_path = show;
self
}
pub fn show_status(mut self, show: bool) -> Self {
self.show_status = show;
self
}
pub fn render(&self) -> String {
let mut output = String::new();
let style = self.state.get_style();
let visible = self.state.visible_entries();
if self.show_path {
output.push_str(&format!(
"\x1b[1m{}\x1b[0m\n",
self.state.current_dir.display()
));
output.push_str(&"─".repeat(40));
output.push('\n');
}
if !self.state.search.is_empty() {
output.push_str(&format!("🔍 {}\n", self.state.search));
}
let total = visible.len();
let start = if total <= self.max_visible || self.state.cursor < self.max_visible / 2 {
0
} else if self.state.cursor > total - self.max_visible / 2 {
total - self.max_visible
} else {
self.state.cursor - self.max_visible / 2
};
let end = (start + self.max_visible).min(total);
if start > 0 {
output.push_str(&format!(" \x1b[90m↑ {} more above\x1b[0m\n", start));
}
for (i, entry) in visible.iter().enumerate().skip(start).take(end - start) {
let is_focused = i == self.state.cursor;
let is_selected = self.state.is_selected(&entry.path);
if is_focused {
output.push_str("\x1b[7m"); }
if is_selected {
output.push_str("✓ ");
} else {
output.push_str(" ");
}
if style.show_icons {
let icon = if style.emoji_icons {
entry.file_type.icon()
} else {
entry.file_type.simple_icon()
};
output.push_str(icon);
output.push(' ');
}
let color = if entry.is_directory() {
style.dir_color
} else {
style.file_color
};
output.push_str(&color.to_ansi_fg());
output.push_str(&entry.name);
output.push_str("\x1b[0m");
if style.show_sizes {
if let Some(size) = entry.size {
output.push_str(&format!(" {}", format_size(size)));
}
}
if is_focused {
output.push_str("\x1b[0m");
}
output.push('\n');
}
if end < total {
output.push_str(&format!(" \x1b[90m↓ {} more below\x1b[0m\n", total - end));
}
if self.show_status {
output.push_str(&"─".repeat(40));
output.push('\n');
output.push_str(&format!("{}/{} items", self.state.cursor + 1, total));
if !self.state.selected.is_empty() {
output.push_str(&format!(" | {} selected", self.state.selected.len()));
}
}
output
}
pub fn into_element(self) -> Element {
Text::new(self.render()).into_element()
}
}
fn format_size(bytes: u64) -> String {
const KB: u64 = 1024;
const MB: u64 = KB * 1024;
const GB: u64 = MB * 1024;
if bytes >= GB {
format!("{:.1}G", bytes as f64 / GB as f64)
} else if bytes >= MB {
format!("{:.1}M", bytes as f64 / MB as f64)
} else if bytes >= KB {
format!("{:.1}K", bytes as f64 / KB as f64)
} else {
format!("{}B", bytes)
}
}
pub fn file_picker(state: &FilePickerState) -> FilePicker<'_> {
FilePicker::new(state)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_file_type_icons() {
assert_eq!(FileType::File.icon(), "📄");
assert_eq!(FileType::Directory.icon(), "📁");
assert_eq!(FileType::File.simple_icon(), "-");
assert_eq!(FileType::Directory.simple_icon(), "d");
}
#[test]
fn test_file_entry_creation() {
let entry = FileEntry::file("test.txt", PathBuf::from("/test.txt"));
assert!(entry.is_file());
assert!(!entry.is_directory());
assert!(!entry.is_hidden);
let dir = FileEntry::directory("src", PathBuf::from("/src"));
assert!(dir.is_directory());
assert!(!dir.is_file());
}
#[test]
fn test_file_entry_hidden() {
let hidden = FileEntry::file(".gitignore", PathBuf::from("/.gitignore"));
assert!(hidden.is_hidden);
let visible = FileEntry::file("README.md", PathBuf::from("/README.md"));
assert!(!visible.is_hidden);
}
#[test]
fn test_file_filter() {
let file = FileEntry::file("test.rs", PathBuf::from("/test.rs"));
let dir = FileEntry::directory("src", PathBuf::from("/src"));
assert!(FileFilter::All.matches(&file));
assert!(FileFilter::All.matches(&dir));
assert!(FileFilter::FilesOnly.matches(&file));
assert!(!FileFilter::FilesOnly.matches(&dir));
assert!(!FileFilter::DirectoriesOnly.matches(&file));
assert!(FileFilter::DirectoriesOnly.matches(&dir));
let ext_filter = FileFilter::Extensions(vec![".rs".to_string()]);
assert!(ext_filter.matches(&file));
assert!(ext_filter.matches(&dir)); }
#[test]
fn test_file_picker_state_navigation() {
let mut state = FilePickerState::new(PathBuf::from("/home"));
state.set_entries(vec![
FileEntry::directory("dir1", PathBuf::from("/home/dir1")),
FileEntry::file("file1.txt", PathBuf::from("/home/file1.txt")),
FileEntry::file("file2.txt", PathBuf::from("/home/file2.txt")),
]);
assert_eq!(state.cursor(), 0);
state.cursor_down();
assert_eq!(state.cursor(), 1);
state.cursor_down();
assert_eq!(state.cursor(), 2);
state.cursor_down(); assert_eq!(state.cursor(), 2);
state.cursor_up();
assert_eq!(state.cursor(), 1);
state.cursor_first();
assert_eq!(state.cursor(), 0);
state.cursor_last();
assert_eq!(state.cursor(), 2);
}
#[test]
fn test_file_picker_state_selection() {
let mut state = FilePickerState::new(PathBuf::from("/home"));
state.set_entries(vec![
FileEntry::file("file1.txt", PathBuf::from("/home/file1.txt")),
FileEntry::file("file2.txt", PathBuf::from("/home/file2.txt")),
]);
assert!(state.selected().is_empty());
state.select();
assert_eq!(state.selected().len(), 1);
state.cursor_down();
state.select();
assert_eq!(state.selected().len(), 1);
state.clear_selection();
assert!(state.selected().is_empty());
}
#[test]
fn test_file_picker_state_multi_select() {
let mut state = FilePickerState::new(PathBuf::from("/home")).multi_select(true);
state.set_entries(vec![
FileEntry::file("file1.txt", PathBuf::from("/home/file1.txt")),
FileEntry::file("file2.txt", PathBuf::from("/home/file2.txt")),
]);
state.toggle_selection();
assert_eq!(state.selected().len(), 1);
state.cursor_down();
state.toggle_selection();
assert_eq!(state.selected().len(), 2);
state.toggle_selection(); assert_eq!(state.selected().len(), 1);
}
#[test]
fn test_file_picker_state_search() {
let mut state = FilePickerState::new(PathBuf::from("/home"));
state.set_entries(vec![
FileEntry::file("apple.txt", PathBuf::from("/home/apple.txt")),
FileEntry::file("banana.txt", PathBuf::from("/home/banana.txt")),
FileEntry::file("cherry.txt", PathBuf::from("/home/cherry.txt")),
]);
assert_eq!(state.visible_entries().len(), 3);
state.set_search("an");
assert_eq!(state.visible_entries().len(), 1);
assert_eq!(state.visible_entries()[0].name, "banana.txt");
state.clear_search();
assert_eq!(state.visible_entries().len(), 3);
}
#[test]
fn test_file_picker_state_hidden() {
let mut state = FilePickerState::new(PathBuf::from("/home"));
state.set_entries(vec![
FileEntry::file(".hidden", PathBuf::from("/home/.hidden")),
FileEntry::file("visible.txt", PathBuf::from("/home/visible.txt")),
]);
assert_eq!(state.visible_entries().len(), 1);
state.toggle_hidden();
assert_eq!(state.visible_entries().len(), 2);
}
#[test]
fn test_format_size() {
assert_eq!(format_size(500), "500B");
assert_eq!(format_size(1024), "1.0K");
assert_eq!(format_size(1536), "1.5K");
assert_eq!(format_size(1048576), "1.0M");
assert_eq!(format_size(1073741824), "1.0G");
}
#[test]
fn test_file_picker_render() {
let mut state = FilePickerState::new(PathBuf::from("/home"));
state.set_entries(vec![
FileEntry::directory("src", PathBuf::from("/home/src")),
FileEntry::file("main.rs", PathBuf::from("/home/main.rs")),
]);
let picker = FilePicker::new(&state);
let rendered = picker.render();
assert!(rendered.contains("/home"));
assert!(rendered.contains("src"));
assert!(rendered.contains("main.rs"));
}
}