use std::{collections::HashSet, path::{Path, PathBuf}};
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::Style,
text::Span,
widgets::Paragraph,
Frame,
};
use crate::prelude::*;
fn build_reveal_queue(project_root: &Path, target: &Path) -> Vec<PathBuf> {
let mut queue = Vec::new();
if let Ok(rel) = target.strip_prefix(project_root)
&& let Some(parent) = rel.parent() {
let mut cur = project_root.to_path_buf();
for comp in parent.components() {
cur = cur.join(comp.as_os_str());
queue.push(cur.clone());
}
}
queue
}
use file_index::SharedFileIndex;
use input::{Key, KeyEvent, MouseButton, MouseEvent, MouseEventKind};
use operation::{Event, FileSelectorOp, Operation};
use settings::Settings;
use utils::fuzzy::filter_and_rank;
use views::View;
use widgets::{
file_tree::{path_list_item, FileTreeView},
focus::FocusRing,
list::SelectableList,
pane::TitledPane,
};
use widgets::focusable::FocusOp;
use widgets::input_field::InputField;
const FOCUS_FILTER: &str = "filter";
const FOCUS_HISTORY: &str = "history";
const FOCUS_TREE: &str = "tree";
#[derive(Debug)]
pub struct FileSelector {
pub project_root: PathBuf,
pub filter: InputField,
focus: FocusRing,
history_all: Vec<PathBuf>,
history_list: SelectableList<PathBuf>,
tree_view: FileTreeView,
file_index: SharedFileIndex,
tree_filtered: SelectableList<PathBuf>,
pub search_generation: u64,
search_pending: bool,
pub pending_load: Option<PathBuf>,
reveal_queue: Vec<PathBuf>,
reveal_target: Option<PathBuf>,
dirty_paths: HashSet<PathBuf>,
}
impl FileSelector {
pub fn new(project_root: PathBuf, history: &[PathBuf], file_index: SharedFileIndex, dirty_paths: HashSet<PathBuf>, initial_path: Option<PathBuf>) -> Self {
let mut tree_view = FileTreeView::from_dir(&project_root);
let history_all = history.to_vec();
let history_list = SelectableList::new(history_all.clone());
let mut reveal_queue: Vec<PathBuf> = Vec::new();
let mut reveal_target: Option<PathBuf> = None;
let mut pending_load_val: Option<PathBuf> = None;
if let Some(init) = initial_path.clone()
&& init.exists() && init.starts_with(&project_root) {
reveal_target = Some(init.clone());
reveal_queue = build_reveal_queue(&project_root, &init);
if !reveal_queue.is_empty() {
pending_load_val = Some(reveal_queue.remove(0));
} else {
let _ = tree_view.set_cursor_to(&init);
}
}
Self {
project_root,
filter: InputField::new("Filter"),
focus: FocusRing::new(vec![FOCUS_FILTER, FOCUS_HISTORY, FOCUS_TREE]),
history_all,
history_list,
tree_view,
file_index,
tree_filtered: SelectableList::new(vec![]),
search_generation: 0,
search_pending: false,
pending_load: pending_load_val,
reveal_queue,
reveal_target,
dirty_paths,
}
}
fn active_pane(&self) -> &str {
let cur = self.focus.current();
if cur == FOCUS_HISTORY {
FOCUS_HISTORY
} else if cur == FOCUS_TREE {
FOCUS_TREE
} else {
FOCUS_HISTORY
}
}
pub fn selected_path(&self) -> Option<PathBuf> {
match self.active_pane() {
FOCUS_HISTORY => {
let path = self.history_list.selected()?;
if path.is_absolute() {
Some(path.clone())
} else {
Some(self.project_root.join(path))
}
}
_ => {
if self.filter.is_empty() {
self.tree_view.selected_file()
} else {
let rel = self.tree_filtered.selected()?;
Some(self.project_root.join(rel))
}
}
}
}
#[cfg(test)]
pub(crate) fn tree_selected_file(&self) -> Option<PathBuf> {
self.tree_view.selected_file()
}
fn render_history_pane(&self, frame: &mut Frame, area: Rect, theme: &crate::theme::Theme) {
let pane = TitledPane::new("Recent Files", self.focus.is_focused(FOCUS_HISTORY));
let content = pane.prepare(frame, area, theme);
let pane_bg = pane.bg(theme);
let (sel_bg, sel_fg, fg_dim) = (theme.selection_bg(), theme.selection_fg(), theme.fg_dim());
frame.render_widget(
self.history_list.widget(|path, selected| {
let abs = self.project_root.join(path);
let dirty = self.dirty_paths.contains(&abs);
let max_w = content.width as usize;
path_list_item(path, selected, dirty, pane_bg, sel_bg, sel_fg, fg_dim, max_w)
}),
content,
);
}
fn render_tree_pane(&self, frame: &mut Frame, area: Rect, theme: &crate::theme::Theme) {
let is_active = self.focus.is_focused(FOCUS_TREE);
let root_name = self
.project_root
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| self.project_root.display().to_string());
let pane = TitledPane::new(&root_name, is_active);
let content = pane.prepare(frame, area, theme);
let pane_bg = pane.bg(theme);
let (sel_bg, sel_fg, fg_dim) = (theme.selection_bg(), theme.selection_fg(), theme.fg_dim());
if self.filter.is_empty() {
self.tree_view.render(
frame,
content,
is_active,
pane_bg,
sel_bg,
sel_fg,
theme.tree_dir(),
theme.fg_dim(),
);
} else if self.tree_filtered.is_empty() {
let is_busy = self.search_pending || {
let guard = self.file_index.load();
(**guard).is_none()
};
let msg = if is_busy {
" (searching\u{2026})"
} else {
" (no matches)"
};
frame.render_widget(
Paragraph::new(Span::styled(msg, Style::default().fg(fg_dim))),
content,
);
} else {
frame.render_widget(
self.tree_filtered.widget(|path, selected| {
let max_w = content.width as usize;
path_list_item(path, selected, false, pane_bg, sel_bg, sel_fg, fg_dim, max_w)
}),
content,
);
}
}
}
impl View for FileSelector {
const KIND: crate::views::ViewKind = crate::views::ViewKind::Modal;
fn save_state(&mut self, _app: &mut crate::app_state::AppState) {}
fn handle_key(&self, key: KeyEvent) -> Vec<Operation> {
match (key.modifiers, key.key) {
(_, Key::Enter) => {
if let Some(path) = self.selected_path() {
vec![Operation::OpenFile { path }]
} else if self.active_pane() == FOCUS_TREE && self.filter.is_empty() {
vec![Operation::FileSelectorLocal(FileSelectorOp::ToggleDir)]
} else {
vec![]
}
}
(_, Key::Tab) => vec![Operation::Focus(FocusOp::Next)],
(_, Key::BackTab) => vec![Operation::Focus(FocusOp::Prev)],
(_, Key::ArrowUp) => vec![Operation::NavigateUp],
(_, Key::ArrowDown) => vec![Operation::NavigateDown],
(_, Key::PageUp) => vec![Operation::NavigatePageUp],
(_, Key::PageDown) => vec![Operation::NavigatePageDown],
(_, Key::ArrowLeft) if self.active_pane() == FOCUS_TREE && self.filter.is_empty() => {
vec![Operation::FileSelectorLocal(FileSelectorOp::CollapseOrLeft)]
}
(_, Key::ArrowRight) if self.active_pane() == FOCUS_TREE && self.filter.is_empty() => {
vec![Operation::FileSelectorLocal(FileSelectorOp::ExpandOrRight)]
}
(m, Key::Char(' '))
if m.is_empty() && self.filter.is_empty() && self.active_pane() == FOCUS_TREE =>
{
vec![Operation::FileSelectorLocal(FileSelectorOp::ToggleDir)]
}
_ => {
if let Some(field_op) = crate::widgets::input_field::key_to_op(key) {
vec![Operation::FileSelectorLocal(FileSelectorOp::FilterInput(field_op))]
} else {
vec![]
}
}
}
}
fn handle_mouse(&self, mouse: MouseEvent) -> Vec<Operation> {
if !matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) {
return vec![];
}
let is_filter_row = mouse.row == 0;
if is_filter_row {
return vec![];
}
let is_left_pane = mouse.column < 80;
if is_left_pane {
if !self.focus.is_focused(FOCUS_HISTORY) {
return vec![Operation::FileSelectorLocal(FileSelectorOp::SwitchPane)];
}
} else if !self.focus.is_focused(FOCUS_TREE) {
return vec![Operation::FileSelectorLocal(FileSelectorOp::SwitchPane)];
}
vec![]
}
fn handle_operation(&mut self, op: &Operation, _settings: &Settings) -> Option<Event> {
match op {
Operation::Focus(focus_op) => {
match focus_op {
FocusOp::Next => self.focus.focus_next(),
FocusOp::Prev => self.focus.focus_prev(),
_ => {}
}
Some(Event::applied("file_selector", op.clone()))
}
Operation::FileSelectorLocal(local_op) => {
match local_op {
FileSelectorOp::FilterInput(field_op) => {
self.filter.apply(field_op);
self.search_generation += 1;
self.search_pending = !self.filter.is_empty();
let hist = filter_and_rank(&self.history_all, self.filter.text())
.into_iter()
.cloned()
.collect();
self.history_list.set_items(hist);
}
FileSelectorOp::SetResults { generation, paths } => {
if *generation == self.search_generation {
self.search_pending = false;
self.tree_filtered.set_items(paths.clone());
}
}
FileSelectorOp::SwitchPane => {
let cur = self.focus.current();
if cur == FOCUS_HISTORY || cur == FOCUS_FILTER {
self.focus.set_focus(FOCUS_TREE);
} else {
self.focus.set_focus(FOCUS_HISTORY);
}
}
FileSelectorOp::CollapseOrLeft => self.tree_view.key_left(),
FileSelectorOp::ExpandOrRight => {
self.pending_load = self.tree_view.key_right();
}
FileSelectorOp::ToggleDir => {
self.pending_load = self.tree_view.toggle_selected();
}
FileSelectorOp::DirLoaded { path, entries } => {
self.tree_view.inject_children(path, entries.clone());
if self.reveal_target.is_some() {
if !self.reveal_queue.is_empty() {
let next = self.reveal_queue.remove(0);
self.pending_load = Some(next);
} else if let Some(target) = &self.reveal_target {
if self.tree_view.set_cursor_to(target) {
self.reveal_target = None;
self.reveal_queue.clear();
}
}
}
}
}
Some(Event::applied("file_selector", op.clone()))
}
Operation::NavigateUp => {
match self.active_pane() {
FOCUS_HISTORY => self.history_list.move_up(),
_ => {
if self.filter.is_empty() {
self.tree_view.key_up();
} else {
self.tree_filtered.move_up();
}
}
}
Some(Event::applied("file_selector", op.clone()))
}
Operation::NavigateDown => {
match self.active_pane() {
FOCUS_HISTORY => self.history_list.move_down(),
_ => {
if self.filter.is_empty() {
self.tree_view.key_down();
} else {
self.tree_filtered.move_down();
}
}
}
Some(Event::applied("file_selector", op.clone()))
}
Operation::NavigatePageUp => {
const PAGE: usize = 10;
match self.active_pane() {
FOCUS_HISTORY => {
for _ in 0..PAGE {
self.history_list.move_up();
}
}
_ => {
if self.filter.is_empty() {
for _ in 0..PAGE {
self.tree_view.key_up();
}
} else {
for _ in 0..PAGE {
self.tree_filtered.move_up();
}
}
}
}
Some(Event::applied("file_selector", op.clone()))
}
Operation::NavigatePageDown => {
const PAGE: usize = 10;
match self.active_pane() {
FOCUS_HISTORY => {
for _ in 0..PAGE {
self.history_list.move_down();
}
}
_ => {
if self.filter.is_empty() {
for _ in 0..PAGE {
self.tree_view.key_down();
}
} else {
for _ in 0..PAGE {
self.tree_filtered.move_down();
}
}
}
}
Some(Event::applied("file_selector", op.clone()))
}
_ => None,
}
}
fn render(&self, frame: &mut Frame, area: Rect, theme: &crate::theme::Theme) {
let outer = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(1), Constraint::Min(1)])
.split(area);
let filter_focused = self.focus.is_focused(FOCUS_FILTER);
self.filter.render(frame, outer[0], filter_focused, theme);
let panes = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(35), Constraint::Percentage(65)])
.split(outer[1]);
self.render_history_pane(frame, panes[0], theme);
self.render_tree_pane(frame, panes[1], theme);
}
}