mod types;
mod view;
pub use types::*;
use std::sync::Arc;
use ratatui::widgets::ListState;
use super::{Component, EventContext, RenderContext};
use crate::input::{Event, Key};
use types::{FileBrowserFocus, compute_segments};
pub trait DirectoryProvider: Send + 'static {
fn list_entries(&self, path: &str) -> Vec<FileEntry>;
fn parent_path(&self, path: &str) -> Option<String>;
fn separator(&self) -> &str {
"/"
}
}
#[derive(Clone)]
pub struct FileBrowserState {
current_path: String,
path_segments: Vec<String>,
entries: Vec<FileEntry>,
filtered_indices: Vec<usize>,
selected_index: Option<usize>,
selected_paths: Vec<String>,
filter_text: String,
internal_focus: FileBrowserFocus,
selection_mode: SelectionMode,
sort_field: FileSortField,
sort_direction: FileSortDirection,
directories_first: bool,
show_hidden: bool,
pub(crate) list_state: ListState,
#[allow(dead_code)]
provider: Option<Arc<dyn DirectoryProvider>>,
}
impl Default for FileBrowserState {
fn default() -> Self {
Self {
current_path: "/".to_string(),
path_segments: vec!["/".to_string()],
entries: Vec::new(),
filtered_indices: Vec::new(),
selected_index: None,
selected_paths: Vec::new(),
filter_text: String::new(),
internal_focus: FileBrowserFocus::FileList,
selection_mode: SelectionMode::Single,
sort_field: FileSortField::Name,
sort_direction: FileSortDirection::Ascending,
directories_first: true,
show_hidden: false,
list_state: ListState::default(),
provider: None,
}
}
}
impl std::fmt::Debug for FileBrowserState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("FileBrowserState")
.field("current_path", &self.current_path)
.field("path_segments", &self.path_segments)
.field("entries", &self.entries)
.field("filtered_indices", &self.filtered_indices)
.field("selected_index", &self.selected_index)
.field("selected_paths", &self.selected_paths)
.field("filter_text", &self.filter_text)
.field("internal_focus", &self.internal_focus)
.field("selection_mode", &self.selection_mode)
.field("sort_field", &self.sort_field)
.field("sort_direction", &self.sort_direction)
.field("directories_first", &self.directories_first)
.field("show_hidden", &self.show_hidden)
.field("list_state", &self.list_state)
.field(
"provider",
&self.provider.as_ref().map(|_| "<DirectoryProvider>"),
)
.finish()
}
}
impl PartialEq for FileBrowserState {
fn eq(&self, other: &Self) -> bool {
self.current_path == other.current_path
&& self.path_segments == other.path_segments
&& self.entries == other.entries
&& self.filtered_indices == other.filtered_indices
&& self.selected_index == other.selected_index
&& self.selected_paths == other.selected_paths
&& self.filter_text == other.filter_text
&& self.internal_focus == other.internal_focus
&& self.selection_mode == other.selection_mode
&& self.sort_field == other.sort_field
&& self.sort_direction == other.sort_direction
&& self.directories_first == other.directories_first
&& self.show_hidden == other.show_hidden
&& self.list_state.selected() == other.list_state.selected()
}
}
impl FileBrowserState {
pub fn new(path: impl Into<String>, entries: Vec<FileEntry>) -> Self {
let path_str = path.into();
let segments = compute_segments(&path_str);
let mut state = Self {
current_path: path_str,
path_segments: segments,
entries,
..Self::default()
};
state.sort_and_filter();
if !state.filtered_indices.is_empty() {
state.selected_index = Some(0);
state.list_state.select(Some(0));
}
state
}
pub fn with_provider(path: impl Into<String>, provider: Arc<dyn DirectoryProvider>) -> Self {
let path_str = path.into();
let entries = provider.list_entries(&path_str);
let segments = compute_segments(&path_str);
let mut state = Self {
current_path: path_str,
path_segments: segments,
entries,
provider: Some(provider),
..Self::default()
};
state.sort_and_filter();
if !state.filtered_indices.is_empty() {
state.selected_index = Some(0);
state.list_state.select(Some(0));
}
state
}
pub fn with_selection_mode(mut self, mode: SelectionMode) -> Self {
self.selection_mode = mode;
self
}
pub fn with_sort_field(mut self, field: FileSortField) -> Self {
self.sort_field = field;
self.sort_and_filter();
self
}
pub fn with_sort_direction(mut self, direction: FileSortDirection) -> Self {
self.sort_direction = direction;
self.sort_and_filter();
self
}
pub fn with_directories_first(mut self, directories_first: bool) -> Self {
self.directories_first = directories_first;
self.sort_and_filter();
self
}
pub fn with_show_hidden(mut self, show: bool) -> Self {
self.show_hidden = show;
self.sort_and_filter();
self
}
pub fn current_path(&self) -> &str {
&self.current_path
}
pub fn path_segments(&self) -> &[String] {
&self.path_segments
}
pub fn entries(&self) -> &[FileEntry] {
&self.entries
}
pub fn filtered_indices(&self) -> &[usize] {
&self.filtered_indices
}
pub fn filtered_entries(&self) -> Vec<&FileEntry> {
self.filtered_indices
.iter()
.filter_map(|&i| self.entries.get(i))
.collect()
}
pub fn selected_entry(&self) -> Option<&FileEntry> {
self.selected_index
.and_then(|sel| self.filtered_indices.get(sel))
.and_then(|&i| self.entries.get(i))
}
pub fn selected_index(&self) -> Option<usize> {
self.selected_index
}
pub fn selected(&self) -> Option<usize> {
self.selected_index()
}
pub fn selected_item(&self) -> Option<&FileEntry> {
self.selected_entry()
}
pub fn selected_paths(&self) -> &[String] {
&self.selected_paths
}
pub fn filter_text(&self) -> &str {
&self.filter_text
}
pub fn selection_mode(&self) -> &SelectionMode {
&self.selection_mode
}
pub fn sort_field(&self) -> &FileSortField {
&self.sort_field
}
pub fn sort_direction(&self) -> &FileSortDirection {
&self.sort_direction
}
pub fn show_hidden(&self) -> bool {
self.show_hidden
}
pub fn set_show_hidden(&mut self, show: bool) {
self.show_hidden = show;
}
pub(crate) fn internal_focus(&self) -> &FileBrowserFocus {
&self.internal_focus
}
pub fn update(&mut self, msg: FileBrowserMessage) -> Option<FileBrowserOutput> {
FileBrowser::update(self, msg)
}
fn sort_and_filter(&mut self) {
self.filtered_indices = (0..self.entries.len())
.filter(|&i| {
let entry = &self.entries[i];
if !self.show_hidden && entry.is_hidden() {
return false;
}
if !self.filter_text.is_empty()
&& !entry
.name()
.to_lowercase()
.contains(&self.filter_text.to_lowercase())
{
return false;
}
true
})
.collect();
let entries = &self.entries;
let sort_field = &self.sort_field;
let sort_direction = &self.sort_direction;
let directories_first = self.directories_first;
self.filtered_indices.sort_by(|&a, &b| {
let ea = &entries[a];
let eb = &entries[b];
if directories_first && ea.is_dir() != eb.is_dir() {
return if ea.is_dir() {
std::cmp::Ordering::Less
} else {
std::cmp::Ordering::Greater
};
}
let ord = match sort_field {
FileSortField::Name => ea.name().to_lowercase().cmp(&eb.name().to_lowercase()),
FileSortField::Size => ea.size().cmp(&eb.size()),
FileSortField::Modified => ea.modified().cmp(&eb.modified()),
FileSortField::Extension => ea.extension().cmp(&eb.extension()),
};
match sort_direction {
FileSortDirection::Ascending => ord,
FileSortDirection::Descending => ord.reverse(),
}
});
if self.filtered_indices.is_empty() {
self.selected_index = None;
self.list_state.select(None);
} else if let Some(sel) = self.selected_index {
if sel >= self.filtered_indices.len() {
self.selected_index = Some(self.filtered_indices.len() - 1);
self.list_state
.select(Some(self.filtered_indices.len() - 1));
}
}
}
fn navigate_to(&mut self, path: String, entries: Vec<FileEntry>) {
self.current_path = path;
self.path_segments = compute_segments(&self.current_path);
self.entries = entries;
self.filter_text.clear();
self.sort_and_filter();
self.selected_index = if self.filtered_indices.is_empty() {
None
} else {
Some(0)
};
self.list_state.select(self.selected_index);
}
}
pub struct FileBrowser;
impl Component for FileBrowser {
type State = FileBrowserState;
type Message = FileBrowserMessage;
type Output = FileBrowserOutput;
fn init() -> Self::State {
FileBrowserState::default()
}
fn handle_event(
state: &Self::State,
event: &Event,
ctx: &EventContext,
) -> Option<Self::Message> {
if !ctx.focused || ctx.disabled {
return None;
}
let key = event.as_key()?;
let ctrl = key.modifiers.ctrl();
match state.internal_focus {
FileBrowserFocus::FileList => match key.code {
Key::Up | Key::Char('k') if !ctrl => Some(FileBrowserMessage::Up),
Key::Down | Key::Char('j') if !ctrl => Some(FileBrowserMessage::Down),
Key::Char('g') if key.modifiers.shift() => Some(FileBrowserMessage::Last),
Key::Home | Key::Char('g') => Some(FileBrowserMessage::First),
Key::End => Some(FileBrowserMessage::Last),
Key::PageUp => Some(FileBrowserMessage::PageUp(10)),
Key::PageDown => Some(FileBrowserMessage::PageDown(10)),
Key::Enter => Some(FileBrowserMessage::Enter),
Key::Backspace if state.filter_text.is_empty() => Some(FileBrowserMessage::Back),
Key::Backspace => Some(FileBrowserMessage::FilterBackspace),
Key::Char(' ') => Some(FileBrowserMessage::ToggleSelect),
Key::Char('h') if ctrl => Some(FileBrowserMessage::ToggleHidden),
Key::Tab => Some(FileBrowserMessage::CycleFocus),
Key::Esc => Some(FileBrowserMessage::FilterClear),
Key::Char(_) if !ctrl => key
.raw_char
.filter(|c| c.is_alphanumeric() || *c == '.' || *c == '_' || *c == '-')
.map(FileBrowserMessage::FilterChar),
_ => None,
},
FileBrowserFocus::PathBar => match key.code {
Key::Tab => Some(FileBrowserMessage::CycleFocus),
_ => None,
},
FileBrowserFocus::Filter => match key.code {
Key::Tab => Some(FileBrowserMessage::CycleFocus),
Key::Backspace => Some(FileBrowserMessage::FilterBackspace),
Key::Esc => Some(FileBrowserMessage::FilterClear),
Key::Char(_) if !ctrl => key.raw_char.map(FileBrowserMessage::FilterChar),
_ => None,
},
}
}
fn update(state: &mut Self::State, msg: Self::Message) -> Option<Self::Output> {
match msg {
FileBrowserMessage::Up => {
if state.filtered_indices.is_empty() {
return None;
}
let new_index = match state.selected_index {
Some(0) | None => state.filtered_indices.len() - 1,
Some(i) => i - 1,
};
state.selected_index = Some(new_index);
state.list_state.select(Some(new_index));
Some(FileBrowserOutput::SelectionChanged(new_index))
}
FileBrowserMessage::Down => {
if state.filtered_indices.is_empty() {
return None;
}
let new_index = match state.selected_index {
None => 0,
Some(i) => (i + 1) % state.filtered_indices.len(),
};
state.selected_index = Some(new_index);
state.list_state.select(Some(new_index));
Some(FileBrowserOutput::SelectionChanged(new_index))
}
FileBrowserMessage::First => {
if state.filtered_indices.is_empty() {
return None;
}
state.selected_index = Some(0);
state.list_state.select(Some(0));
Some(FileBrowserOutput::SelectionChanged(0))
}
FileBrowserMessage::Last => {
if state.filtered_indices.is_empty() {
return None;
}
let last = state.filtered_indices.len() - 1;
state.selected_index = Some(last);
state.list_state.select(Some(last));
Some(FileBrowserOutput::SelectionChanged(last))
}
FileBrowserMessage::PageUp(n) => {
if state.filtered_indices.is_empty() {
return None;
}
let new_index = state.selected_index.unwrap_or(0).saturating_sub(n);
state.selected_index = Some(new_index);
state.list_state.select(Some(new_index));
Some(FileBrowserOutput::SelectionChanged(new_index))
}
FileBrowserMessage::PageDown(n) => {
if state.filtered_indices.is_empty() {
return None;
}
let max = state.filtered_indices.len() - 1;
let current = state.selected_index.unwrap_or(0);
let new_index = (current + n).min(max);
state.selected_index = Some(new_index);
state.list_state.select(Some(new_index));
Some(FileBrowserOutput::SelectionChanged(new_index))
}
FileBrowserMessage::Enter => {
let entry = state.selected_entry()?.clone();
if entry.is_dir() {
let path = entry.path().to_string();
let new_entries = state
.provider
.as_ref()
.map(|p| p.list_entries(&path))
.unwrap_or_default();
state.navigate_to(path.clone(), new_entries);
Some(FileBrowserOutput::DirectoryEntered(path))
} else {
Some(FileBrowserOutput::FileSelected(entry))
}
}
FileBrowserMessage::Back => {
let parent = state
.provider
.as_ref()
.and_then(|p| p.parent_path(&state.current_path));
if let Some(parent_path) = parent {
let new_entries = state
.provider
.as_ref()
.map(|p| p.list_entries(&parent_path))
.unwrap_or_default();
state.navigate_to(parent_path.clone(), new_entries);
Some(FileBrowserOutput::NavigatedBack(parent_path))
} else {
None
}
}
FileBrowserMessage::ToggleSelect => {
let entry = state.selected_entry()?.clone();
let path = entry.path().to_string();
if let Some(pos) = state.selected_paths.iter().position(|p| p == &path) {
state.selected_paths.remove(pos);
} else {
state.selected_paths.push(path.clone());
}
Some(FileBrowserOutput::SelectionToggled(path))
}
FileBrowserMessage::ToggleHidden => {
state.show_hidden = !state.show_hidden;
state.sort_and_filter();
Some(FileBrowserOutput::HiddenToggled(state.show_hidden))
}
FileBrowserMessage::CycleFocus => {
state.internal_focus = match state.internal_focus {
FileBrowserFocus::PathBar => FileBrowserFocus::FileList,
FileBrowserFocus::FileList => FileBrowserFocus::Filter,
FileBrowserFocus::Filter => FileBrowserFocus::PathBar,
};
None
}
FileBrowserMessage::FilterChar(c) => {
state.filter_text.push(c);
state.sort_and_filter();
if !state.filtered_indices.is_empty() && state.selected_index.is_none() {
state.selected_index = Some(0);
state.list_state.select(Some(0));
}
Some(FileBrowserOutput::FilterChanged(state.filter_text.clone()))
}
FileBrowserMessage::FilterBackspace => {
if state.filter_text.pop().is_some() {
state.sort_and_filter();
Some(FileBrowserOutput::FilterChanged(state.filter_text.clone()))
} else {
None
}
}
FileBrowserMessage::FilterClear => {
if state.filter_text.is_empty() {
return None;
}
state.filter_text.clear();
state.sort_and_filter();
Some(FileBrowserOutput::FilterChanged(String::new()))
}
FileBrowserMessage::SetSort(field) => {
state.sort_field = field.clone();
state.sort_and_filter();
Some(FileBrowserOutput::SortChanged(
field,
state.sort_direction.clone(),
))
}
FileBrowserMessage::ToggleSortDirection => {
state.sort_direction = match state.sort_direction {
FileSortDirection::Ascending => FileSortDirection::Descending,
FileSortDirection::Descending => FileSortDirection::Ascending,
};
state.sort_and_filter();
Some(FileBrowserOutput::SortChanged(
state.sort_field.clone(),
state.sort_direction.clone(),
))
}
FileBrowserMessage::NavigateToSegment(index) => {
if index >= state.path_segments.len() {
return None;
}
let new_path = if index == 0 {
"/".to_string()
} else {
let parts: Vec<&str> = state.path_segments[1..=index]
.iter()
.map(|s| s.as_str())
.collect();
format!("/{}", parts.join("/"))
};
let new_entries = state
.provider
.as_ref()
.map(|p| p.list_entries(&new_path))
.unwrap_or_default();
state.navigate_to(new_path.clone(), new_entries);
Some(FileBrowserOutput::DirectoryEntered(new_path))
}
FileBrowserMessage::Refresh => {
let path = state.current_path.clone();
let new_entries = state
.provider
.as_ref()
.map(|p| p.list_entries(&path))
.unwrap_or_default();
state.entries = new_entries;
state.sort_and_filter();
None
}
}
}
fn view(state: &Self::State, ctx: &mut RenderContext<'_, '_>) {
view::render(
state,
ctx.frame,
ctx.area,
ctx.theme,
ctx.focused,
ctx.disabled,
);
}
}
#[cfg(test)]
mod helper_tests;
#[cfg(test)]
mod snapshot_tests;
#[cfg(test)]
mod tests;