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 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    /// Internal accessor used by tests to observe the tree's selected file even
166    /// when the filter is focused. This does not change runtime behavior.
167    #[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            // "searching…" covers both: index still building AND search in flight.
214            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    /// Translate key input into operations — no mutation of self.
245    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    /// Apply operations this view owns.
311    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                        // Legacy: toggle between history and tree
341                        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 we were revealing a target, continue with the precomputed queue.
358                        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                                // Final step: set cursor to target.
364                                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            // Not this view's operation.
447            _ => 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}