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