返回 CodeWhale
file_picker.rs
根目录 / crates / tui / src / tui / file_picker.rs
1 //! Fuzzy file-picker modal (Ctrl+P).
2 //!
3 //! Opens an overlay populated with workspace-relative paths discovered by a
4 //! single-pass `WalkBuilder` walk (depth from `mention_walk_depth`, default
5 //! 10, `0` = unlimited; hidden=true, follow_links=false,
6 //! `.gitignore` honored). The walk keeps at most [`MAX_CANDIDATES`] paths in
7 //! walk order so opening the picker stays bounded on huge repos. Subsequent
8 //! keystrokes filter that cached list in memory using a small subsequence +
9 //! first-letter-bonus scorer — no per-keystroke disk traversal.
10 //!
11 //! When the typed query matches nothing in that truncated index, a targeted
12 //! rescan walks from the query's existing path prefix (or the workspace root)
13 //! and collects only matching files. Raising `mention_walk_depth` cannot
14 //! recover files past the 20k cutoff; the rescan can (#2488).
15 //!
16 //! Enter emits a [`ViewEvent::FilePickerSelected`] which the UI handler turns
17 //! into an `@<path>` insertion at the composer cursor.
18
19 use std::cell::RefCell;
20 use std::collections::HashSet;
21 use std::path::{Path, PathBuf};
22 use std::sync::{Arc, Mutex};
23
24 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
25 use ignore::WalkBuilder;
26 use ratatui::{
27 buffer::Buffer,
28 layout::Rect,
29 style::Style,
30 text::{Line, Span},
31 widgets::{Paragraph, Widget},
32 };
33
34 use crate::tui::menu_style;
35 use crate::tui::views::{
36 ActionHint, ModalKind, ModalView, ViewAction, ViewEvent, render_modal_footer,
37 render_panel_scroll_rail, render_underwater_surface,
38 };
39 use crate::workspace_discovery::{DISCOVERY_ALWAYS_DIRS, path_is_excluded_from_discovery};
40 use codewhale_localization::{Locale, MessageId, tr};
41 use codewhale_palette as palette;
42
43 /// Maximum number of candidates collected from the initial walk. Keeps memory
44 /// bounded for very large monorepos; matches the limits codex-rs uses for the
45 /// equivalent overlay. Files past this cutoff are recovered by a query-targeted
46 /// rescan rather than by raising the cap or `mention_walk_depth` (#2488).
47 const MAX_CANDIDATES: usize = 20_000;
48
49 /// Cap on files a miss-rescan may add. The walk itself continues past
50 /// [`MAX_CANDIDATES`] looking for matches; only this many hits are merged.
51 const MAX_RESCAN_HITS: usize = 512;
52
53 /// Default walk depth used by the picker's own tests. Production callers pass
54 /// the configured `mention_walk_depth` (default 10, `0` = unlimited) through
55 /// [`FilePickerView::new_with_relevance_and_depth`], mirroring the `Workspace`
56 /// fuzzy index default (`DEFAULT_COMPLETIONS_WALK_DEPTH`).
57 #[cfg(test)]
58 const WALK_DEPTH: usize = 10;
59
60 /// Visible candidate rows in the overlay.
61 const VISIBLE_ROWS: usize = 14;
62
63 const MODIFIED_BOOST: i32 = 360;
64 const MENTIONED_BOOST: i32 = 240;
65 const TOOL_BOOST: i32 = 160;
66
67 /// Working-set hints captured when the picker opens.
68 ///
69 /// The picker keeps this as plain path strings so filtering stays in-memory and
70 /// per-keystroke work remains the same shape as the original fuzzy search.
71 #[derive(Debug, Clone, Default, PartialEq, Eq)]
72 pub struct FilePickerRelevance {
73 modified: HashSet<String>,
74 mentioned: HashSet<String>,
75 tool: HashSet<String>,
76 }
77
78 impl FilePickerRelevance {
79 pub fn mark_modified(&mut self, path: impl Into<String>) {
80 let path = path.into();
81 if !path.is_empty() {
82 self.modified.insert(path);
83 }
84 }
85
86 pub fn mark_mentioned(&mut self, path: impl Into<String>) {
87 let path = path.into();
88 if !path.is_empty() {
89 self.mentioned.insert(path);
90 }
91 }
92
93 pub fn mark_tool(&mut self, path: impl Into<String>) {
94 let path = path.into();
95 if !path.is_empty() {
96 self.tool.insert(path);
97 }
98 }
99
100 fn boost_for(&self, path: &str) -> i32 {
101 let mut boost = 0;
102 if self.modified.contains(path) {
103 boost += MODIFIED_BOOST;
104 }
105 if self.mentioned.contains(path) {
106 boost += MENTIONED_BOOST;
107 }
108 if self.tool.contains(path) {
109 boost += TOOL_BOOST;
110 }
111 boost
112 }
113
114 fn markers_for(&self, path: &str) -> String {
115 let mut markers = String::with_capacity(3);
116 markers.push(if self.modified.contains(path) {
117 'M'
118 } else {
119 ' '
120 });
121 markers.push(if self.mentioned.contains(path) {
122 '@'
123 } else {
124 ' '
125 });
126 markers.push(if self.tool.contains(path) { 'T' } else { ' ' });
127 markers
128 }
129 }
130
131 pub struct FilePickerView {
132 /// All workspace-relative candidate paths, captured once at construction.
133 candidates: Vec<String>,
134 /// Working-set relevance hints, captured once at construction.
135 relevance: FilePickerRelevance,
136 /// Filtered indices into `candidates`, sorted by descending score.
137 filtered: Vec<usize>,
138 /// User's typed query (lowercased on each refilter).
139 query: String,
140 /// Selected row within `filtered`.
141 selected: usize,
142 /// Top of the visible window within `filtered`.
143 scroll: usize,
144 /// Exact visible row targets from the last render for mouse parity.
145 last_row_hitboxes: RefCell<Vec<(u16, usize)>>,
146 /// UI locale captured from the app at construction (#4057 wave 2).
147 locale: Locale,
148 /// True until the background workspace scan delivers (#3905). The picker
149 /// paints immediately in this state instead of blocking the event loop on
150 /// a `git status` subprocess and a 20k-file walk.
151 is_loading: bool,
152 /// True while a query-targeted rescan is in flight (#2488).
153 is_rescanning: bool,
154 /// Where the background scan drops its result. `None` once drained, or
155 /// when the scan ran synchronously (no tokio runtime, i.e. unit tests).
156 loading_cell: Option<Arc<Mutex<Option<PickerScan>>>>,
157 /// Retained so a query that misses the truncated index can rescan.
158 workspace_root: PathBuf,
159 /// Depth used by the initial walk and by a miss-rescan (`None` = unlimited).
160 max_depth: Option<usize>,
161 /// True when the initial walk stopped at [`MAX_CANDIDATES`].
162 index_truncated: bool,
163 /// Lowercased query a rescan was last completed for. Prevents repeating
164 /// a walk that already produced no extra hits.
165 rescan_query: Option<String>,
166 }
167
168 /// What the off-thread workspace scan produces: the candidate paths and the
169 /// git-reported modified paths, which are the only two blocking parts of
170 /// building this picker.
171 struct WorkspaceScan {
172 candidates: Vec<String>,
173 modified: Vec<String>,
174 truncated: bool,
175 }
176
177 /// Either the opening walk or a later query-targeted miss-rescan.
178 enum PickerScan {
179 Initial(WorkspaceScan),
180 Targeted { query: String, hits: Vec<String> },
181 }
182
183 struct CandidateWalk {
184 paths: Vec<String>,
185 truncated: bool,
186 }
187
188 impl FilePickerView {
189 /// Build a picker with working-set relevance hints, using the default
190 /// walk depth ([`WALK_DEPTH`]). Test-only convenience; production code uses
191 /// [`FilePickerView::new_with_relevance_and_depth`] with the configured
192 /// `mention_walk_depth`.
193 #[cfg(test)]
194 pub fn new_with_relevance(workspace_root: &Path, relevance: FilePickerRelevance) -> Self {
195 Self::new_with_relevance_and_depth(workspace_root, relevance, WALK_DEPTH, Locale::En)
196 }
197
198 /// Build a picker with working-set relevance hints and an explicit walk
199 /// depth. A depth of `0` disables the depth limit so files in deeply
200 /// nested workspaces (>= 6 levels) remain discoverable. Files past the
201 /// [`MAX_CANDIDATES`] walk-order cutoff are recovered by a targeted
202 /// rescan when the typed query misses the index (#2488).
203 pub fn new_with_relevance_and_depth(
204 workspace_root: &Path,
205 relevance: FilePickerRelevance,
206 walk_depth: usize,
207 locale: Locale,
208 ) -> Self {
209 let max_depth = if walk_depth == 0 {
210 None
211 } else {
212 Some(walk_depth)
213 };
214
215 // Outside a tokio runtime (plain unit tests) do the work inline, so
216 // tests keep observing a fully-populated picker from the constructor.
217 if tokio::runtime::Handle::try_current().is_err() {
218 let walk = collect_candidates_limited(workspace_root, max_depth, MAX_CANDIDATES);
219 let mut relevance = relevance;
220 for path in crate::tui::file_picker_relevance::modified_workspace_paths(workspace_root)
221 {
222 relevance.mark_modified(path);
223 }
224 let mut view = Self {
225 candidates: walk.paths,
226 relevance,
227 filtered: Vec::new(),
228 query: String::new(),
229 selected: 0,
230 scroll: 0,
231 last_row_hitboxes: RefCell::new(Vec::new()),
232 locale,
233 is_loading: false,
234 is_rescanning: false,
235 loading_cell: None,
236 workspace_root: workspace_root.to_path_buf(),
237 max_depth,
238 index_truncated: walk.truncated,
239 rescan_query: None,
240 };
241 view.refilter();
242 return view;
243 }
244
245 // Both halves of the scan are blocking: `git status` is a subprocess,
246 // and the walk visits up to MAX_CANDIDATES paths. Neither belongs on
247 // the event loop — Ctrl+P used to freeze the whole TUI until both
248 // finished (#3905), the same failure #3899/#3900 fixed for the
249 // adjacent @-mention and file-tree paths.
250 let loading_cell = Arc::new(Mutex::new(None));
251 let cell = loading_cell.clone();
252 let root = workspace_root.to_path_buf();
253 crate::utils::spawn_blocking_supervised("file-picker-scan", move || {
254 let walk = collect_candidates_limited(&root, max_depth, MAX_CANDIDATES);
255 let scan = PickerScan::Initial(WorkspaceScan {
256 candidates: walk.paths,
257 modified: crate::tui::file_picker_relevance::modified_workspace_paths(&root),
258 truncated: walk.truncated,
259 });
260 if let Ok(mut guard) = cell.lock() {
261 *guard = Some(scan);
262 }
263 });
264
265 let mut view = Self {
266 candidates: Vec::new(),
267 relevance,
268 filtered: Vec::new(),
269 query: String::new(),
270 selected: 0,
271 scroll: 0,
272 last_row_hitboxes: RefCell::new(Vec::new()),
273 locale,
274 is_loading: true,
275 is_rescanning: false,
276 loading_cell: Some(loading_cell),
277 workspace_root: workspace_root.to_path_buf(),
278 max_depth,
279 index_truncated: false,
280 rescan_query: None,
281 };
282 view.refilter();
283 view
284 }
285
286 /// Test helper: a picker whose in-memory index is already known, including
287 /// whether that index hit [`MAX_CANDIDATES`]. Used to exercise miss-rescan
288 /// without creating 20k files.
289 #[cfg(test)]
290 fn from_preloaded(
291 workspace_root: &Path,
292 candidates: Vec<String>,
293 truncated: bool,
294 max_depth: Option<usize>,
295 ) -> Self {
296 let mut view = Self {
297 candidates,
298 relevance: FilePickerRelevance::default(),
299 filtered: Vec::new(),
300 query: String::new(),
301 selected: 0,
302 scroll: 0,
303 last_row_hitboxes: RefCell::new(Vec::new()),
304 locale: Locale::En,
305 is_loading: false,
306 is_rescanning: false,
307 loading_cell: None,
308 workspace_root: workspace_root.to_path_buf(),
309 max_depth,
310 index_truncated: truncated,
311 rescan_query: None,
312 };
313 view.refilter();
314 view
315 }
316
317 /// Drain the background scan if it has landed. Called from `tick`, which
318 /// the view stack runs on the top view every loop iteration.
319 fn poll_loading(&mut self) {
320 if !self.is_loading && !self.is_rescanning {
321 return;
322 }
323 // Take the Arc out temporarily to avoid a double-borrow of self.
324 let Some(cell) = self.loading_cell.take() else {
325 self.is_loading = false;
326 self.is_rescanning = false;
327 return;
328 };
329 let scan = cell.lock().ok().and_then(|mut guard| guard.take());
330 match scan {
331 Some(PickerScan::Initial(scan)) => {
332 self.candidates = scan.candidates;
333 self.index_truncated = scan.truncated;
334 for path in scan.modified {
335 self.relevance.mark_modified(path);
336 }
337 self.is_loading = false;
338 // The user may already have typed while the scan ran; refilter
339 // against the query they actually have, not an empty one.
340 self.refilter();
341 }
342 Some(PickerScan::Targeted { query, hits }) => {
343 let current = self.query.trim().to_lowercase();
344 self.is_rescanning = false;
345 if current == query {
346 self.merge_rescan_hits(&query, hits);
347 } else {
348 // Query moved on while the walk ran; try again for the
349 // query the user actually has.
350 self.maybe_rescan();
351 }
352 }
353 None => self.loading_cell = Some(cell),
354 }
355 }
356
357 fn refilter(&mut self) {
358 self.refilter_from_index();
359 self.maybe_rescan();
360 }
361
362 fn refilter_from_index(&mut self) {
363 let query = self.query.trim().to_lowercase();
364 let mut scored: Vec<(usize, i32, i32, i32)> = if query.is_empty() {
365 self.candidates
366 .iter()
367 .enumerate()
368 .map(|(idx, path)| {
369 let boost = self.relevance.boost_for(path);
370 (idx, boost, 0, boost)
371 })
372 .collect()
373 } else {
374 self.candidates
375 .iter()
376 .enumerate()
377 .filter_map(|(idx, path)| {
378 score(&query, path).map(|fuzzy| {
379 let boost = self.relevance.boost_for(path);
380 (idx, fuzzy + boost, fuzzy, boost)
381 })
382 })
383 .collect()
384 };
385
386 // Higher scores first; tie-break by ascending path length, then lex order
387 // so shorter / more central matches surface above deep nested ones.
388 scored.sort_by(|a, b| {
389 b.1.cmp(&a.1)
390 .then_with(|| b.2.cmp(&a.2))
391 .then_with(|| b.3.cmp(&a.3))
392 .then_with(|| self.candidates[a.0].len().cmp(&self.candidates[b.0].len()))
393 .then_with(|| self.candidates[a.0].cmp(&self.candidates[b.0]))
394 });
395
396 self.filtered = scored.into_iter().map(|(idx, _, _, _)| idx).collect();
397 if self.filtered.is_empty() {
398 self.selected = 0;
399 self.scroll = 0;
400 } else if self.selected >= self.filtered.len() {
401 self.selected = self.filtered.len() - 1;
402 }
403 self.adjust_scroll();
404 }
405
406 /// When the in-memory index is known-incomplete and the typed query
407 /// matches nothing in it, walk from the query's existing path prefix
408 /// (or the workspace root) collecting only matching files (#2488).
409 fn maybe_rescan(&mut self) {
410 if self.is_loading || self.is_rescanning || !self.index_truncated {
411 return;
412 }
413 if !self.filtered.is_empty() {
414 return;
415 }
416 let query = self.query.trim().to_lowercase();
417 if query.is_empty() {
418 return;
419 }
420 // A single letter almost never misses a 20k index; require a bit
421 // more specificity so a stray miss does not walk a huge tree.
422 let specific_enough =
423 query.chars().count() >= 2 || query.contains('/') || query.contains('\\');
424 if !specific_enough {
425 return;
426 }
427 if self.rescan_query.as_deref() == Some(query.as_str()) {
428 return;
429 }
430
431 if tokio::runtime::Handle::try_current().is_err() {
432 let hits = collect_query_matches(
433 &self.workspace_root,
434 self.max_depth,
435 &query,
436 MAX_RESCAN_HITS,
437 );
438 self.merge_rescan_hits(&query, hits);
439 return;
440 }
441
442 self.is_rescanning = true;
443 let loading_cell = Arc::new(Mutex::new(None));
444 let cell = loading_cell.clone();
445 self.loading_cell = Some(loading_cell);
446 let root = self.workspace_root.clone();
447 let max_depth = self.max_depth;
448 let query_for_scan = query.clone();
449 crate::utils::spawn_blocking_supervised("file-picker-rescan", move || {
450 let hits = collect_query_matches(&root, max_depth, &query_for_scan, MAX_RESCAN_HITS);
451 if let Ok(mut guard) = cell.lock() {
452 *guard = Some(PickerScan::Targeted {
453 query: query_for_scan,
454 hits,
455 });
456 }
457 });
458 }
459
460 fn merge_rescan_hits(&mut self, query: &str, hits: Vec<String>) {
461 self.rescan_query = Some(query.to_string());
462 self.is_rescanning = false;
463 if !hits.is_empty() {
464 for hit in hits {
465 if !self.candidates.iter().any(|existing| existing == &hit) {
466 self.candidates.push(hit);
467 }
468 }
469 }
470 self.refilter_from_index();
471 }
472
473 fn adjust_scroll(&mut self) {
474 if self.filtered.is_empty() {
475 self.scroll = 0;
476 return;
477 }
478 if self.selected < self.scroll {
479 self.scroll = self.selected;
480 } else if self.selected >= self.scroll + VISIBLE_ROWS {
481 self.scroll = self.selected + 1 - VISIBLE_ROWS;
482 }
483 }
484
485 /// Apply one [`list_nav`](crate::tui::list_nav) motion (#6290), returning
486 /// whether it was consumed. This is a typing surface, so only the
487 /// typing-safe vocabulary applies — no letter alias may eat a query
488 /// character. `Prev`/`Next` wrap; paging and Home/End clamp.
489 fn apply_motion(&mut self, motion: crate::tui::list_nav::Motion) -> bool {
490 if self.filtered.is_empty() {
491 return false;
492 }
493 let Some(next) =
494 crate::tui::list_nav::apply(self.selected, self.filtered.len(), VISIBLE_ROWS, motion)
495 else {
496 return false;
497 };
498 self.selected = next;
499 self.adjust_scroll();
500 true
501 }
502
503 fn selected_path(&self) -> Option<&str> {
504 let idx = *self.filtered.get(self.selected)?;
505 self.candidates.get(idx).map(String::as_str)
506 }
507
508 /// Visible candidate count for tests / diagnostics.
509 #[cfg(test)]
510 pub fn visible_count(&self) -> usize {
511 self.filtered.len()
512 }
513
514 #[cfg(test)]
515 pub fn query(&self) -> &str {
516 &self.query
517 }
518
519 #[cfg(test)]
520 pub fn selected_for_test(&self) -> Option<&str> {
521 self.selected_path()
522 }
523
524 #[cfg(test)]
525 pub fn markers_for_test(&self, path: &str) -> String {
526 self.relevance.markers_for(path)
527 }
528 }
529
530 impl ModalView for FilePickerView {
531 fn kind(&self) -> ModalKind {
532 ModalKind::FilePicker
533 }
534
535 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
536 self
537 }
538
539 fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
540 // Movement keys come from the shared vocabulary (#6290), typing-safe
541 // set only. This match owns the filter's own keys.
542 if let Some(motion) = crate::tui::list_nav::motion_while_typing(&key)
543 && self.apply_motion(motion)
544 {
545 return ViewAction::None;
546 }
547 match key.code {
548 KeyCode::Esc => ViewAction::Close,
549 KeyCode::Enter => {
550 if let Some(path) = self.selected_path() {
551 let path = path.to_string();
552 return ViewAction::EmitAndClose(ViewEvent::FilePickerSelected { path });
553 }
554 ViewAction::Close
555 }
556 KeyCode::Backspace => {
557 self.query.pop();
558 self.selected = 0;
559 self.scroll = 0;
560 self.refilter();
561 ViewAction::None
562 }
563 KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
564 self.query.clear();
565 self.selected = 0;
566 self.scroll = 0;
567 self.refilter();
568 ViewAction::None
569 }
570 KeyCode::Char(ch)
571 if !key.modifiers.contains(KeyModifiers::CONTROL)
572 && !key.modifiers.contains(KeyModifiers::ALT)
573 && !ch.is_control() =>
574 {
575 self.query.push(ch);
576 self.selected = 0;
577 self.scroll = 0;
578 self.refilter();
579 ViewAction::None
580 }
581 _ => ViewAction::None,
582 }
583 }
584
585 fn handle_mouse(&mut self, mouse: MouseEvent) -> ViewAction {
586 match mouse.kind {
587 MouseEventKind::ScrollUp => {
588 self.apply_motion(crate::tui::list_nav::Motion::Prev);
589 ViewAction::None
590 }
591 MouseEventKind::ScrollDown => {
592 self.apply_motion(crate::tui::list_nav::Motion::Next);
593 ViewAction::None
594 }
595 MouseEventKind::Down(MouseButton::Left) => {
596 let hit = self
597 .last_row_hitboxes
598 .borrow()
599 .iter()
600 .find_map(|(y, idx)| (*y == mouse.row).then_some(*idx));
601 let Some(idx) = hit else {
602 return ViewAction::None;
603 };
604 if idx == self.selected {
605 if let Some(path) = self.selected_path() {
606 return ViewAction::EmitAndClose(ViewEvent::FilePickerSelected {
607 path: path.to_string(),
608 });
609 }
610 } else {
611 self.selected = idx;
612 self.adjust_scroll();
613 }
614 ViewAction::None
615 }
616 _ => ViewAction::None,
617 }
618 }
619
620 fn tick(&mut self) -> ViewAction {
621 self.poll_loading();
622 ViewAction::None
623 }
624
625 fn render(&self, area: Rect, buf: &mut Buffer) {
626 let match_count = self.filtered.len();
627 let title = if match_count == 1 {
628 tr(self.locale, MessageId::FilePickerMatchSingular).into_owned()
629 } else {
630 tr(self.locale, MessageId::FilePickerMatchesPlural)
631 .replace("{count}", &match_count.to_string())
632 };
633 let inner = render_underwater_surface(area, buf, title);
634
635 let content = render_modal_footer(
636 inner,
637 buf,
638 &[
639 ActionHint::new("↑/↓", "move"),
640 ActionHint::new("Enter", "insert @path"),
641 ActionHint::new("Esc", "cancel"),
642 ],
643 );
644 let visible = VISIBLE_ROWS.min(content.height.saturating_sub(2) as usize);
645 let content = render_panel_scroll_rail(
646 content,
647 buf,
648 self.filtered.len(),
649 self.scroll,
650 visible,
651 true,
652 );
653
654 let mut lines: Vec<Line<'static>> = Vec::new();
655 // Query line.
656 lines.push(Line::from(vec![
657 Span::styled("> ", Style::default().fg(palette::WHALE_ACTION).bold()),
658 // Explicit ink: the picker paints WHALE_BG, so an unstyled query
659 // would inherit a dark terminal default on light-profile terminals.
660 Span::styled(
661 self.query.clone(),
662 Style::default().fg(palette::TEXT_PRIMARY),
663 ),
664 Span::styled(
665 " ",
666 Style::default()
667 .fg(palette::WHALE_BG)
668 .bg(palette::WHALE_ACTION),
669 ),
670 ]));
671 lines.push(Line::from(""));
672
673 let end = (self.scroll + visible).min(self.filtered.len());
674 self.last_row_hitboxes.borrow_mut().clear();
675 if self.is_loading || (self.is_rescanning && self.filtered.is_empty()) {
676 // "No matches" would be a lie while the walk is still running.
677 lines.push(Line::from(Span::styled(
678 format!(" {}", tr(self.locale, MessageId::FilePickerScanning)),
679 Style::default().fg(palette::TEXT_MUTED),
680 )));
681 } else if self.filtered.is_empty() {
682 lines.push(Line::from(Span::styled(
683 " No matches",
684 Style::default().fg(palette::TEXT_MUTED),
685 )));
686 } else {
687 for idx in self.scroll..end {
688 let path = &self.candidates[self.filtered[idx]];
689 let selected = idx == self.selected;
690 let style = if selected {
691 menu_style::selected_row_bg_style().fg(palette::SELECTION_TEXT)
692 } else {
693 Style::default().fg(palette::TEXT_PRIMARY)
694 };
695 let prefix = format!("{} ", crate::tui::glyphs::selection_marker(selected));
696 let marker_field = if content.width >= 18 {
697 format!("{} ", self.relevance.markers_for(path))
698 } else {
699 String::new()
700 };
701 let reserved = prefix.chars().count() + marker_field.chars().count();
702 let display =
703 truncate_path(path, (content.width as usize).saturating_sub(reserved));
704 let mut line = Line::from(format!("{prefix}{marker_field}{display}"));
705 line.style = style;
706 let y = content
707 .y
708 .saturating_add(u16::try_from(lines.len()).unwrap_or(u16::MAX));
709 self.last_row_hitboxes.borrow_mut().push((y, idx));
710 lines.push(line);
711 }
712 }
713
714 Paragraph::new(lines)
715 .style(Style::default().fg(palette::TEXT_PRIMARY))
716 .render(content, buf);
717 }
718 }
719
720 fn truncate_path(path: &str, max: usize) -> String {
721 if max == 0 {
722 return String::new();
723 }
724 if path.chars().count() <= max {
725 return path.to_string();
726 }
727 let take = max.saturating_sub(1);
728 let truncated: String = path
729 .chars()
730 .rev()
731 .take(take)
732 .collect::<Vec<_>>()
733 .into_iter()
734 .rev()
735 .collect();
736 format!("…{truncated}")
737 }
738
739 /// Single-pass walk that collects workspace-relative paths. `max_depth` of
740 /// `None` walks the whole tree (still bounded by `MAX_CANDIDATES` and
741 /// `.gitignore`); `Some(n)` caps the recursion at `n` levels.
742 #[cfg(test)]
743 fn collect_candidates(root: &Path, max_depth: Option<usize>) -> Vec<String> {
744 collect_candidates_limited(root, max_depth, MAX_CANDIDATES).paths
745 }
746
747 fn collect_candidates_limited(
748 root: &Path,
749 max_depth: Option<usize>,
750 limit: usize,
751 ) -> CandidateWalk {
752 let mut out: Vec<String> = Vec::new();
753 let mut truncated = push_matching_files(
754 MatchingFileWalk {
755 walk_root: root,
756 display_root: root,
757 max_depth,
758 honor_gitignore: true,
759 limit,
760 matches: &|_| true,
761 },
762 &mut out,
763 None,
764 );
765 if !truncated {
766 // Whitelist AI-tool dot-directories so they're discoverable even when
767 // gitignored. Walk each one separately with gitignore disabled.
768 for dir in DISCOVERY_ALWAYS_DIRS {
769 let dot_dir = root.join(dir);
770 if !dot_dir.is_dir() {
771 continue;
772 }
773 truncated = push_matching_files(
774 MatchingFileWalk {
775 walk_root: &dot_dir,
776 display_root: root,
777 max_depth: max_depth.map(|d| d.saturating_sub(1)),
778 honor_gitignore: false,
779 limit,
780 matches: &|_| true,
781 },
782 &mut out,
783 None,
784 );
785 if truncated {
786 break;
787 }
788 }
789 }
790 out.sort();
791 CandidateWalk {
792 paths: out,
793 truncated,
794 }
795 }
796
797 /// Walk matching files for a query that missed the truncated index.
798 ///
799 /// Starts at the longest existing directory prefix of `query` so a typed path
800 /// like `packages/app/lib/room_chat_shell` does not re-walk the first 20k
801 /// files. The walk continues past [`MAX_CANDIDATES`]; only `limit` hits are
802 /// kept.
803 fn collect_query_matches(
804 root: &Path,
805 max_depth: Option<usize>,
806 query: &str,
807 limit: usize,
808 ) -> Vec<String> {
809 let query = query.trim();
810 if query.is_empty() || limit == 0 {
811 return Vec::new();
812 }
813 let needle = query.to_lowercase();
814 let matches = |path: &str| score(&needle, path).is_some();
815 let (start, depth) = targeted_walk_root(root, query, max_depth);
816 let mut out = Vec::new();
817 let mut seen = HashSet::new();
818 let under_always = always_dir_prefix(root, &start).is_some();
819 let hit_cap = push_matching_files(
820 MatchingFileWalk {
821 walk_root: &start,
822 display_root: root,
823 max_depth: depth,
824 honor_gitignore: !under_always,
825 limit,
826 matches: &matches,
827 },
828 &mut out,
829 Some(&mut seen),
830 );
831 if start.as_path() == root && !hit_cap {
832 for dir in DISCOVERY_ALWAYS_DIRS {
833 let dot_dir = root.join(dir);
834 if !dot_dir.is_dir() {
835 continue;
836 }
837 if push_matching_files(
838 MatchingFileWalk {
839 walk_root: &dot_dir,
840 display_root: root,
841 max_depth: max_depth.map(|d| d.saturating_sub(1)),
842 honor_gitignore: false,
843 limit,
844 matches: &matches,
845 },
846 &mut out,
847 Some(&mut seen),
848 ) {
849 break;
850 }
851 }
852 }
853 out.sort();
854 out
855 }
856
857 /// Longest existing directory prefix of `query` under `root`. Depth is
858 /// reduced by the number of consumed components so a targeted walk cannot
859 /// see farther than the original `mention_walk_depth` cap.
860 fn targeted_walk_root(
861 root: &Path,
862 query: &str,
863 max_depth: Option<usize>,
864 ) -> (PathBuf, Option<usize>) {
865 let normalized = query.replace('\\', "/");
866 let mut dir = root.to_path_buf();
867 let mut consumed = 0usize;
868 for component in normalized.split('/') {
869 if component.is_empty() || component == "." {
870 continue;
871 }
872 if component == ".." {
873 break;
874 }
875 let next = dir.join(component);
876 if next.is_dir() {
877 dir = next;
878 consumed += 1;
879 } else {
880 break;
881 }
882 }
883 (dir, max_depth.map(|depth| depth.saturating_sub(consumed)))
884 }
885
886 fn always_dir_prefix(root: &Path, path: &Path) -> Option<&'static str> {
887 DISCOVERY_ALWAYS_DIRS.iter().copied().find(|dir| {
888 let always = root.join(dir);
889 path == always || path.starts_with(&always)
890 })
891 }
892
893 struct MatchingFileWalk<'a> {
894 walk_root: &'a Path,
895 display_root: &'a Path,
896 max_depth: Option<usize>,
897 honor_gitignore: bool,
898 limit: usize,
899 matches: &'a dyn Fn(&str) -> bool,
900 }
901
902 fn push_matching_files(
903 walk: MatchingFileWalk<'_>,
904 out: &mut Vec<String>,
905 mut seen: Option<&mut HashSet<String>>,
906 ) -> bool {
907 let MatchingFileWalk {
908 walk_root,
909 display_root,
910 max_depth,
911 honor_gitignore,
912 limit,
913 matches,
914 } = walk;
915 if limit == 0 || out.len() >= limit {
916 return true;
917 }
918 let mut builder = WalkBuilder::new(walk_root);
919 builder
920 .hidden(true)
921 .follow_links(false)
922 .max_depth(max_depth);
923 if honor_gitignore {
924 builder.git_ignore(true).git_exclude(true).git_global(true);
925 } else {
926 builder.git_ignore(false).ignore(false);
927 }
928
929 for entry in builder.build().flatten() {
930 if !honor_gitignore && path_is_excluded_from_discovery(display_root, entry.path()) {
931 continue;
932 }
933 if !entry.file_type().is_some_and(|ft| ft.is_file()) {
934 continue;
935 }
936 let path = entry.path();
937 let rel = path.strip_prefix(display_root).unwrap_or(path);
938 if rel.as_os_str().is_empty() {
939 continue;
940 }
941 let display = path_to_workspace_string(rel);
942 if display.is_empty() || !matches(&display) {
943 continue;
944 }
945 if let Some(seen) = seen.as_mut()
946 && !seen.insert(display.clone())
947 {
948 continue;
949 }
950 out.push(display);
951 if out.len() >= limit {
952 return true;
953 }
954 }
955 false
956 }
957
958 fn path_to_workspace_string(path: &Path) -> String {
959 // Use forward-slash separators for cross-platform display, matching how
960 // @-mentions are spelled in the composer.
961 let mut out = String::new();
962 for (idx, comp) in path.components().enumerate() {
963 if idx > 0 {
964 out.push('/');
965 }
966 out.push_str(&comp.as_os_str().to_string_lossy());
967 }
968 out
969 }
970
971 /// Subsequence scorer with first-letter and boundary bonuses.
972 ///
973 /// Returns `None` if `query` is not a subsequence of `path` (case-insensitive),
974 /// otherwise a positive score where higher is better.
975 ///
976 /// Heuristics (kept deliberately small and predictable):
977 /// * +25 for each match that lands at the start of the path or right after a
978 /// boundary character (`/`, `_`, `-`, `.`, ` `).
979 /// * +10 if the very first character of the query matches the first character
980 /// of the path.
981 /// * +5 per consecutive match (rewards contiguous runs like typing "main" and
982 /// matching `main.rs`).
983 /// * Penalty proportional to the gap between consecutive matches keeps tightly
984 /// matched candidates above scattered ones.
985 pub fn score(query: &str, path: &str) -> Option<i32> {
986 if query.is_empty() {
987 return Some(0);
988 }
989 let q: Vec<char> = query.chars().flat_map(char::to_lowercase).collect();
990 let p: Vec<char> = path.chars().flat_map(char::to_lowercase).collect();
991 if q.len() > p.len() {
992 return None;
993 }
994
995 let mut qi = 0usize;
996 let mut score: i32 = 0;
997 let mut last_match: Option<usize> = None;
998 let mut consecutive = 0i32;
999
1000 for (i, ch) in p.iter().enumerate() {
1001 if qi >= q.len() {
1002 break;
1003 }
1004 if *ch == q[qi] {
1005 // Boundary / start bonus.
1006 if i == 0 {
1007 score += 25;
1008 if qi == 0 {
1009 score += 10;
1010 }
1011 } else if matches!(p[i - 1], '/' | '_' | '-' | '.' | ' ') {
1012 score += 25;
1013 } else {
1014 score += 1;
1015 }
1016
1017 // Consecutive bonus.
1018 if last_match == Some(i.saturating_sub(1)) {
1019 consecutive += 1;
1020 score += 5 * consecutive;
1021 } else {
1022 consecutive = 0;
1023 }
1024
1025 // Gap penalty.
1026 if let Some(prev) = last_match {
1027 let gap = i - prev - 1;
1028 score -= gap as i32;
1029 }
1030
1031 last_match = Some(i);
1032 qi += 1;
1033 }
1034 }
1035
1036 if qi == q.len() { Some(score) } else { None }
1037 }
1038
1039 #[cfg(test)]
1040 mod tests {
1041 use super::*;
1042 use std::fs;
1043 use std::time::Duration;
1044 use tempfile::TempDir;
1045
1046 #[test]
1047 fn score_subsequence_match() {
1048 // Identical query matches start with high bonus.
1049 let a = score("main", "main.rs").unwrap();
1050 let b = score("main", "src/very/deep/main.rs").unwrap();
1051 assert!(a > b, "a={a} b={b}");
1052 }
1053
1054 #[test]
1055 fn score_rejects_non_subsequence() {
1056 assert!(score("zzz", "main.rs").is_none());
1057 assert!(score("xyz", "src/lib.rs").is_none());
1058 }
1059
1060 #[test]
1061 fn query_line_carries_explicit_ink_on_the_dark_surface() {
1062 // The picker paints WHALE_BG, so the typed query must carry its own
1063 // fg: light-profile terminals default to black ink.
1064 let dir = TempDir::new().expect("tempdir");
1065 let mut picker =
1066 FilePickerView::new_with_relevance(dir.path(), FilePickerRelevance::default());
1067 picker.query = "main".to_string();
1068 let area = Rect::new(0, 0, 80, 20);
1069 let mut buf = Buffer::empty(area);
1070 picker.render(area, &mut buf);
1071 let mut checked = 0;
1072 for y in 0..area.height {
1073 let mut row = String::new();
1074 for x in 0..area.width {
1075 row.push_str(buf[(x, y)].symbol());
1076 }
1077 if !row.contains("> main") {
1078 continue;
1079 }
1080 for x in 0..area.width {
1081 let cell = &buf[(x, y)];
1082 let symbol = cell.symbol();
1083 if symbol.trim().is_empty() || symbol == ">" {
1084 continue;
1085 }
1086 assert_eq!(
1087 cell.style().fg,
1088 Some(palette::TEXT_PRIMARY),
1089 "query cell ({x}, {y}) must carry explicit body ink",
1090 );
1091 checked += 1;
1092 }
1093 }
1094 assert!(checked > 0, "expected a rendered query line");
1095 }
1096
1097 #[test]
1098 fn score_boundary_bonus_beats_substring() {
1099 // "fp" matches the boundary letters in "file_picker.rs" but only the
1100 // first letter in "filepicker.rs" — so the boundary candidate should
1101 // win.
1102 let boundary = score("fp", "src/file_picker.rs").unwrap();
1103 let inline = score("fp", "src/filepicker.rs");
1104 // inline doesn't even contain 'p' immediately following 'f'? It does:
1105 // f-i-l-e-p-i-c-k-e-r — 'p' is preceded by 'e' (no boundary), so it
1106 // gets only the +1 path score, while boundary gets +25 for the 'p'
1107 // following the underscore.
1108 if let Some(inline_score) = inline {
1109 assert!(
1110 boundary > inline_score,
1111 "boundary={boundary} inline={inline_score}"
1112 );
1113 }
1114 }
1115
1116 #[test]
1117 fn score_case_insensitive() {
1118 assert!(score("MAIN", "main.rs").is_some());
1119 assert!(score("main", "MAIN.RS").is_some());
1120 }
1121
1122 #[test]
1123 fn score_empty_query_returns_zero() {
1124 assert_eq!(score("", "anything").unwrap(), 0);
1125 }
1126
1127 #[test]
1128 fn picker_typing_narrows_candidates() {
1129 let dir = TempDir::new().expect("tempdir");
1130 let root = dir.path();
1131 fs::create_dir_all(root.join("src")).unwrap();
1132 fs::write(root.join("src/main.rs"), "").unwrap();
1133 fs::write(root.join("src/lib.rs"), "").unwrap();
1134 fs::write(root.join("README.md"), "").unwrap();
1135 fs::write(root.join("Cargo.toml"), "").unwrap();
1136
1137 let mut view = FilePickerView::new_with_relevance(root, FilePickerRelevance::default());
1138 // Empty query -> all 4 files visible.
1139 assert_eq!(view.visible_count(), 4, "expected all 4 candidates");
1140
1141 // Typing "main" should narrow to just src/main.rs.
1142 for ch in "main".chars() {
1143 view.handle_key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
1144 }
1145 assert_eq!(view.query(), "main");
1146 let visible = view.visible_count();
1147 assert_eq!(visible, 1, "expected exactly 1 match for 'main'");
1148 let selected = view.selected_for_test().expect("selected path");
1149 assert!(selected.ends_with("main.rs"), "selected = {selected}");
1150 }
1151
1152 #[test]
1153 fn picker_empty_query_prioritizes_working_set_files() {
1154 let dir = TempDir::new().expect("tempdir");
1155 let root = dir.path();
1156 fs::create_dir_all(root.join("src")).unwrap();
1157 fs::write(root.join("src/main.rs"), "").unwrap();
1158 fs::write(root.join("src/lib.rs"), "").unwrap();
1159 fs::write(root.join("README.md"), "").unwrap();
1160
1161 let mut relevance = FilePickerRelevance::default();
1162 relevance.mark_modified("src/lib.rs");
1163 let view = FilePickerView::new_with_relevance(root, relevance);
1164
1165 assert_eq!(view.selected_for_test(), Some("src/lib.rs"));
1166 assert_eq!(view.markers_for_test("src/lib.rs"), "M ");
1167 }
1168
1169 #[test]
1170 fn picker_fuzzy_query_keeps_working_set_boosts() {
1171 let dir = TempDir::new().expect("tempdir");
1172 let root = dir.path();
1173 fs::create_dir_all(root.join("src")).unwrap();
1174 fs::write(root.join("src/alpha.rs"), "").unwrap();
1175 fs::write(root.join("src/zeta.rs"), "").unwrap();
1176
1177 let mut relevance = FilePickerRelevance::default();
1178 relevance.mark_mentioned("src/zeta.rs");
1179 relevance.mark_tool("src/zeta.rs");
1180 let mut view = FilePickerView::new_with_relevance(root, relevance);
1181 for ch in "rs".chars() {
1182 view.handle_key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
1183 }
1184
1185 assert_eq!(view.selected_for_test(), Some("src/zeta.rs"));
1186 assert_eq!(view.markers_for_test("src/zeta.rs"), " @T");
1187 }
1188
1189 #[test]
1190 fn picker_backspace_widens_candidates() {
1191 let dir = TempDir::new().expect("tempdir");
1192 let root = dir.path();
1193 fs::write(root.join("a.txt"), "").unwrap();
1194 fs::write(root.join("b.txt"), "").unwrap();
1195
1196 let mut view = FilePickerView::new_with_relevance(root, FilePickerRelevance::default());
1197 view.handle_key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE));
1198 assert_eq!(view.visible_count(), 1);
1199 view.handle_key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
1200 assert_eq!(view.visible_count(), 2);
1201 }
1202
1203 #[test]
1204 fn picker_enter_emits_event() {
1205 let dir = TempDir::new().expect("tempdir");
1206 let root = dir.path();
1207 fs::write(root.join("only.txt"), "").unwrap();
1208
1209 let mut view = FilePickerView::new_with_relevance(root, FilePickerRelevance::default());
1210 let action = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
1211 match action {
1212 ViewAction::EmitAndClose(ViewEvent::FilePickerSelected { path }) => {
1213 assert!(path.ends_with("only.txt"));
1214 }
1215 other => panic!("expected EmitAndClose(FilePickerSelected), got {other:?}"),
1216 }
1217 }
1218
1219 #[test]
1220 fn picker_esc_closes_without_emit() {
1221 let dir = TempDir::new().expect("tempdir");
1222 let root = dir.path();
1223 fs::write(root.join("only.txt"), "").unwrap();
1224
1225 let mut view = FilePickerView::new_with_relevance(root, FilePickerRelevance::default());
1226 let action = view.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
1227 assert!(matches!(action, ViewAction::Close));
1228 }
1229
1230 #[test]
1231 fn picker_honors_gitignore() {
1232 let dir = TempDir::new().expect("tempdir");
1233 let root = dir.path();
1234 // .gitignore filtering only kicks in inside a git repo or with an
1235 // explicit `.ignore` file. Use `.ignore` which `WalkBuilder` honors
1236 // even outside of git.
1237 fs::write(root.join(".ignore"), "skipme.txt\n").unwrap();
1238 fs::write(root.join("keepme.txt"), "").unwrap();
1239 fs::write(root.join("skipme.txt"), "").unwrap();
1240
1241 let view = FilePickerView::new_with_relevance(root, FilePickerRelevance::default());
1242 let visible: Vec<_> = view
1243 .filtered
1244 .iter()
1245 .map(|i| view.candidates[*i].as_str())
1246 .collect();
1247 assert!(visible.iter().any(|p| p.ends_with("keepme.txt")));
1248 assert!(
1249 !visible.iter().any(|p| p.ends_with("skipme.txt")),
1250 "skipme.txt should be filtered by .ignore: {visible:?}"
1251 );
1252 }
1253
1254 #[test]
1255 fn picker_finds_deeply_nested_files_within_walk_depth() {
1256 // #2488: a file inside a 6-level-deep directory sits at component depth
1257 // 7 and was excluded by the old depth-6 cap. The default depth (10) now
1258 // reaches it, and `0` (unlimited) reaches arbitrarily deep files.
1259 let dir = TempDir::new().expect("tempdir");
1260 let root = dir.path();
1261 let nested = root.join("a/b/c/d/e/f");
1262 fs::create_dir_all(&nested).unwrap();
1263 fs::write(nested.join("deep.rs"), "deep").unwrap();
1264 let deeper = root.join("a/b/c/d/e/f/g/h/i/j/k");
1265 fs::create_dir_all(&deeper).unwrap();
1266 fs::write(deeper.join("very_deep.rs"), "deeper").unwrap();
1267
1268 // The old default (6) misses the depth-7 file — the reported bug.
1269 let shallow = collect_candidates(root, Some(6));
1270 assert!(
1271 !shallow.iter().any(|p| p == "a/b/c/d/e/f/deep.rs"),
1272 "depth-6 cap should miss the depth-7 file: {shallow:?}"
1273 );
1274
1275 // The new default reaches files inside a 6-level-deep directory.
1276 let default = collect_candidates(root, Some(WALK_DEPTH));
1277 assert!(
1278 default.iter().any(|p| p == "a/b/c/d/e/f/deep.rs"),
1279 "default walk depth should reach depth-7 files: {default:?}"
1280 );
1281
1282 // Unlimited (mention_walk_depth = 0) reaches arbitrarily deep files.
1283 let unlimited = collect_candidates(root, None);
1284 assert!(
1285 unlimited
1286 .iter()
1287 .any(|p| p == "a/b/c/d/e/f/g/h/i/j/k/very_deep.rs"),
1288 "unlimited walk should reach very deep files: {unlimited:?}"
1289 );
1290 }
1291
1292 #[test]
1293 fn picker_skips_generated_worktree_bulk_inside_unignored_dot_dirs() {
1294 let dir = TempDir::new().expect("tempdir");
1295 let root = dir.path();
1296 fs::create_dir_all(root.join("src")).unwrap();
1297 fs::write(root.join("src/main.rs"), "fn main() {}").unwrap();
1298
1299 fs::create_dir_all(root.join(".deepseek/commands")).unwrap();
1300 fs::write(root.join(".deepseek/commands/build.md"), "build").unwrap();
1301 fs::create_dir_all(root.join(".deepseek/snapshots/deadbeef/.git/objects")).unwrap();
1302 fs::write(
1303 root.join(".deepseek/snapshots/deadbeef/.git/objects/snapshot.pack"),
1304 "pack",
1305 )
1306 .unwrap();
1307
1308 fs::create_dir_all(root.join(".claude/commands")).unwrap();
1309 fs::write(root.join(".claude/commands/test.md"), "test").unwrap();
1310 fs::create_dir_all(root.join(".claude/worktrees/agent/src")).unwrap();
1311 fs::write(
1312 root.join(".claude/worktrees/agent/src/agent-only.md"),
1313 "agent",
1314 )
1315 .unwrap();
1316
1317 let candidates = collect_candidates(root, Some(WALK_DEPTH));
1318
1319 assert!(candidates.iter().any(|path| path == "src/main.rs"));
1320 assert!(
1321 candidates
1322 .iter()
1323 .any(|path| path == ".deepseek/commands/build.md"),
1324 "normal .deepseek command files should stay discoverable: {candidates:?}",
1325 );
1326 assert!(
1327 candidates
1328 .iter()
1329 .any(|path| path == ".claude/commands/test.md"),
1330 "normal .claude command files should stay discoverable: {candidates:?}",
1331 );
1332 assert!(
1333 candidates
1334 .iter()
1335 .all(|path| !path.starts_with(".deepseek/snapshots/")),
1336 "snapshot side repo files must not enter picker candidates: {candidates:?}",
1337 );
1338 assert!(
1339 candidates
1340 .iter()
1341 .all(|path| !path.starts_with(".claude/worktrees/")),
1342 ".claude worktree files must not enter picker candidates: {candidates:?}",
1343 );
1344 }
1345
1346 #[test]
1347 fn collect_candidates_limited_stops_at_the_cap_and_flags_truncation() {
1348 let dir = TempDir::new().expect("tempdir");
1349 let root = dir.path();
1350 fs::create_dir_all(root.join("pad")).unwrap();
1351 for i in 0..30 {
1352 fs::write(root.join("pad").join(format!("n{i:02}.txt")), "").unwrap();
1353 }
1354
1355 let walk = collect_candidates_limited(root, Some(WALK_DEPTH), 12);
1356 assert!(
1357 walk.truncated,
1358 "hitting the cap must mark the index incomplete"
1359 );
1360 assert_eq!(walk.paths.len(), 12);
1361 assert!(
1362 !collect_candidates_limited(root, Some(WALK_DEPTH), 64).truncated,
1363 "a cap above the file count is a complete index"
1364 );
1365 }
1366
1367 #[test]
1368 fn targeted_rescan_finds_a_file_the_candidate_cap_dropped() {
1369 // #2488: the opening walk keeps the first N files in walk order. A
1370 // later unique file must still be reachable once the user types it.
1371 let dir = TempDir::new().expect("tempdir");
1372 let root = dir.path();
1373 fs::create_dir_all(root.join("pad")).unwrap();
1374 for i in 0..40 {
1375 fs::write(root.join("pad").join(format!("n{i:02}.txt")), "").unwrap();
1376 }
1377 fs::create_dir_all(root.join("zzz")).unwrap();
1378 fs::write(root.join("zzz/room_chat_shell.dart"), "late").unwrap();
1379
1380 let walk = collect_candidates_limited(root, Some(WALK_DEPTH), 15);
1381 assert!(walk.truncated);
1382 let hits = collect_query_matches(root, Some(WALK_DEPTH), "room_chat_shell", 64);
1383 assert!(
1384 hits.iter().any(|path| path == "zzz/room_chat_shell.dart"),
1385 "targeted rescan must recover the file past the cap: {hits:?}"
1386 );
1387 }
1388
1389 #[test]
1390 fn targeted_walk_root_descends_into_an_existing_prefix() {
1391 let dir = TempDir::new().expect("tempdir");
1392 let root = dir.path();
1393 fs::create_dir_all(root.join("src/nested")).unwrap();
1394 fs::write(root.join("src/nested/hit.rs"), "").unwrap();
1395 let (start, depth) = targeted_walk_root(root, "src/nested/hit", Some(10));
1396 assert_eq!(start, root.join("src/nested"));
1397 assert_eq!(depth, Some(8));
1398 }
1399
1400 #[test]
1401 fn picker_query_miss_rescans_a_truncated_index() {
1402 let dir = TempDir::new().expect("tempdir");
1403 let root = dir.path();
1404 fs::create_dir_all(root.join("zzz")).unwrap();
1405 fs::write(root.join("zzz/room_chat_shell.dart"), "late").unwrap();
1406
1407 let mut view = FilePickerView::from_preloaded(
1408 root,
1409 vec!["pad/n00.txt".into(), "pad/n01.txt".into()],
1410 true,
1411 Some(WALK_DEPTH),
1412 );
1413 assert_eq!(
1414 view.visible_count(),
1415 2,
1416 "empty query shows the truncated index"
1417 );
1418
1419 for ch in "room_chat_shell".chars() {
1420 view.handle_key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
1421 }
1422 assert_eq!(
1423 view.selected_for_test(),
1424 Some("zzz/room_chat_shell.dart"),
1425 "a miss against the truncated index must rescan and surface the file"
1426 );
1427 }
1428
1429 #[test]
1430 fn picker_complete_index_miss_does_not_rescan() {
1431 let dir = TempDir::new().expect("tempdir");
1432 let root = dir.path();
1433 fs::write(root.join("keep.txt"), "").unwrap();
1434 // A file on disk that is not in the (complete) index must stay
1435 // invisible — a complete walk already saw the whole tree.
1436 fs::write(root.join("secret.txt"), "").unwrap();
1437
1438 let mut view =
1439 FilePickerView::from_preloaded(root, vec!["keep.txt".into()], false, Some(WALK_DEPTH));
1440 for ch in "secret".chars() {
1441 view.handle_key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
1442 }
1443 assert_eq!(view.visible_count(), 0);
1444 assert_eq!(view.candidates, vec!["keep.txt".to_string()]);
1445 }
1446
1447 /// The four terminal sizes the v0.8.66 modal blocker (#3732) requires
1448 /// every overlay to remain readable and fully operable at.
1449 const BLOCKER_SIZES: [(u16, u16); 4] = [(80, 24), (100, 30), (120, 32), (160, 40)];
1450
1451 #[test]
1452 fn file_picker_is_usable_and_opaque_at_blocker_sizes() {
1453 use crate::tui::views::ViewStack;
1454 use ratatui::{buffer::Buffer, layout::Rect};
1455 use unicode_width::UnicodeWidthStr;
1456
1457 let dir = TempDir::new().expect("tempdir");
1458 let root = dir.path();
1459 fs::create_dir_all(root.join("src")).unwrap();
1460 fs::write(root.join("src/main.rs"), "").unwrap();
1461 fs::write(root.join("src/lib.rs"), "").unwrap();
1462 fs::write(root.join("README.md"), "").unwrap();
1463
1464 for (w, h) in BLOCKER_SIZES {
1465 let area = Rect::new(0, 0, w, h);
1466 let mut buf = Buffer::empty(area);
1467 for y in 0..h {
1468 for x in 0..w {
1469 buf[(x, y)].set_symbol("X");
1470 }
1471 }
1472 let mut stack = ViewStack::new();
1473 stack.push(FilePickerView::new_with_relevance(
1474 root,
1475 FilePickerRelevance::default(),
1476 ));
1477 stack.render(area, &mut buf);
1478
1479 let rows: Vec<String> = (0..h)
1480 .map(|y| {
1481 (0..w)
1482 .map(|x| buf[(x, y)].symbol().to_string())
1483 .collect::<String>()
1484 })
1485 .collect();
1486 let text = rows.join("\n");
1487
1488 for label in ["move", "insert @path", "cancel"] {
1489 assert!(text.contains(label), "{w}x{h}: missing footer '{label}'");
1490 }
1491 assert!(
1492 !text.contains('X'),
1493 "{w}x{h}: background bleed-through into modal surface"
1494 );
1495 assert_eq!(
1496 buf[(w / 2, h / 2)].bg,
1497 palette::WHALE_BG,
1498 "{w}x{h}: modal interior must be opaque"
1499 );
1500 for (y, row) in rows.iter().enumerate() {
1501 assert!(
1502 UnicodeWidthStr::width(row.trim_end()) <= w as usize,
1503 "{w}x{h}: row {y} overflows width: {row:?}"
1504 );
1505 }
1506 }
1507 }
1508
1509 /// #3905: opening the picker used to block the event loop on a `git status`
1510 /// subprocess plus a walk of up to MAX_CANDIDATES paths, freezing the whole
1511 /// TUI between Ctrl+P and the picker appearing.
1512 ///
1513 /// Asserting "fast" by wall clock would be a flaky proxy for the real
1514 /// contract, so this asserts the structural property instead: inside a
1515 /// runtime the constructor returns a paintable view that has not yet done
1516 /// the scan, and the results arrive later through `tick`.
1517 #[tokio::test]
1518 async fn opening_the_picker_does_not_block_on_the_workspace_scan() {
1519 let ws = TempDir::new().unwrap();
1520 fs::create_dir_all(ws.path().join("src")).unwrap();
1521 for i in 0..200 {
1522 fs::write(ws.path().join("src").join(format!("f{i}.rs")), "x").unwrap();
1523 }
1524
1525 let mut view = FilePickerView::new_with_relevance_and_depth(
1526 ws.path(),
1527 FilePickerRelevance::default(),
1528 WALK_DEPTH,
1529 Locale::En,
1530 );
1531
1532 assert!(
1533 view.is_loading,
1534 "the constructor must hand back a paintable view, not a finished scan"
1535 );
1536 assert!(
1537 view.candidates.is_empty(),
1538 "no walk may have run on the calling thread"
1539 );
1540
1541 // The view is renderable in the loading state — this is the frame the
1542 // user sees immediately after Ctrl+P.
1543 let area = Rect::new(0, 0, 60, 20);
1544 let mut buf = Buffer::empty(area);
1545 view.render(area, &mut buf);
1546
1547 for _ in 0..500 {
1548 view.tick();
1549 if !view.is_loading {
1550 break;
1551 }
1552 tokio::time::sleep(Duration::from_millis(5)).await;
1553 }
1554
1555 assert!(!view.is_loading, "the background scan must land via tick");
1556 assert_eq!(
1557 view.candidates.len(),
1558 200,
1559 "every workspace file is discovered once the scan lands"
1560 );
1561 assert_eq!(
1562 view.filtered.len(),
1563 200,
1564 "results are refiltered after the scan, not left empty"
1565 );
1566 }
1567
1568 /// A query typed while the scan was still running must survive it.
1569 #[tokio::test]
1570 async fn a_query_typed_during_the_scan_is_applied_when_results_land() {
1571 let ws = TempDir::new().unwrap();
1572 fs::write(ws.path().join("alpha.rs"), "x").unwrap();
1573 fs::write(ws.path().join("beta.rs"), "x").unwrap();
1574
1575 let mut view = FilePickerView::new_with_relevance_and_depth(
1576 ws.path(),
1577 FilePickerRelevance::default(),
1578 WALK_DEPTH,
1579 Locale::En,
1580 );
1581 assert!(view.is_loading);
1582
1583 for ch in "alpha".chars() {
1584 view.handle_key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
1585 }
1586
1587 for _ in 0..500 {
1588 view.tick();
1589 if !view.is_loading {
1590 break;
1591 }
1592 tokio::time::sleep(Duration::from_millis(5)).await;
1593 }
1594
1595 assert!(!view.is_loading);
1596 assert_eq!(view.query, "alpha");
1597 let matched: Vec<&str> = view
1598 .filtered
1599 .iter()
1600 .map(|i| view.candidates[*i].as_str())
1601 .collect();
1602 assert_eq!(
1603 matched,
1604 vec!["alpha.rs"],
1605 "the scan must refilter against the query the user already typed"
1606 );
1607 }
1608
1609 /// #2488: a miss-rescan on a truncated index must not run on the event
1610 /// loop. The constructor-style property from #3905 applies here too:
1611 /// `handle_key` returns a paintable view and the extra file arrives via
1612 /// `tick`.
1613 #[tokio::test]
1614 async fn truncated_index_rescan_does_not_block_handle_key() {
1615 let ws = TempDir::new().unwrap();
1616 fs::write(ws.path().join("late_unique_file.rs"), "x").unwrap();
1617
1618 let mut view = FilePickerView::from_preloaded(
1619 ws.path(),
1620 vec!["unrelated.rs".into()],
1621 true,
1622 Some(WALK_DEPTH),
1623 );
1624 for ch in "late_unique_file".chars() {
1625 view.handle_key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
1626 }
1627 assert!(
1628 view.is_rescanning
1629 || view
1630 .candidates
1631 .iter()
1632 .any(|path| path == "late_unique_file.rs"),
1633 "rescan must start off-thread (or already have merged on a tiny race)"
1634 );
1635
1636 for _ in 0..500 {
1637 view.tick();
1638 if view
1639 .candidates
1640 .iter()
1641 .any(|path| path == "late_unique_file.rs")
1642 {
1643 break;
1644 }
1645 tokio::time::sleep(Duration::from_millis(5)).await;
1646 }
1647 assert_eq!(view.selected_for_test(), Some("late_unique_file.rs"));
1648 }
1649 }
1650
1650 lines RUST