返回 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 /// Keep the path's tail within `max` display cells, cutting only between
721 /// grapheme clusters: counting chars overflowed CJK rows and could split a
722 /// ZWJ or combining sequence (U07-06). ASCII output is unchanged.
723 fn truncate_path(path: &str, max: usize) -> String {
724 use unicode_segmentation::UnicodeSegmentation;
725 if max == 0 {
726 return String::new();
727 }
728 if crate::tui::ui_text::text_display_width(path) <= max {
729 return path.to_string();
730 }
731 let budget = max.saturating_sub(1);
732 let mut used = 0usize;
733 let mut start = path.len();
734 for (idx, grapheme) in path.grapheme_indices(true).rev() {
735 let width = crate::tui::ui_text::text_display_width(grapheme);
736 if used + width > budget {
737 break;
738 }
739 used += width;
740 start = idx;
741 }
742 format!("…{}", &path[start..])
743 }
744
745 /// Single-pass walk that collects workspace-relative paths. `max_depth` of
746 /// `None` walks the whole tree (still bounded by `MAX_CANDIDATES` and
747 /// `.gitignore`); `Some(n)` caps the recursion at `n` levels.
748 #[cfg(test)]
749 fn collect_candidates(root: &Path, max_depth: Option<usize>) -> Vec<String> {
750 collect_candidates_limited(root, max_depth, MAX_CANDIDATES).paths
751 }
752
753 fn collect_candidates_limited(
754 root: &Path,
755 max_depth: Option<usize>,
756 limit: usize,
757 ) -> CandidateWalk {
758 let mut out: Vec<String> = Vec::new();
759 let mut truncated = push_matching_files(
760 MatchingFileWalk {
761 walk_root: root,
762 display_root: root,
763 max_depth,
764 honor_gitignore: true,
765 limit,
766 matches: &|_| true,
767 },
768 &mut out,
769 None,
770 );
771 if !truncated {
772 // Whitelist AI-tool dot-directories so they're discoverable even when
773 // gitignored. Walk each one separately with gitignore disabled.
774 for dir in DISCOVERY_ALWAYS_DIRS {
775 let dot_dir = root.join(dir);
776 if !dot_dir.is_dir() {
777 continue;
778 }
779 truncated = push_matching_files(
780 MatchingFileWalk {
781 walk_root: &dot_dir,
782 display_root: root,
783 max_depth: max_depth.map(|d| d.saturating_sub(1)),
784 honor_gitignore: false,
785 limit,
786 matches: &|_| true,
787 },
788 &mut out,
789 None,
790 );
791 if truncated {
792 break;
793 }
794 }
795 }
796 out.sort();
797 CandidateWalk {
798 paths: out,
799 truncated,
800 }
801 }
802
803 /// Walk matching files for a query that missed the truncated index.
804 ///
805 /// Starts at the longest existing directory prefix of `query` so a typed path
806 /// like `packages/app/lib/room_chat_shell` does not re-walk the first 20k
807 /// files. The walk continues past [`MAX_CANDIDATES`]; only `limit` hits are
808 /// kept.
809 fn collect_query_matches(
810 root: &Path,
811 max_depth: Option<usize>,
812 query: &str,
813 limit: usize,
814 ) -> Vec<String> {
815 let query = query.trim();
816 if query.is_empty() || limit == 0 {
817 return Vec::new();
818 }
819 let needle = query.to_lowercase();
820 let matches = |path: &str| score(&needle, path).is_some();
821 let (start, depth) = targeted_walk_root(root, query, max_depth);
822 let mut out = Vec::new();
823 let mut seen = HashSet::new();
824 let under_always = always_dir_prefix(root, &start).is_some();
825 let hit_cap = push_matching_files(
826 MatchingFileWalk {
827 walk_root: &start,
828 display_root: root,
829 max_depth: depth,
830 honor_gitignore: !under_always,
831 limit,
832 matches: &matches,
833 },
834 &mut out,
835 Some(&mut seen),
836 );
837 if start.as_path() == root && !hit_cap {
838 for dir in DISCOVERY_ALWAYS_DIRS {
839 let dot_dir = root.join(dir);
840 if !dot_dir.is_dir() {
841 continue;
842 }
843 if push_matching_files(
844 MatchingFileWalk {
845 walk_root: &dot_dir,
846 display_root: root,
847 max_depth: max_depth.map(|d| d.saturating_sub(1)),
848 honor_gitignore: false,
849 limit,
850 matches: &matches,
851 },
852 &mut out,
853 Some(&mut seen),
854 ) {
855 break;
856 }
857 }
858 }
859 out.sort();
860 out
861 }
862
863 /// Longest existing directory prefix of `query` under `root`. Depth is
864 /// reduced by the number of consumed components so a targeted walk cannot
865 /// see farther than the original `mention_walk_depth` cap.
866 fn targeted_walk_root(
867 root: &Path,
868 query: &str,
869 max_depth: Option<usize>,
870 ) -> (PathBuf, Option<usize>) {
871 let normalized = query.replace('\\', "/");
872 let mut dir = root.to_path_buf();
873 let mut consumed = 0usize;
874 for component in normalized.split('/') {
875 if component.is_empty() || component == "." {
876 continue;
877 }
878 if component == ".." {
879 break;
880 }
881 let next = dir.join(component);
882 if next.is_dir() {
883 dir = next;
884 consumed += 1;
885 } else {
886 break;
887 }
888 }
889 (dir, max_depth.map(|depth| depth.saturating_sub(consumed)))
890 }
891
892 fn always_dir_prefix(root: &Path, path: &Path) -> Option<&'static str> {
893 DISCOVERY_ALWAYS_DIRS.iter().copied().find(|dir| {
894 let always = root.join(dir);
895 path == always || path.starts_with(&always)
896 })
897 }
898
899 struct MatchingFileWalk<'a> {
900 walk_root: &'a Path,
901 display_root: &'a Path,
902 max_depth: Option<usize>,
903 honor_gitignore: bool,
904 limit: usize,
905 matches: &'a dyn Fn(&str) -> bool,
906 }
907
908 fn push_matching_files(
909 walk: MatchingFileWalk<'_>,
910 out: &mut Vec<String>,
911 mut seen: Option<&mut HashSet<String>>,
912 ) -> bool {
913 let MatchingFileWalk {
914 walk_root,
915 display_root,
916 max_depth,
917 honor_gitignore,
918 limit,
919 matches,
920 } = walk;
921 if limit == 0 || out.len() >= limit {
922 return true;
923 }
924 let mut builder = WalkBuilder::new(walk_root);
925 builder
926 .hidden(true)
927 .follow_links(false)
928 .max_depth(max_depth);
929 if honor_gitignore {
930 builder.git_ignore(true).git_exclude(true).git_global(true);
931 } else {
932 builder.git_ignore(false).ignore(false);
933 }
934
935 for entry in builder.build().flatten() {
936 if !honor_gitignore && path_is_excluded_from_discovery(display_root, entry.path()) {
937 continue;
938 }
939 if !entry.file_type().is_some_and(|ft| ft.is_file()) {
940 continue;
941 }
942 let path = entry.path();
943 let rel = path.strip_prefix(display_root).unwrap_or(path);
944 // A pick becomes @-mention text that must resolve back to this file.
945 // A non-UTF-8 name cannot round-trip through text, and its lossy form
946 // can collide with another file's name, so it is not offered (U07-06).
947 if rel.as_os_str().is_empty() || rel.to_str().is_none() {
948 continue;
949 }
950 let display = path_to_workspace_string(rel);
951 if display.is_empty() || !matches(&display) {
952 continue;
953 }
954 if let Some(seen) = seen.as_mut()
955 && !seen.insert(display.clone())
956 {
957 continue;
958 }
959 out.push(display);
960 if out.len() >= limit {
961 return true;
962 }
963 }
964 false
965 }
966
967 fn path_to_workspace_string(path: &Path) -> String {
968 // Use forward-slash separators for cross-platform display, matching how
969 // @-mentions are spelled in the composer.
970 let mut out = String::new();
971 for (idx, comp) in path.components().enumerate() {
972 if idx > 0 {
973 out.push('/');
974 }
975 out.push_str(&comp.as_os_str().to_string_lossy());
976 }
977 out
978 }
979
980 /// Subsequence scorer with first-letter and boundary bonuses.
981 ///
982 /// Returns `None` if `query` is not a subsequence of `path` (case-insensitive),
983 /// otherwise a positive score where higher is better.
984 ///
985 /// Heuristics (kept deliberately small and predictable):
986 /// * +25 for each match that lands at the start of the path or right after a
987 /// boundary character (`/`, `_`, `-`, `.`, ` `).
988 /// * +10 if the very first character of the query matches the first character
989 /// of the path.
990 /// * +5 per consecutive match (rewards contiguous runs like typing "main" and
991 /// matching `main.rs`).
992 /// * Penalty proportional to the gap between consecutive matches keeps tightly
993 /// matched candidates above scattered ones.
994 pub fn score(query: &str, path: &str) -> Option<i32> {
995 if query.is_empty() {
996 return Some(0);
997 }
998 let q: Vec<char> = query.chars().flat_map(char::to_lowercase).collect();
999 let p: Vec<char> = path.chars().flat_map(char::to_lowercase).collect();
1000 if q.len() > p.len() {
1001 return None;
1002 }
1003
1004 let mut qi = 0usize;
1005 let mut score: i32 = 0;
1006 let mut last_match: Option<usize> = None;
1007 let mut consecutive = 0i32;
1008
1009 for (i, ch) in p.iter().enumerate() {
1010 if qi >= q.len() {
1011 break;
1012 }
1013 if *ch == q[qi] {
1014 // Boundary / start bonus.
1015 if i == 0 {
1016 score += 25;
1017 if qi == 0 {
1018 score += 10;
1019 }
1020 } else if matches!(p[i - 1], '/' | '_' | '-' | '.' | ' ') {
1021 score += 25;
1022 } else {
1023 score += 1;
1024 }
1025
1026 // Consecutive bonus.
1027 if last_match == Some(i.saturating_sub(1)) {
1028 consecutive += 1;
1029 score += 5 * consecutive;
1030 } else {
1031 consecutive = 0;
1032 }
1033
1034 // Gap penalty.
1035 if let Some(prev) = last_match {
1036 let gap = i - prev - 1;
1037 score -= gap as i32;
1038 }
1039
1040 last_match = Some(i);
1041 qi += 1;
1042 }
1043 }
1044
1045 if qi == q.len() { Some(score) } else { None }
1046 }
1047
1048 #[cfg(test)]
1049 mod tests {
1050 use super::*;
1051 use std::fs;
1052 use std::time::Duration;
1053 use tempfile::TempDir;
1054
1055 #[test]
1056 fn score_subsequence_match() {
1057 // Identical query matches start with high bonus.
1058 let a = score("main", "main.rs").unwrap();
1059 let b = score("main", "src/very/deep/main.rs").unwrap();
1060 assert!(a > b, "a={a} b={b}");
1061 }
1062
1063 #[test]
1064 fn score_rejects_non_subsequence() {
1065 assert!(score("zzz", "main.rs").is_none());
1066 assert!(score("xyz", "src/lib.rs").is_none());
1067 }
1068
1069 #[test]
1070 fn query_line_carries_explicit_ink_on_the_dark_surface() {
1071 // The picker paints WHALE_BG, so the typed query must carry its own
1072 // fg: light-profile terminals default to black ink.
1073 let dir = TempDir::new().expect("tempdir");
1074 let mut picker =
1075 FilePickerView::new_with_relevance(dir.path(), FilePickerRelevance::default());
1076 picker.query = "main".to_string();
1077 let area = Rect::new(0, 0, 80, 20);
1078 let mut buf = Buffer::empty(area);
1079 picker.render(area, &mut buf);
1080 let mut checked = 0;
1081 for y in 0..area.height {
1082 let mut row = String::new();
1083 for x in 0..area.width {
1084 row.push_str(buf[(x, y)].symbol());
1085 }
1086 if !row.contains("> main") {
1087 continue;
1088 }
1089 for x in 0..area.width {
1090 let cell = &buf[(x, y)];
1091 let symbol = cell.symbol();
1092 if symbol.trim().is_empty() || symbol == ">" {
1093 continue;
1094 }
1095 assert_eq!(
1096 cell.style().fg,
1097 Some(palette::TEXT_PRIMARY),
1098 "query cell ({x}, {y}) must carry explicit body ink",
1099 );
1100 checked += 1;
1101 }
1102 }
1103 assert!(checked > 0, "expected a rendered query line");
1104 }
1105
1106 #[test]
1107 fn score_boundary_bonus_beats_substring() {
1108 // "fp" matches the boundary letters in "file_picker.rs" but only the
1109 // first letter in "filepicker.rs" — so the boundary candidate should
1110 // win.
1111 let boundary = score("fp", "src/file_picker.rs").unwrap();
1112 let inline = score("fp", "src/filepicker.rs");
1113 // inline doesn't even contain 'p' immediately following 'f'? It does:
1114 // f-i-l-e-p-i-c-k-e-r — 'p' is preceded by 'e' (no boundary), so it
1115 // gets only the +1 path score, while boundary gets +25 for the 'p'
1116 // following the underscore.
1117 if let Some(inline_score) = inline {
1118 assert!(
1119 boundary > inline_score,
1120 "boundary={boundary} inline={inline_score}"
1121 );
1122 }
1123 }
1124
1125 #[test]
1126 fn score_case_insensitive() {
1127 assert!(score("MAIN", "main.rs").is_some());
1128 assert!(score("main", "MAIN.RS").is_some());
1129 }
1130
1131 #[test]
1132 fn score_empty_query_returns_zero() {
1133 assert_eq!(score("", "anything").unwrap(), 0);
1134 }
1135
1136 #[test]
1137 fn picker_typing_narrows_candidates() {
1138 let dir = TempDir::new().expect("tempdir");
1139 let root = dir.path();
1140 fs::create_dir_all(root.join("src")).unwrap();
1141 fs::write(root.join("src/main.rs"), "").unwrap();
1142 fs::write(root.join("src/lib.rs"), "").unwrap();
1143 fs::write(root.join("README.md"), "").unwrap();
1144 fs::write(root.join("Cargo.toml"), "").unwrap();
1145
1146 let mut view = FilePickerView::new_with_relevance(root, FilePickerRelevance::default());
1147 // Empty query -> all 4 files visible.
1148 assert_eq!(view.visible_count(), 4, "expected all 4 candidates");
1149
1150 // Typing "main" should narrow to just src/main.rs.
1151 for ch in "main".chars() {
1152 view.handle_key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
1153 }
1154 assert_eq!(view.query(), "main");
1155 let visible = view.visible_count();
1156 assert_eq!(visible, 1, "expected exactly 1 match for 'main'");
1157 let selected = view.selected_for_test().expect("selected path");
1158 assert!(selected.ends_with("main.rs"), "selected = {selected}");
1159 }
1160
1161 #[test]
1162 fn picker_empty_query_prioritizes_working_set_files() {
1163 let dir = TempDir::new().expect("tempdir");
1164 let root = dir.path();
1165 fs::create_dir_all(root.join("src")).unwrap();
1166 fs::write(root.join("src/main.rs"), "").unwrap();
1167 fs::write(root.join("src/lib.rs"), "").unwrap();
1168 fs::write(root.join("README.md"), "").unwrap();
1169
1170 let mut relevance = FilePickerRelevance::default();
1171 relevance.mark_modified("src/lib.rs");
1172 let view = FilePickerView::new_with_relevance(root, relevance);
1173
1174 assert_eq!(view.selected_for_test(), Some("src/lib.rs"));
1175 assert_eq!(view.markers_for_test("src/lib.rs"), "M ");
1176 }
1177
1178 #[test]
1179 fn picker_fuzzy_query_keeps_working_set_boosts() {
1180 let dir = TempDir::new().expect("tempdir");
1181 let root = dir.path();
1182 fs::create_dir_all(root.join("src")).unwrap();
1183 fs::write(root.join("src/alpha.rs"), "").unwrap();
1184 fs::write(root.join("src/zeta.rs"), "").unwrap();
1185
1186 let mut relevance = FilePickerRelevance::default();
1187 relevance.mark_mentioned("src/zeta.rs");
1188 relevance.mark_tool("src/zeta.rs");
1189 let mut view = FilePickerView::new_with_relevance(root, relevance);
1190 for ch in "rs".chars() {
1191 view.handle_key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
1192 }
1193
1194 assert_eq!(view.selected_for_test(), Some("src/zeta.rs"));
1195 assert_eq!(view.markers_for_test("src/zeta.rs"), " @T");
1196 }
1197
1198 #[test]
1199 fn picker_backspace_widens_candidates() {
1200 let dir = TempDir::new().expect("tempdir");
1201 let root = dir.path();
1202 fs::write(root.join("a.txt"), "").unwrap();
1203 fs::write(root.join("b.txt"), "").unwrap();
1204
1205 let mut view = FilePickerView::new_with_relevance(root, FilePickerRelevance::default());
1206 view.handle_key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE));
1207 assert_eq!(view.visible_count(), 1);
1208 view.handle_key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
1209 assert_eq!(view.visible_count(), 2);
1210 }
1211
1212 #[test]
1213 fn picker_enter_emits_event() {
1214 let dir = TempDir::new().expect("tempdir");
1215 let root = dir.path();
1216 fs::write(root.join("only.txt"), "").unwrap();
1217
1218 let mut view = FilePickerView::new_with_relevance(root, FilePickerRelevance::default());
1219 let action = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
1220 match action {
1221 ViewAction::EmitAndClose(ViewEvent::FilePickerSelected { path }) => {
1222 assert!(path.ends_with("only.txt"));
1223 }
1224 other => panic!("expected EmitAndClose(FilePickerSelected), got {other:?}"),
1225 }
1226 }
1227
1228 #[test]
1229 fn picker_esc_closes_without_emit() {
1230 let dir = TempDir::new().expect("tempdir");
1231 let root = dir.path();
1232 fs::write(root.join("only.txt"), "").unwrap();
1233
1234 let mut view = FilePickerView::new_with_relevance(root, FilePickerRelevance::default());
1235 let action = view.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
1236 assert!(matches!(action, ViewAction::Close));
1237 }
1238
1239 #[test]
1240 fn picker_honors_gitignore() {
1241 let dir = TempDir::new().expect("tempdir");
1242 let root = dir.path();
1243 // .gitignore filtering only kicks in inside a git repo or with an
1244 // explicit `.ignore` file. Use `.ignore` which `WalkBuilder` honors
1245 // even outside of git.
1246 fs::write(root.join(".ignore"), "skipme.txt\n").unwrap();
1247 fs::write(root.join("keepme.txt"), "").unwrap();
1248 fs::write(root.join("skipme.txt"), "").unwrap();
1249
1250 let view = FilePickerView::new_with_relevance(root, FilePickerRelevance::default());
1251 let visible: Vec<_> = view
1252 .filtered
1253 .iter()
1254 .map(|i| view.candidates[*i].as_str())
1255 .collect();
1256 assert!(visible.iter().any(|p| p.ends_with("keepme.txt")));
1257 assert!(
1258 !visible.iter().any(|p| p.ends_with("skipme.txt")),
1259 "skipme.txt should be filtered by .ignore: {visible:?}"
1260 );
1261 }
1262
1263 #[test]
1264 fn picker_finds_deeply_nested_files_within_walk_depth() {
1265 // #2488: a file inside a 6-level-deep directory sits at component depth
1266 // 7 and was excluded by the old depth-6 cap. The default depth (10) now
1267 // reaches it, and `0` (unlimited) reaches arbitrarily deep files.
1268 let dir = TempDir::new().expect("tempdir");
1269 let root = dir.path();
1270 let nested = root.join("a/b/c/d/e/f");
1271 fs::create_dir_all(&nested).unwrap();
1272 fs::write(nested.join("deep.rs"), "deep").unwrap();
1273 let deeper = root.join("a/b/c/d/e/f/g/h/i/j/k");
1274 fs::create_dir_all(&deeper).unwrap();
1275 fs::write(deeper.join("very_deep.rs"), "deeper").unwrap();
1276
1277 // The old default (6) misses the depth-7 file — the reported bug.
1278 let shallow = collect_candidates(root, Some(6));
1279 assert!(
1280 !shallow.iter().any(|p| p == "a/b/c/d/e/f/deep.rs"),
1281 "depth-6 cap should miss the depth-7 file: {shallow:?}"
1282 );
1283
1284 // The new default reaches files inside a 6-level-deep directory.
1285 let default = collect_candidates(root, Some(WALK_DEPTH));
1286 assert!(
1287 default.iter().any(|p| p == "a/b/c/d/e/f/deep.rs"),
1288 "default walk depth should reach depth-7 files: {default:?}"
1289 );
1290
1291 // Unlimited (mention_walk_depth = 0) reaches arbitrarily deep files.
1292 let unlimited = collect_candidates(root, None);
1293 assert!(
1294 unlimited
1295 .iter()
1296 .any(|p| p == "a/b/c/d/e/f/g/h/i/j/k/very_deep.rs"),
1297 "unlimited walk should reach very deep files: {unlimited:?}"
1298 );
1299 }
1300
1301 #[test]
1302 fn picker_skips_generated_worktree_bulk_inside_unignored_dot_dirs() {
1303 let dir = TempDir::new().expect("tempdir");
1304 let root = dir.path();
1305 fs::create_dir_all(root.join("src")).unwrap();
1306 fs::write(root.join("src/main.rs"), "fn main() {}").unwrap();
1307
1308 fs::create_dir_all(root.join(".deepseek/commands")).unwrap();
1309 fs::write(root.join(".deepseek/commands/build.md"), "build").unwrap();
1310 fs::create_dir_all(root.join(".deepseek/snapshots/deadbeef/.git/objects")).unwrap();
1311 fs::write(
1312 root.join(".deepseek/snapshots/deadbeef/.git/objects/snapshot.pack"),
1313 "pack",
1314 )
1315 .unwrap();
1316
1317 fs::create_dir_all(root.join(".claude/commands")).unwrap();
1318 fs::write(root.join(".claude/commands/test.md"), "test").unwrap();
1319 fs::create_dir_all(root.join(".claude/worktrees/agent/src")).unwrap();
1320 fs::write(
1321 root.join(".claude/worktrees/agent/src/agent-only.md"),
1322 "agent",
1323 )
1324 .unwrap();
1325
1326 let candidates = collect_candidates(root, Some(WALK_DEPTH));
1327
1328 assert!(candidates.iter().any(|path| path == "src/main.rs"));
1329 assert!(
1330 candidates
1331 .iter()
1332 .any(|path| path == ".deepseek/commands/build.md"),
1333 "normal .deepseek command files should stay discoverable: {candidates:?}",
1334 );
1335 assert!(
1336 candidates
1337 .iter()
1338 .any(|path| path == ".claude/commands/test.md"),
1339 "normal .claude command files should stay discoverable: {candidates:?}",
1340 );
1341 assert!(
1342 candidates
1343 .iter()
1344 .all(|path| !path.starts_with(".deepseek/snapshots/")),
1345 "snapshot side repo files must not enter picker candidates: {candidates:?}",
1346 );
1347 assert!(
1348 candidates
1349 .iter()
1350 .all(|path| !path.starts_with(".claude/worktrees/")),
1351 ".claude worktree files must not enter picker candidates: {candidates:?}",
1352 );
1353 }
1354
1355 #[test]
1356 fn collect_candidates_limited_stops_at_the_cap_and_flags_truncation() {
1357 let dir = TempDir::new().expect("tempdir");
1358 let root = dir.path();
1359 fs::create_dir_all(root.join("pad")).unwrap();
1360 for i in 0..30 {
1361 fs::write(root.join("pad").join(format!("n{i:02}.txt")), "").unwrap();
1362 }
1363
1364 let walk = collect_candidates_limited(root, Some(WALK_DEPTH), 12);
1365 assert!(
1366 walk.truncated,
1367 "hitting the cap must mark the index incomplete"
1368 );
1369 assert_eq!(walk.paths.len(), 12);
1370 assert!(
1371 !collect_candidates_limited(root, Some(WALK_DEPTH), 64).truncated,
1372 "a cap above the file count is a complete index"
1373 );
1374 }
1375
1376 #[test]
1377 fn targeted_rescan_finds_a_file_the_candidate_cap_dropped() {
1378 // #2488: the opening walk keeps the first N files in walk order. A
1379 // later unique file must still be reachable once the user types it.
1380 let dir = TempDir::new().expect("tempdir");
1381 let root = dir.path();
1382 fs::create_dir_all(root.join("pad")).unwrap();
1383 for i in 0..40 {
1384 fs::write(root.join("pad").join(format!("n{i:02}.txt")), "").unwrap();
1385 }
1386 fs::create_dir_all(root.join("zzz")).unwrap();
1387 fs::write(root.join("zzz/room_chat_shell.dart"), "late").unwrap();
1388
1389 let walk = collect_candidates_limited(root, Some(WALK_DEPTH), 15);
1390 assert!(walk.truncated);
1391 let hits = collect_query_matches(root, Some(WALK_DEPTH), "room_chat_shell", 64);
1392 assert!(
1393 hits.iter().any(|path| path == "zzz/room_chat_shell.dart"),
1394 "targeted rescan must recover the file past the cap: {hits:?}"
1395 );
1396 }
1397
1398 #[test]
1399 fn targeted_walk_root_descends_into_an_existing_prefix() {
1400 let dir = TempDir::new().expect("tempdir");
1401 let root = dir.path();
1402 fs::create_dir_all(root.join("src/nested")).unwrap();
1403 fs::write(root.join("src/nested/hit.rs"), "").unwrap();
1404 let (start, depth) = targeted_walk_root(root, "src/nested/hit", Some(10));
1405 assert_eq!(start, root.join("src/nested"));
1406 assert_eq!(depth, Some(8));
1407 }
1408
1409 #[test]
1410 fn picker_query_miss_rescans_a_truncated_index() {
1411 let dir = TempDir::new().expect("tempdir");
1412 let root = dir.path();
1413 fs::create_dir_all(root.join("zzz")).unwrap();
1414 fs::write(root.join("zzz/room_chat_shell.dart"), "late").unwrap();
1415
1416 let mut view = FilePickerView::from_preloaded(
1417 root,
1418 vec!["pad/n00.txt".into(), "pad/n01.txt".into()],
1419 true,
1420 Some(WALK_DEPTH),
1421 );
1422 assert_eq!(
1423 view.visible_count(),
1424 2,
1425 "empty query shows the truncated index"
1426 );
1427
1428 for ch in "room_chat_shell".chars() {
1429 view.handle_key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
1430 }
1431 assert_eq!(
1432 view.selected_for_test(),
1433 Some("zzz/room_chat_shell.dart"),
1434 "a miss against the truncated index must rescan and surface the file"
1435 );
1436 }
1437
1438 #[test]
1439 fn picker_complete_index_miss_does_not_rescan() {
1440 let dir = TempDir::new().expect("tempdir");
1441 let root = dir.path();
1442 fs::write(root.join("keep.txt"), "").unwrap();
1443 // A file on disk that is not in the (complete) index must stay
1444 // invisible — a complete walk already saw the whole tree.
1445 fs::write(root.join("secret.txt"), "").unwrap();
1446
1447 let mut view =
1448 FilePickerView::from_preloaded(root, vec!["keep.txt".into()], false, Some(WALK_DEPTH));
1449 for ch in "secret".chars() {
1450 view.handle_key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
1451 }
1452 assert_eq!(view.visible_count(), 0);
1453 assert_eq!(view.candidates, vec!["keep.txt".to_string()]);
1454 }
1455
1456 /// The four terminal sizes the v0.8.66 modal blocker (#3732) requires
1457 /// every overlay to remain readable and fully operable at.
1458 const BLOCKER_SIZES: [(u16, u16); 4] = [(80, 24), (100, 30), (120, 32), (160, 40)];
1459
1460 #[test]
1461 fn file_picker_is_usable_and_opaque_at_blocker_sizes() {
1462 use crate::tui::views::ViewStack;
1463 use ratatui::{buffer::Buffer, layout::Rect};
1464 use unicode_width::UnicodeWidthStr;
1465
1466 let dir = TempDir::new().expect("tempdir");
1467 let root = dir.path();
1468 fs::create_dir_all(root.join("src")).unwrap();
1469 fs::write(root.join("src/main.rs"), "").unwrap();
1470 fs::write(root.join("src/lib.rs"), "").unwrap();
1471 fs::write(root.join("README.md"), "").unwrap();
1472
1473 for (w, h) in BLOCKER_SIZES {
1474 let area = Rect::new(0, 0, w, h);
1475 let mut buf = Buffer::empty(area);
1476 for y in 0..h {
1477 for x in 0..w {
1478 buf[(x, y)].set_symbol("X");
1479 }
1480 }
1481 let mut stack = ViewStack::new();
1482 stack.push(FilePickerView::new_with_relevance(
1483 root,
1484 FilePickerRelevance::default(),
1485 ));
1486 stack.render(area, &mut buf);
1487
1488 let rows: Vec<String> = (0..h)
1489 .map(|y| {
1490 (0..w)
1491 .map(|x| buf[(x, y)].symbol().to_string())
1492 .collect::<String>()
1493 })
1494 .collect();
1495 let text = rows.join("\n");
1496
1497 for label in ["move", "insert @path", "cancel"] {
1498 assert!(text.contains(label), "{w}x{h}: missing footer '{label}'");
1499 }
1500 assert!(
1501 !text.contains('X'),
1502 "{w}x{h}: background bleed-through into modal surface"
1503 );
1504 assert_eq!(
1505 buf[(w / 2, h / 2)].bg,
1506 palette::WHALE_BG,
1507 "{w}x{h}: modal interior must be opaque"
1508 );
1509 for (y, row) in rows.iter().enumerate() {
1510 assert!(
1511 UnicodeWidthStr::width(row.trim_end()) <= w as usize,
1512 "{w}x{h}: row {y} overflows width: {row:?}"
1513 );
1514 }
1515 }
1516 }
1517
1518 /// #3905: opening the picker used to block the event loop on a `git status`
1519 /// subprocess plus a walk of up to MAX_CANDIDATES paths, freezing the whole
1520 /// TUI between Ctrl+P and the picker appearing.
1521 ///
1522 /// Asserting "fast" by wall clock would be a flaky proxy for the real
1523 /// contract, so this asserts the structural property instead: inside a
1524 /// runtime the constructor returns a paintable view that has not yet done
1525 /// the scan, and the results arrive later through `tick`.
1526 #[tokio::test]
1527 async fn opening_the_picker_does_not_block_on_the_workspace_scan() {
1528 let ws = TempDir::new().unwrap();
1529 fs::create_dir_all(ws.path().join("src")).unwrap();
1530 for i in 0..200 {
1531 fs::write(ws.path().join("src").join(format!("f{i}.rs")), "x").unwrap();
1532 }
1533
1534 let mut view = FilePickerView::new_with_relevance_and_depth(
1535 ws.path(),
1536 FilePickerRelevance::default(),
1537 WALK_DEPTH,
1538 Locale::En,
1539 );
1540
1541 assert!(
1542 view.is_loading,
1543 "the constructor must hand back a paintable view, not a finished scan"
1544 );
1545 assert!(
1546 view.candidates.is_empty(),
1547 "no walk may have run on the calling thread"
1548 );
1549
1550 // The view is renderable in the loading state — this is the frame the
1551 // user sees immediately after Ctrl+P.
1552 let area = Rect::new(0, 0, 60, 20);
1553 let mut buf = Buffer::empty(area);
1554 view.render(area, &mut buf);
1555
1556 for _ in 0..500 {
1557 view.tick();
1558 if !view.is_loading {
1559 break;
1560 }
1561 tokio::time::sleep(Duration::from_millis(5)).await;
1562 }
1563
1564 assert!(!view.is_loading, "the background scan must land via tick");
1565 assert_eq!(
1566 view.candidates.len(),
1567 200,
1568 "every workspace file is discovered once the scan lands"
1569 );
1570 assert_eq!(
1571 view.filtered.len(),
1572 200,
1573 "results are refiltered after the scan, not left empty"
1574 );
1575 }
1576
1577 /// A query typed while the scan was still running must survive it.
1578 #[tokio::test]
1579 async fn a_query_typed_during_the_scan_is_applied_when_results_land() {
1580 let ws = TempDir::new().unwrap();
1581 fs::write(ws.path().join("alpha.rs"), "x").unwrap();
1582 fs::write(ws.path().join("beta.rs"), "x").unwrap();
1583
1584 let mut view = FilePickerView::new_with_relevance_and_depth(
1585 ws.path(),
1586 FilePickerRelevance::default(),
1587 WALK_DEPTH,
1588 Locale::En,
1589 );
1590 assert!(view.is_loading);
1591
1592 for ch in "alpha".chars() {
1593 view.handle_key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
1594 }
1595
1596 for _ in 0..500 {
1597 view.tick();
1598 if !view.is_loading {
1599 break;
1600 }
1601 tokio::time::sleep(Duration::from_millis(5)).await;
1602 }
1603
1604 assert!(!view.is_loading);
1605 assert_eq!(view.query, "alpha");
1606 let matched: Vec<&str> = view
1607 .filtered
1608 .iter()
1609 .map(|i| view.candidates[*i].as_str())
1610 .collect();
1611 assert_eq!(
1612 matched,
1613 vec!["alpha.rs"],
1614 "the scan must refilter against the query the user already typed"
1615 );
1616 }
1617
1618 /// #2488: a miss-rescan on a truncated index must not run on the event
1619 /// loop. The constructor-style property from #3905 applies here too:
1620 /// `handle_key` returns a paintable view and the extra file arrives via
1621 /// `tick`.
1622 #[tokio::test]
1623 async fn truncated_index_rescan_does_not_block_handle_key() {
1624 let ws = TempDir::new().unwrap();
1625 fs::write(ws.path().join("late_unique_file.rs"), "x").unwrap();
1626
1627 let mut view = FilePickerView::from_preloaded(
1628 ws.path(),
1629 vec!["unrelated.rs".into()],
1630 true,
1631 Some(WALK_DEPTH),
1632 );
1633 for ch in "late_unique_file".chars() {
1634 view.handle_key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
1635 }
1636 assert!(
1637 view.is_rescanning
1638 || view
1639 .candidates
1640 .iter()
1641 .any(|path| path == "late_unique_file.rs"),
1642 "rescan must start off-thread (or already have merged on a tiny race)"
1643 );
1644
1645 for _ in 0..500 {
1646 view.tick();
1647 if view
1648 .candidates
1649 .iter()
1650 .any(|path| path == "late_unique_file.rs")
1651 {
1652 break;
1653 }
1654 tokio::time::sleep(Duration::from_millis(5)).await;
1655 }
1656 assert_eq!(view.selected_for_test(), Some("late_unique_file.rs"));
1657 }
1658 }
1659
1659 lines RUST