use crate::components::file_preview::FilePreview;
use crate::components::popup::Popup;
use crate::config::Config;
use crate::keymap::Action;
use crate::styles::{theme as ui_theme, LIST_HIGHLIGHT_SYMBOL};
use crate::utils::list_navigation::ListStateExt;
use crate::utils::mouse::MouseRegions;
use crate::utils::style::{focused_border_style, unfocused_border_style};
use crate::utils::text_input::TextInput;
use crate::widgets::text_input::{TextInputWidget, TextInputWidgetExt};
use anyhow::Result;
use crossterm::event::{Event, KeyCode, KeyEventKind, MouseButton, MouseEventKind};
use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
use ratatui::widgets::{
Block, Borders, List, ListItem, ListState, Paragraph, Scrollbar, ScrollbarOrientation,
ScrollbarState, StatefulWidget,
};
use ratatui::Frame;
use std::path::{Path, PathBuf};
use syntect::highlighting::Theme;
use syntect::parsing::SyntaxSet;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum FileBrowserFocus {
PathInput,
#[default]
List,
Preview,
}
#[derive(Debug, Clone)]
pub enum FileBrowserResult {
None,
Cancelled,
Selected {
full_path: PathBuf,
relative_path: String,
},
RefreshNeeded,
}
#[derive(Debug)]
pub struct FileBrowser {
pub is_active: bool,
pub current_path: PathBuf,
pub entries: Vec<PathBuf>,
pub list_state: ListState,
pub scrollbar_state: ScrollbarState,
pub path_input: TextInput,
pub preview_scroll: usize,
pub focus: FileBrowserFocus,
mouse_regions: MouseRegions<usize>,
list_pane_area: Option<Rect>,
preview_pane_area: Option<Rect>,
path_input_area: Option<Rect>,
}
impl Default for FileBrowser {
fn default() -> Self {
Self::new()
}
}
impl FileBrowser {
#[must_use]
pub fn new() -> Self {
Self {
is_active: false,
current_path: dirs::home_dir().unwrap_or_else(|| PathBuf::from("/")),
entries: Vec::new(),
list_state: ListState::default(),
scrollbar_state: ScrollbarState::new(0),
path_input: TextInput::new(),
preview_scroll: 0,
focus: FileBrowserFocus::List,
mouse_regions: MouseRegions::new(),
list_pane_area: None,
preview_pane_area: None,
path_input_area: None,
}
}
pub fn open(&mut self, path: PathBuf) {
self.is_active = true;
self.current_path = path.clone();
self.path_input.set_text(path.to_string_lossy().to_string());
self.list_state.select(Some(0));
self.preview_scroll = 0;
self.focus = FileBrowserFocus::List;
self.refresh_entries();
}
pub fn close(&mut self) {
self.is_active = false;
self.path_input.clear();
self.entries.clear();
}
#[must_use]
pub fn is_open(&self) -> bool {
self.is_active
}
pub fn refresh_entries(&mut self) {
self.entries.clear();
if self.current_path.parent().is_some() {
self.entries.push(PathBuf::from(".."));
}
self.entries.push(PathBuf::from("."));
if let Ok(read_dir) = std::fs::read_dir(&self.current_path) {
let mut dirs: Vec<PathBuf> = Vec::new();
let mut files: Vec<PathBuf> = Vec::new();
for entry in read_dir.flatten() {
let path = entry.path();
if path.is_dir() {
dirs.push(path);
} else {
files.push(path);
}
}
dirs.sort();
files.sort();
for dir in dirs {
self.entries.push(dir);
}
for file in files {
self.entries.push(file);
}
}
self.scrollbar_state = self.scrollbar_state.content_length(self.entries.len());
if self.list_state.selected().is_none() && !self.entries.is_empty() {
self.list_state.select(Some(0));
}
}
#[must_use]
pub fn is_input_focused(&self) -> bool {
self.is_active && self.focus == FileBrowserFocus::PathInput
}
pub fn handle_event(&mut self, event: Event, config: &Config) -> Result<FileBrowserResult> {
if !self.is_active {
return Ok(FileBrowserResult::None);
}
match event {
Event::Key(key) => {
if key.kind != KeyEventKind::Press {
return Ok(FileBrowserResult::None);
}
match self.focus {
FileBrowserFocus::PathInput => {
return self.handle_path_input(key.code, config);
}
FileBrowserFocus::List => {
return self.handle_list_navigation(key.code, config);
}
FileBrowserFocus::Preview => {
return self.handle_preview_navigation(key.code, config);
}
}
}
Event::Mouse(mouse) => {
return self.handle_mouse_event(mouse, config);
}
_ => {}
}
Ok(FileBrowserResult::None)
}
fn handle_path_input(
&mut self,
key_code: KeyCode,
_config: &Config,
) -> Result<FileBrowserResult> {
match key_code {
KeyCode::Char(c) => {
self.path_input.insert_char(c);
}
KeyCode::Left | KeyCode::Right | KeyCode::Home | KeyCode::End => {
self.path_input.handle_key(key_code);
}
KeyCode::Backspace => {
self.path_input.backspace();
}
KeyCode::Delete => {
self.path_input.delete();
}
KeyCode::Enter => {
let path_str = self.path_input.text_trimmed();
if !path_str.is_empty() {
let full_path = crate::utils::expand_path(path_str);
if full_path.exists() {
if full_path.is_dir() {
self.current_path = full_path.clone();
self.path_input
.set_text(self.current_path.to_string_lossy().to_string());
self.list_state.select(Some(0));
self.focus = FileBrowserFocus::List;
self.refresh_entries();
return Ok(FileBrowserResult::RefreshNeeded);
} else {
let home_dir = crate::utils::get_home_dir();
let relative_path = full_path.strip_prefix(&home_dir).map_or_else(
|_| full_path.to_string_lossy().to_string(),
|p| p.to_string_lossy().to_string(),
);
self.close();
return Ok(FileBrowserResult::Selected {
full_path,
relative_path,
});
}
}
}
}
KeyCode::Tab => {
self.focus = FileBrowserFocus::List;
}
KeyCode::Esc => {
self.close();
return Ok(FileBrowserResult::Cancelled);
}
_ => {}
}
Ok(FileBrowserResult::None)
}
fn handle_list_navigation(
&mut self,
key_code: KeyCode,
config: &Config,
) -> Result<FileBrowserResult> {
let action = config
.keymap
.get_action(key_code, crossterm::event::KeyModifiers::NONE);
if let Some(action) = action {
match action {
Action::MoveUp => {
self.list_state.select_previous();
self.preview_scroll = 0; }
Action::MoveDown => {
self.list_state.select_next();
self.preview_scroll = 0;
}
Action::Confirm => {
return self.handle_selection(config);
}
Action::NextTab => {
self.focus = FileBrowserFocus::Preview;
}
Action::PageUp => {
self.list_state.page_up(10, self.entries.len());
}
Action::PageDown => {
self.list_state.page_down(10, self.entries.len());
}
Action::GoToTop => {
self.list_state.select_first();
}
Action::GoToEnd => {
self.list_state.select_last();
}
Action::Cancel | Action::Quit => {
self.close();
return Ok(FileBrowserResult::Cancelled);
}
_ => {}
}
}
if let Some(selected) = self.list_state.selected() {
self.scrollbar_state = self.scrollbar_state.position(selected);
}
Ok(FileBrowserResult::None)
}
fn handle_selection(&mut self, config: &Config) -> Result<FileBrowserResult> {
if let Some(idx) = self.list_state.selected() {
if idx < self.entries.len() {
let entry = self.entries[idx].clone();
if entry == Path::new("..") {
if let Some(parent) = self.current_path.parent() {
self.current_path = parent.to_path_buf();
self.path_input
.set_text(self.current_path.to_string_lossy().to_string());
self.list_state.select(Some(0));
self.refresh_entries();
return Ok(FileBrowserResult::RefreshNeeded);
}
} else if entry == Path::new(".") {
let current_folder = self.current_path.clone();
let home_dir = crate::utils::get_home_dir();
let relative_path = current_folder.strip_prefix(&home_dir).map_or_else(
|_| current_folder.to_string_lossy().to_string(),
|p| p.to_string_lossy().to_string(),
);
let repo_path = &config.repo_path;
let (is_safe, _reason) =
crate::utils::is_safe_to_add(¤t_folder, repo_path);
if !is_safe {
return Ok(FileBrowserResult::None); }
if crate::utils::is_git_repo(¤t_folder) {
return Ok(FileBrowserResult::None); }
self.close();
return Ok(FileBrowserResult::Selected {
full_path: current_folder,
relative_path,
});
} else {
let full_path = if entry.is_absolute() {
entry.clone()
} else {
self.current_path.join(&entry)
};
if full_path.is_dir() {
self.current_path = full_path.clone();
self.path_input
.set_text(full_path.to_string_lossy().to_string());
self.list_state.select(Some(0));
self.refresh_entries();
return Ok(FileBrowserResult::RefreshNeeded);
} else if full_path.is_file() {
let home_dir = crate::utils::get_home_dir();
let relative_path = full_path.strip_prefix(&home_dir).map_or_else(
|_| full_path.to_string_lossy().to_string(),
|p| p.to_string_lossy().to_string(),
);
self.close();
return Ok(FileBrowserResult::Selected {
full_path,
relative_path,
});
}
}
}
}
Ok(FileBrowserResult::None)
}
fn handle_preview_navigation(
&mut self,
key_code: KeyCode,
config: &Config,
) -> Result<FileBrowserResult> {
let action = config
.keymap
.get_action(key_code, crossterm::event::KeyModifiers::NONE);
if let Some(action) = action {
match action {
Action::MoveUp | Action::ScrollUp => {
self.preview_scroll = self.preview_scroll.saturating_sub(1);
}
Action::MoveDown | Action::ScrollDown => {
self.preview_scroll = self.preview_scroll.saturating_add(1);
}
Action::PageUp => {
self.preview_scroll = self.preview_scroll.saturating_sub(20);
}
Action::PageDown => {
self.preview_scroll = self.preview_scroll.saturating_add(20);
}
Action::GoToTop => {
self.preview_scroll = 0;
}
Action::GoToEnd => {
self.preview_scroll = 10000; }
Action::NextTab => {
self.focus = FileBrowserFocus::PathInput;
}
Action::Cancel | Action::Quit => {
self.close();
return Ok(FileBrowserResult::Cancelled);
}
_ => {}
}
}
Ok(FileBrowserResult::None)
}
fn handle_mouse_event(
&mut self,
mouse: crossterm::event::MouseEvent,
_config: &Config,
) -> Result<FileBrowserResult> {
let pos = ratatui::layout::Position::new(mouse.column, mouse.row);
match mouse.kind {
MouseEventKind::Down(MouseButton::Left) => {
if let Some(&idx) = self.mouse_regions.hit_test(mouse.column, mouse.row) {
self.list_state.select(Some(idx));
self.preview_scroll = 0;
self.focus = FileBrowserFocus::List;
self.scrollbar_state = self.scrollbar_state.position(idx);
return Ok(FileBrowserResult::None);
}
if let Some(area) = self.path_input_area {
if area.contains(pos) {
self.focus = FileBrowserFocus::PathInput;
return Ok(FileBrowserResult::None);
}
}
if let Some(area) = self.preview_pane_area {
if area.contains(pos) {
self.focus = FileBrowserFocus::Preview;
return Ok(FileBrowserResult::None);
}
}
if let Some(area) = self.list_pane_area {
if area.contains(pos) {
self.focus = FileBrowserFocus::List;
return Ok(FileBrowserResult::None);
}
}
}
MouseEventKind::ScrollDown => {
if let Some(area) = self.list_pane_area {
if area.contains(pos) {
for _ in 0..3 {
self.list_state.select_next();
}
self.preview_scroll = 0;
if let Some(selected) = self.list_state.selected() {
self.scrollbar_state = self.scrollbar_state.position(selected);
}
return Ok(FileBrowserResult::None);
}
}
if let Some(area) = self.preview_pane_area {
if area.contains(pos) {
self.preview_scroll = self.preview_scroll.saturating_add(3);
return Ok(FileBrowserResult::None);
}
}
}
MouseEventKind::ScrollUp => {
if let Some(area) = self.list_pane_area {
if area.contains(pos) {
for _ in 0..3 {
self.list_state.select_previous();
}
self.preview_scroll = 0;
if let Some(selected) = self.list_state.selected() {
self.scrollbar_state = self.scrollbar_state.position(selected);
}
return Ok(FileBrowserResult::None);
}
}
if let Some(area) = self.preview_pane_area {
if area.contains(pos) {
self.preview_scroll = self.preview_scroll.saturating_sub(3);
return Ok(FileBrowserResult::None);
}
}
}
_ => {}
}
Ok(FileBrowserResult::None)
}
#[allow(clippy::too_many_arguments)]
pub fn render(
&mut self,
frame: &mut Frame,
area: Rect,
config: &Config,
syntax_set: &SyntaxSet,
theme: &Theme,
) -> Result<()> {
if !self.is_active {
return Ok(());
}
let t = ui_theme();
let k = |a| config.keymap.get_key_display_for_action(a);
let footer_text = format!(
"{}: Switch Focus | {}: Navigate | {}: Select | {}: Cancel",
k(Action::NextTab),
config.keymap.navigation_display(),
k(Action::Confirm),
k(Action::Cancel)
);
let Some(popup_result) = Popup::new()
.width(80)
.height(70)
.min_height(17)
.min_width(60)
.title("Select File or Directory")
.footer(&footer_text)
.render(frame, area)
else {
return Ok(());
};
let content_area = popup_result.content_area;
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), Constraint::Length(3), Constraint::Min(0), ])
.split(content_area);
let path_display = Paragraph::new(self.current_path.to_string_lossy().to_string()).block(
Block::default()
.title(" Current Directory ")
.title_alignment(Alignment::Center)
.title_style(t.title_style())
.style(t.background_style()),
);
frame.render_widget(path_display, chunks[0]);
self.path_input_area = Some(chunks[1]);
let widget = TextInputWidget::new(&self.path_input)
.title("Path Input")
.focused(self.focus == FileBrowserFocus::PathInput);
frame.render_text_input_widget(widget, chunks[1]);
let list_preview_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(chunks[2]);
self.render_list(frame, list_preview_chunks[0], config);
self.preview_pane_area = Some(list_preview_chunks[1]);
self.render_preview(frame, list_preview_chunks[1], syntax_set, theme, config)?;
Ok(())
}
fn render_list(&mut self, frame: &mut Frame, area: Rect, config: &Config) {
let t = ui_theme();
let icons = crate::icons::Icons::from_config(config);
let items: Vec<ListItem> = self
.entries
.iter()
.map(|path| {
let is_dir = if path == Path::new("..") || path == Path::new(".") {
true
} else {
path.is_dir()
};
let name = if path == Path::new("..") {
".. (parent)".to_string()
} else if path == Path::new(".") {
". (add this folder)".to_string()
} else {
path.file_name().and_then(|n| n.to_str()).map_or_else(
|| path.to_string_lossy().to_string(),
std::string::ToString::to_string,
)
};
let prefix = if is_dir {
format!("{} ", icons.folder())
} else {
format!("{} ", icons.file())
};
ListItem::new(format!("{prefix}{name}")).style(t.text_style())
})
.collect();
let is_focused = self.focus == FileBrowserFocus::List;
let border_style = if is_focused {
focused_border_style().bg(t.background)
} else {
unfocused_border_style().bg(t.background)
};
let list = List::new(items)
.block(
Block::default()
.borders(Borders::ALL)
.title(" Files ")
.border_type(t.border_type(is_focused))
.title_alignment(Alignment::Center)
.border_style(border_style)
.style(t.background_style()),
)
.highlight_style(t.highlight_style())
.highlight_symbol(LIST_HIGHLIGHT_SYMBOL);
StatefulWidget::render(list, area, frame.buffer_mut(), &mut self.list_state);
self.list_pane_area = Some(area);
self.mouse_regions.clear();
let inner = Block::default().borders(Borders::ALL).inner(area);
let scroll_offset = self.list_state.offset();
for i in 0..self.entries.len() {
if i < scroll_offset {
continue;
}
let visible_row = (i - scroll_offset) as u16;
if visible_row >= inner.height {
break;
}
let row_area = Rect::new(inner.x, inner.y + visible_row, inner.width, 1);
self.mouse_regions.add(row_area, i);
}
frame.render_stateful_widget(
Scrollbar::new(ScrollbarOrientation::VerticalRight)
.begin_symbol(Some("↑"))
.end_symbol(Some("↓")),
area,
&mut self.scrollbar_state,
);
}
fn render_preview(
&mut self,
frame: &mut Frame,
area: Rect,
syntax_set: &SyntaxSet,
theme: &Theme,
config: &crate::config::Config,
) -> Result<()> {
let t = ui_theme();
if let Some(selected_index) = self.list_state.selected() {
if selected_index < self.entries.len() {
let selected = &self.entries[selected_index];
let full_path = if selected == Path::new("..") {
self.current_path
.parent()
.map_or_else(|| PathBuf::from("/"), std::path::Path::to_path_buf)
} else if selected == Path::new(".") {
self.current_path.clone()
} else if selected.is_absolute() {
selected.clone()
} else {
self.current_path.join(selected)
};
let is_focused = self.focus == FileBrowserFocus::Preview;
FilePreview::render(
frame,
area,
&full_path,
&mut self.preview_scroll,
is_focused,
Some("Preview"),
None,
syntax_set,
theme,
config,
)?;
return Ok(());
}
}
let empty_preview = Paragraph::new("No selection")
.style(t.muted_style())
.alignment(Alignment::Center)
.block(
Block::default()
.borders(Borders::ALL)
.border_type(t.border_type(false))
.title(" Preview ")
.title_alignment(Alignment::Center)
.style(t.background_style()),
);
frame.render_widget(empty_preview, area);
Ok(())
}
}