Skip to main content

oo_ide/views/
file_selector.rs

1//! File selector view.
2//!
3//! Layout:
4//!   ┌─ Filter ──────────────────────────────────┐
5//!   │ query_                                    │
6//!   ├─ Recent Files ────┬─ project/ ────────────┤
7//!   │ src/app.rs        │ > src/                │
8//!   │ src/main.rs       │     views/            │
9//!   │ …                 │     file_selector.rs  │
10//!   └───────────────────┴───────────────────────┘
11//!
12//! Filtering is **async**: when the user types, `FilterEdit` ops update the
13//! local filter string and history list immediately, then `app.rs` spawns a
14//! debounced background search task (100 ms after the last keystroke).
15//! The task sends back a `FileSelectorOp::SetResults` which this view applies.
16
17use 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    /// Shared registry built in the background.
73    file_index: SharedRegistry,
74    /// Current filtered + ranked results shown in the tree pane.
75    tree_filtered: SelectableList<PathBuf>,
76    /// Monotonically increasing generation counter.  Incremented on every
77    /// `FilterEdit`; checked against incoming `SetResults` to discard stale
78    /// results from superseded queries.
79    pub search_generation: u64,
80    /// True while a search task is in flight (between `FilterEdit` and `SetResults`).
81    search_pending: bool,
82    /// Set when the tree requested a lazy directory load.  `app.rs` drains
83    /// this after dispatching the operation and spawns the async read task.
84    pub pending_load: Option<PathBuf>,
85    /// Queue of ancestor directories that must be loaded to reveal a target.
86    reveal_queue: Vec<PathBuf>,
87    /// Target path to reveal after expansion completes.
88    reveal_target: Option<PathBuf>,
89    /// Absolute paths of files that have unsaved modifications (from project state).
90    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        // Reveal logic: if an initial path is provided, precompute the ancestor
105        // directories that must be loaded (top→bottom) and queue them. Do NOT
106        // change the filter text or history list.
107        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                    // No directories need loading; attempt to set cursor immediately.
118                    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    /// Which content pane is currently focused (history or tree).
141    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            // When filter is focused, default to history for selection purposes
149            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                // External files are stored as absolute paths; project files are relative.
158                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    /// Internal accessor used by tests to observe the tree's selected file even
176    /// when the filter is focused. This does not change runtime behavior.
177    #[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            // "searching…" covers both: index still building AND search in flight.
224            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    /// Translate key input into operations — no mutation of self.
255    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    /// Apply operations this view owns.
321    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                        // Legacy: toggle between history and tree
351                        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 we were revealing a target, continue with the precomputed queue.
368                        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                                // Final step: set cursor to target.
374                                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            // Not this view's operation.
457            _ => 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}