1use std::{collections::HashSet, path::{Path, PathBuf}};
18
19use ratatui::{
20 layout::{Constraint, Direction, Layout, Rect},
21 style::Style,
22 text::Span,
23 widgets::Paragraph,
24 Frame,
25};
26
27use crate::prelude::*;
28
29fn build_reveal_queue(project_root: &Path, target: &Path) -> Vec<PathBuf> {
30 let mut queue = Vec::new();
31
32 if let Ok(rel) = target.strip_prefix(project_root)
33 && let Some(parent) = rel.parent() {
34 let mut cur = project_root.to_path_buf();
35 for comp in parent.components() {
36 cur = cur.join(comp.as_os_str());
37 queue.push(cur.clone());
38 }
39 }
40 queue
41}
42
43use file_index::SharedRegistry;
44use input::{Key, KeyEvent, MouseButton, MouseEvent, MouseEventKind};
45use operation::{Event, FileSelectorOp, Operation};
46use settings::Settings;
47use utils::fuzzy::filter_and_rank;
48use views::View;
49use widgets::{
50 file_tree::{path_list_item, FileTreeView},
51 focus::FocusRing,
52 list::SelectableList,
53 pane::TitledPane,
54};
55use widgets::focusable::FocusOp;
56use widgets::input_field::InputField;
57
58const FOCUS_FILTER: &str = "filter";
59const FOCUS_HISTORY: &str = "history";
60const FOCUS_TREE: &str = "tree";
61
62#[derive(Debug)]
63pub struct FileSelector {
64 pub project_root: PathBuf,
65 pub filter: InputField,
66 focus: FocusRing,
67
68 history_all: Vec<PathBuf>,
69 history_list: SelectableList<PathBuf>,
70
71 tree_view: FileTreeView,
72 file_index: SharedRegistry,
74 tree_filtered: SelectableList<PathBuf>,
76 pub search_generation: u64,
80 search_pending: bool,
82 pub pending_load: Option<PathBuf>,
85 reveal_queue: Vec<PathBuf>,
87 reveal_target: Option<PathBuf>,
89 dirty_paths: HashSet<PathBuf>,
91}
92
93impl FileSelector {
94 pub fn new(project_root: PathBuf, history: &[PathBuf], file_index: SharedRegistry, dirty_paths: HashSet<PathBuf>, initial_path: Option<PathBuf>) -> Self {
95 let guard = file_index.load();
96 let mut tree_view = if let Some(reg) = (**guard).as_ref() {
97 FileTreeView::from_registry(reg, &project_root)
98 } else {
99 FileTreeView::from_dir(&project_root)
100 };
101 let history_all = history.to_vec();
102 let history_list = SelectableList::new(history_all.clone());
103
104 let mut reveal_queue: Vec<PathBuf> = Vec::new();
108 let mut reveal_target: Option<PathBuf> = None;
109 let mut pending_load_val: Option<PathBuf> = None;
110 if let Some(init) = initial_path.clone()
111 && init.exists() && init.starts_with(&project_root) {
112 reveal_target = Some(init.clone());
113 reveal_queue = build_reveal_queue(&project_root, &init);
114 if !reveal_queue.is_empty() {
115 pending_load_val = Some(reveal_queue.remove(0));
116 } else {
117 let _ = tree_view.set_cursor_to(&init);
119 }
120 }
121
122 Self {
123 project_root,
124 filter: InputField::new("Filter"),
125 focus: FocusRing::new(vec![FOCUS_FILTER, FOCUS_HISTORY, FOCUS_TREE]),
126 history_all,
127 history_list,
128 tree_view,
129 file_index,
130 tree_filtered: SelectableList::new(vec![]),
131 search_generation: 0,
132 search_pending: false,
133 pending_load: pending_load_val,
134 reveal_queue,
135 reveal_target,
136 dirty_paths,
137 }
138 }
139
140 fn active_pane(&self) -> &str {
142 let cur = self.focus.current();
143 if cur == FOCUS_HISTORY {
144 FOCUS_HISTORY
145 } else if cur == FOCUS_TREE {
146 FOCUS_TREE
147 } else {
148 FOCUS_HISTORY
150 }
151 }
152
153 pub fn selected_path(&self) -> Option<PathBuf> {
154 match self.active_pane() {
155 FOCUS_HISTORY => {
156 let path = self.history_list.selected()?;
157 if path.is_absolute() {
159 Some(path.clone())
160 } else {
161 Some(self.project_root.join(path))
162 }
163 }
164 _ => {
165 if self.filter.is_empty() {
166 self.tree_view.selected_file()
167 } else {
168 let rel = self.tree_filtered.selected()?;
169 Some(self.project_root.join(rel))
170 }
171 }
172 }
173 }
174
175 #[cfg(test)]
178 pub(crate) fn tree_selected_file(&self) -> Option<PathBuf> {
179 self.tree_view.selected_file()
180 }
181
182
183 fn render_history_pane(&self, frame: &mut Frame, area: Rect, theme: &crate::theme::Theme) {
184 let pane = TitledPane::new("Recent Files", self.focus.is_focused(FOCUS_HISTORY));
185 let content = pane.prepare(frame, area, theme);
186 let pane_bg = pane.bg(theme);
187 let (sel_bg, sel_fg, fg_dim) = (theme.selection_bg(), theme.selection_fg(), theme.fg_dim());
188 frame.render_widget(
189 self.history_list.widget(|path, selected| {
190 let abs = self.project_root.join(path);
191 let dirty = self.dirty_paths.contains(&abs);
192 let max_w = content.width as usize;
193 path_list_item(path, selected, dirty, pane_bg, sel_bg, sel_fg, fg_dim, max_w)
194 }),
195 content,
196 );
197 }
198
199 fn render_tree_pane(&self, frame: &mut Frame, area: Rect, theme: &crate::theme::Theme) {
200 let is_active = self.focus.is_focused(FOCUS_TREE);
201 let root_name = self
202 .project_root
203 .file_name()
204 .map(|n| n.to_string_lossy().into_owned())
205 .unwrap_or_else(|| self.project_root.display().to_string());
206 let pane = TitledPane::new(&root_name, is_active);
207 let content = pane.prepare(frame, area, theme);
208 let pane_bg = pane.bg(theme);
209 let (sel_bg, sel_fg, fg_dim) = (theme.selection_bg(), theme.selection_fg(), theme.fg_dim());
210
211 if self.filter.is_empty() {
212 self.tree_view.render(
213 frame,
214 content,
215 is_active,
216 pane_bg,
217 sel_bg,
218 sel_fg,
219 theme.tree_dir(),
220 theme.fg_dim(),
221 );
222 } else if self.tree_filtered.is_empty() {
223 let is_busy = self.search_pending || {
225 let guard = self.file_index.load();
226 (**guard).as_ref().is_none()
227 };
228 let msg = if is_busy {
229 " (searching\u{2026})"
230 } else {
231 " (no matches)"
232 };
233 frame.render_widget(
234 Paragraph::new(Span::styled(msg, Style::default().fg(fg_dim))),
235 content,
236 );
237 } else {
238 frame.render_widget(
239 self.tree_filtered.widget(|path, selected| {
240 let max_w = content.width as usize;
241 path_list_item(path, selected, false, pane_bg, sel_bg, sel_fg, fg_dim, max_w)
242 }),
243 content,
244 );
245 }
246 }
247}
248
249impl View for FileSelector {
250 const KIND: crate::views::ViewKind = crate::views::ViewKind::Modal;
251
252 fn save_state(&mut self, _app: &mut crate::app_state::AppState) {}
253
254 fn handle_key(&self, key: KeyEvent) -> Vec<Operation> {
256 match (key.modifiers, key.key) {
257 (_, Key::Enter) => {
258 if let Some(path) = self.selected_path() {
259 vec![Operation::OpenFile { path }]
260 } else if self.active_pane() == FOCUS_TREE && self.filter.is_empty() {
261 vec![Operation::FileSelectorLocal(FileSelectorOp::ToggleDir)]
262 } else {
263 vec![]
264 }
265 }
266
267 (_, Key::Tab) => vec![Operation::Focus(FocusOp::Next)],
268 (_, Key::BackTab) => vec![Operation::Focus(FocusOp::Prev)],
269
270 (_, Key::ArrowUp) => vec![Operation::NavigateUp],
271 (_, Key::ArrowDown) => vec![Operation::NavigateDown],
272 (_, Key::PageUp) => vec![Operation::NavigatePageUp],
273 (_, Key::PageDown) => vec![Operation::NavigatePageDown],
274
275 (_, Key::ArrowLeft) if self.active_pane() == FOCUS_TREE && self.filter.is_empty() => {
276 vec![Operation::FileSelectorLocal(FileSelectorOp::CollapseOrLeft)]
277 }
278 (_, Key::ArrowRight) if self.active_pane() == FOCUS_TREE && self.filter.is_empty() => {
279 vec![Operation::FileSelectorLocal(FileSelectorOp::ExpandOrRight)]
280 }
281
282 (m, Key::Char(' '))
283 if m.is_empty() && self.filter.is_empty() && self.active_pane() == FOCUS_TREE =>
284 {
285 vec![Operation::FileSelectorLocal(FileSelectorOp::ToggleDir)]
286 }
287
288 _ => {
289 if let Some(field_op) = crate::widgets::input_field::key_to_op(key) {
290 vec![Operation::FileSelectorLocal(FileSelectorOp::FilterInput(field_op))]
291 } else {
292 vec![]
293 }
294 }
295 }
296 }
297
298 fn handle_mouse(&self, mouse: MouseEvent) -> Vec<Operation> {
299 if !matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) {
300 return vec![];
301 }
302
303 let is_filter_row = mouse.row == 0;
304 if is_filter_row {
305 return vec![];
306 }
307
308 let is_left_pane = mouse.column < 80;
309 if is_left_pane {
310 if !self.focus.is_focused(FOCUS_HISTORY) {
311 return vec![Operation::FileSelectorLocal(FileSelectorOp::SwitchPane)];
312 }
313 } else if !self.focus.is_focused(FOCUS_TREE) {
314 return vec![Operation::FileSelectorLocal(FileSelectorOp::SwitchPane)];
315 }
316
317 vec![]
318 }
319
320 fn handle_operation(&mut self, op: &Operation, _settings: &Settings) -> Option<Event> {
322 match op {
323 Operation::Focus(focus_op) => {
324 match focus_op {
325 FocusOp::Next => self.focus.focus_next(),
326 FocusOp::Prev => self.focus.focus_prev(),
327 _ => {}
328 }
329 Some(Event::applied("file_selector", op.clone()))
330 }
331 Operation::FileSelectorLocal(local_op) => {
332 match local_op {
333 FileSelectorOp::FilterInput(field_op) => {
334 self.filter.apply(field_op);
335 self.search_generation += 1;
336 self.search_pending = !self.filter.is_empty();
337 let hist = filter_and_rank(&self.history_all, self.filter.text())
338 .into_iter()
339 .cloned()
340 .collect();
341 self.history_list.set_items(hist);
342 }
343 FileSelectorOp::SetResults { generation, paths } => {
344 if *generation == self.search_generation {
345 self.search_pending = false;
346 self.tree_filtered.set_items(paths.clone());
347 }
348 }
349 FileSelectorOp::SwitchPane => {
350 let cur = self.focus.current();
352 if cur == FOCUS_HISTORY || cur == FOCUS_FILTER {
353 self.focus.set_focus(FOCUS_TREE);
354 } else {
355 self.focus.set_focus(FOCUS_HISTORY);
356 }
357 }
358 FileSelectorOp::CollapseOrLeft => self.tree_view.key_left(),
359 FileSelectorOp::ExpandOrRight => {
360 self.pending_load = self.tree_view.key_right();
361 }
362 FileSelectorOp::ToggleDir => {
363 self.pending_load = self.tree_view.toggle_selected();
364 }
365 FileSelectorOp::DirLoaded { path, entries } => {
366 self.tree_view.inject_children(path, entries.clone());
367 if self.reveal_target.is_some() {
369 if !self.reveal_queue.is_empty() {
370 let next = self.reveal_queue.remove(0);
371 self.pending_load = Some(next);
372 } else if let Some(target) = &self.reveal_target {
373 if self.tree_view.set_cursor_to(target) {
375 self.reveal_target = None;
376 self.reveal_queue.clear();
377 }
378 }
379 }
380 }
381 }
382 Some(Event::applied("file_selector", op.clone()))
383 }
384
385 Operation::NavigateUp => {
386 match self.active_pane() {
387 FOCUS_HISTORY => self.history_list.move_up(),
388 _ => {
389 if self.filter.is_empty() {
390 self.tree_view.key_up();
391 } else {
392 self.tree_filtered.move_up();
393 }
394 }
395 }
396 Some(Event::applied("file_selector", op.clone()))
397 }
398 Operation::NavigateDown => {
399 match self.active_pane() {
400 FOCUS_HISTORY => self.history_list.move_down(),
401 _ => {
402 if self.filter.is_empty() {
403 self.tree_view.key_down();
404 } else {
405 self.tree_filtered.move_down();
406 }
407 }
408 }
409 Some(Event::applied("file_selector", op.clone()))
410 }
411 Operation::NavigatePageUp => {
412 const PAGE: usize = 10;
413 match self.active_pane() {
414 FOCUS_HISTORY => {
415 for _ in 0..PAGE {
416 self.history_list.move_up();
417 }
418 }
419 _ => {
420 if self.filter.is_empty() {
421 for _ in 0..PAGE {
422 self.tree_view.key_up();
423 }
424 } else {
425 for _ in 0..PAGE {
426 self.tree_filtered.move_up();
427 }
428 }
429 }
430 }
431 Some(Event::applied("file_selector", op.clone()))
432 }
433 Operation::NavigatePageDown => {
434 const PAGE: usize = 10;
435 match self.active_pane() {
436 FOCUS_HISTORY => {
437 for _ in 0..PAGE {
438 self.history_list.move_down();
439 }
440 }
441 _ => {
442 if self.filter.is_empty() {
443 for _ in 0..PAGE {
444 self.tree_view.key_down();
445 }
446 } else {
447 for _ in 0..PAGE {
448 self.tree_filtered.move_down();
449 }
450 }
451 }
452 }
453 Some(Event::applied("file_selector", op.clone()))
454 }
455
456 _ => None,
458 }
459 }
460
461 fn render(&self, frame: &mut Frame, area: Rect, theme: &crate::theme::Theme) {
462 let outer = Layout::default()
463 .direction(Direction::Vertical)
464 .constraints([Constraint::Length(1), Constraint::Min(1)])
465 .split(area);
466
467 let filter_focused = self.focus.is_focused(FOCUS_FILTER);
468 self.filter.render(frame, outer[0], filter_focused, theme);
469
470 let panes = Layout::default()
471 .direction(Direction::Horizontal)
472 .constraints([Constraint::Percentage(35), Constraint::Percentage(65)])
473 .split(outer[1]);
474 self.render_history_pane(frame, panes[0], theme);
475 self.render_tree_pane(frame, panes[1], theme);
476 }
477}