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::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    /// Shared index built in the background; `None` while walk is in progress.
73    file_index: SharedFileIndex,
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: 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        // Reveal logic: if an initial path is provided, precompute the ancestor
100        // directories that must be loaded (top→bottom) and queue them. Do NOT
101        // change the filter text or history list.
102        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                    // No directories need loading; attempt to set cursor immediately.
113                    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    /// Which content pane is currently focused (history or tree).
136    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            // When filter is focused, default to history for selection purposes
144            FOCUS_HISTORY
145        }
146    }
147
148    pub fn selected_path(&self) -> Option<PathBuf> {
149        match self.active_pane() {
150            FOCUS_HISTORY => {
151                let path = self.history_list.selected()?;
152                // External files are stored as absolute paths; project files are relative.
153                if path.is_absolute() {
154                    Some(path.clone())
155                } else {
156                    Some(self.project_root.join(path))
157                }
158            }
159            _ => {
160                if self.filter.is_empty() {
161                    self.tree_view.selected_file()
162                } else {
163                    let rel = self.tree_filtered.selected()?;
164                    Some(self.project_root.join(rel))
165                }
166            }
167        }
168    }
169
170    /// Internal accessor used by tests to observe the tree's selected file even
171    /// when the filter is focused. This does not change runtime behavior.
172    #[cfg(test)]
173    pub(crate) fn tree_selected_file(&self) -> Option<PathBuf> {
174        self.tree_view.selected_file()
175    }
176
177
178    fn render_history_pane(&self, frame: &mut Frame, area: Rect, theme: &crate::theme::Theme) {
179        let pane = TitledPane::new("Recent Files", self.focus.is_focused(FOCUS_HISTORY));
180        let content = pane.prepare(frame, area, theme);
181        let pane_bg = pane.bg(theme);
182        let (sel_bg, sel_fg, fg_dim) = (theme.selection_bg(), theme.selection_fg(), theme.fg_dim());
183        frame.render_widget(
184            self.history_list.widget(|path, selected| {
185                let abs = self.project_root.join(path);
186                let dirty = self.dirty_paths.contains(&abs);
187                let max_w = content.width as usize;
188                path_list_item(path, selected, dirty, pane_bg, sel_bg, sel_fg, fg_dim, max_w)
189            }),
190            content,
191        );
192    }
193
194    fn render_tree_pane(&self, frame: &mut Frame, area: Rect, theme: &crate::theme::Theme) {
195        let is_active = self.focus.is_focused(FOCUS_TREE);
196        let root_name = self
197            .project_root
198            .file_name()
199            .map(|n| n.to_string_lossy().into_owned())
200            .unwrap_or_else(|| self.project_root.display().to_string());
201        let pane = TitledPane::new(&root_name, is_active);
202        let content = pane.prepare(frame, area, theme);
203        let pane_bg = pane.bg(theme);
204        let (sel_bg, sel_fg, fg_dim) = (theme.selection_bg(), theme.selection_fg(), theme.fg_dim());
205
206        if self.filter.is_empty() {
207            self.tree_view.render(
208                frame,
209                content,
210                is_active,
211                pane_bg,
212                sel_bg,
213                sel_fg,
214                theme.tree_dir(),
215                theme.fg_dim(),
216            );
217        } else if self.tree_filtered.is_empty() {
218            // "searching…" covers both: index still building AND search in flight.
219            let is_busy = self.search_pending || {
220                let guard = self.file_index.load();
221                (**guard).is_none()
222            };
223            let msg = if is_busy {
224                "  (searching\u{2026})"
225            } else {
226                "  (no matches)"
227            };
228            frame.render_widget(
229                Paragraph::new(Span::styled(msg, Style::default().fg(fg_dim))),
230                content,
231            );
232        } else {
233            frame.render_widget(
234                self.tree_filtered.widget(|path, selected| {
235                    let max_w = content.width as usize;
236                    path_list_item(path, selected, false, pane_bg, sel_bg, sel_fg, fg_dim, max_w)
237                }),
238                content,
239            );
240        }
241    }
242}
243
244impl View for FileSelector {
245    const KIND: crate::views::ViewKind = crate::views::ViewKind::Modal;
246
247    fn save_state(&mut self, _app: &mut crate::app_state::AppState) {}
248
249    /// Translate key input into operations — no mutation of self.
250    fn handle_key(&self, key: KeyEvent) -> Vec<Operation> {
251        match (key.modifiers, key.key) {
252            (_, Key::Enter) => {
253                if let Some(path) = self.selected_path() {
254                    vec![Operation::OpenFile { path }]
255                } else if self.active_pane() == FOCUS_TREE && self.filter.is_empty() {
256                    vec![Operation::FileSelectorLocal(FileSelectorOp::ToggleDir)]
257                } else {
258                    vec![]
259                }
260            }
261
262            (_, Key::Tab) => vec![Operation::Focus(FocusOp::Next)],
263            (_, Key::BackTab) => vec![Operation::Focus(FocusOp::Prev)],
264
265            (_, Key::ArrowUp) => vec![Operation::NavigateUp],
266            (_, Key::ArrowDown) => vec![Operation::NavigateDown],
267            (_, Key::PageUp) => vec![Operation::NavigatePageUp],
268            (_, Key::PageDown) => vec![Operation::NavigatePageDown],
269
270            (_, Key::ArrowLeft) if self.active_pane() == FOCUS_TREE && self.filter.is_empty() => {
271                vec![Operation::FileSelectorLocal(FileSelectorOp::CollapseOrLeft)]
272            }
273            (_, Key::ArrowRight) if self.active_pane() == FOCUS_TREE && self.filter.is_empty() => {
274                vec![Operation::FileSelectorLocal(FileSelectorOp::ExpandOrRight)]
275            }
276
277            (m, Key::Char(' '))
278                if m.is_empty() && self.filter.is_empty() && self.active_pane() == FOCUS_TREE =>
279            {
280                vec![Operation::FileSelectorLocal(FileSelectorOp::ToggleDir)]
281            }
282
283            _ => {
284                if let Some(field_op) = crate::widgets::input_field::key_to_op(key) {
285                    vec![Operation::FileSelectorLocal(FileSelectorOp::FilterInput(field_op))]
286                } else {
287                    vec![]
288                }
289            }
290        }
291    }
292
293    fn handle_mouse(&self, mouse: MouseEvent) -> Vec<Operation> {
294        if !matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) {
295            return vec![];
296        }
297
298        let is_filter_row = mouse.row == 0;
299        if is_filter_row {
300            return vec![];
301        }
302
303        let is_left_pane = mouse.column < 80;
304        if is_left_pane {
305            if !self.focus.is_focused(FOCUS_HISTORY) {
306                return vec![Operation::FileSelectorLocal(FileSelectorOp::SwitchPane)];
307            }
308        } else if !self.focus.is_focused(FOCUS_TREE) {
309            return vec![Operation::FileSelectorLocal(FileSelectorOp::SwitchPane)];
310        }
311
312        vec![]
313    }
314
315    /// Apply operations this view owns.
316    fn handle_operation(&mut self, op: &Operation, _settings: &Settings) -> Option<Event> {
317        match op {
318            Operation::Focus(focus_op) => {
319                match focus_op {
320                    FocusOp::Next => self.focus.focus_next(),
321                    FocusOp::Prev => self.focus.focus_prev(),
322                    _ => {}
323                }
324                Some(Event::applied("file_selector", op.clone()))
325            }
326            Operation::FileSelectorLocal(local_op) => {
327                match local_op {
328                    FileSelectorOp::FilterInput(field_op) => {
329                        self.filter.apply(field_op);
330                        self.search_generation += 1;
331                        self.search_pending = !self.filter.is_empty();
332                        let hist = filter_and_rank(&self.history_all, self.filter.text())
333                            .into_iter()
334                            .cloned()
335                            .collect();
336                        self.history_list.set_items(hist);
337                    }
338                    FileSelectorOp::SetResults { generation, paths } => {
339                        if *generation == self.search_generation {
340                            self.search_pending = false;
341                            self.tree_filtered.set_items(paths.clone());
342                        }
343                    }
344                    FileSelectorOp::SwitchPane => {
345                        // Legacy: toggle between history and tree
346                        let cur = self.focus.current();
347                        if cur == FOCUS_HISTORY || cur == FOCUS_FILTER {
348                            self.focus.set_focus(FOCUS_TREE);
349                        } else {
350                            self.focus.set_focus(FOCUS_HISTORY);
351                        }
352                    }
353                    FileSelectorOp::CollapseOrLeft => self.tree_view.key_left(),
354                    FileSelectorOp::ExpandOrRight => {
355                        self.pending_load = self.tree_view.key_right();
356                    }
357                    FileSelectorOp::ToggleDir => {
358                        self.pending_load = self.tree_view.toggle_selected();
359                    }
360                    FileSelectorOp::DirLoaded { path, entries } => {
361                        self.tree_view.inject_children(path, entries.clone());
362                        // If we were revealing a target, continue with the precomputed queue.
363                        if self.reveal_target.is_some() {
364                            if !self.reveal_queue.is_empty() {
365                                let next = self.reveal_queue.remove(0);
366                                self.pending_load = Some(next);
367                            } else if let Some(target) = &self.reveal_target {
368                                // Final step: set cursor to target.
369                                if self.tree_view.set_cursor_to(target) {
370                                    self.reveal_target = None;
371                                    self.reveal_queue.clear();
372                                }
373                            }
374                        }
375                    }
376                }
377                Some(Event::applied("file_selector", op.clone()))
378            }
379
380            Operation::NavigateUp => {
381                match self.active_pane() {
382                    FOCUS_HISTORY => self.history_list.move_up(),
383                    _ => {
384                        if self.filter.is_empty() {
385                            self.tree_view.key_up();
386                        } else {
387                            self.tree_filtered.move_up();
388                        }
389                    }
390                }
391                Some(Event::applied("file_selector", op.clone()))
392            }
393            Operation::NavigateDown => {
394                match self.active_pane() {
395                    FOCUS_HISTORY => self.history_list.move_down(),
396                    _ => {
397                        if self.filter.is_empty() {
398                            self.tree_view.key_down();
399                        } else {
400                            self.tree_filtered.move_down();
401                        }
402                    }
403                }
404                Some(Event::applied("file_selector", op.clone()))
405            }
406            Operation::NavigatePageUp => {
407                const PAGE: usize = 10;
408                match self.active_pane() {
409                    FOCUS_HISTORY => {
410                        for _ in 0..PAGE {
411                            self.history_list.move_up();
412                        }
413                    }
414                    _ => {
415                        if self.filter.is_empty() {
416                            for _ in 0..PAGE {
417                                self.tree_view.key_up();
418                            }
419                        } else {
420                            for _ in 0..PAGE {
421                                self.tree_filtered.move_up();
422                            }
423                        }
424                    }
425                }
426                Some(Event::applied("file_selector", op.clone()))
427            }
428            Operation::NavigatePageDown => {
429                const PAGE: usize = 10;
430                match self.active_pane() {
431                    FOCUS_HISTORY => {
432                        for _ in 0..PAGE {
433                            self.history_list.move_down();
434                        }
435                    }
436                    _ => {
437                        if self.filter.is_empty() {
438                            for _ in 0..PAGE {
439                                self.tree_view.key_down();
440                            }
441                        } else {
442                            for _ in 0..PAGE {
443                                self.tree_filtered.move_down();
444                            }
445                        }
446                    }
447                }
448                Some(Event::applied("file_selector", op.clone()))
449            }
450
451            // Not this view's operation.
452            _ => None,
453        }
454    }
455
456    fn render(&self, frame: &mut Frame, area: Rect, theme: &crate::theme::Theme) {
457        let outer = Layout::default()
458            .direction(Direction::Vertical)
459            .constraints([Constraint::Length(1), Constraint::Min(1)])
460            .split(area);
461
462        let filter_focused = self.focus.is_focused(FOCUS_FILTER);
463        self.filter.render(frame, outer[0], filter_focused, theme);
464
465        let panes = Layout::default()
466            .direction(Direction::Horizontal)
467            .constraints([Constraint::Percentage(35), Constraint::Percentage(65)])
468            .split(outer[1]);
469        self.render_history_pane(frame, panes[0], theme);
470        self.render_tree_pane(frame, panes[1], theme);
471    }
472}