返回 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). Subsequent keystrokes filter the cached candidate
7 //! list in memory using a small subsequence + first-letter-bonus scorer — no
8 //! per-keystroke disk traversal.
9 //!
10 //! Enter emits a [`ViewEvent::FilePickerSelected`] which the UI handler turns
11 //! into an `@<path>` insertion at the composer cursor.
12
13 use std::cell::RefCell;
14 use std::collections::HashSet;
15 use std::path::Path;
16 use std::sync::{Arc, Mutex};
17
18 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
19 use ignore::WalkBuilder;
20 use ratatui::{
21 buffer::Buffer,
22 layout::Rect,
23 style::Style,
24 text::{Line, Span},
25 widgets::{Paragraph, Widget},
26 };
27
28 use crate::localization::{Locale, MessageId, tr};
29 use crate::palette;
30 use crate::tui::menu_style;
31 use crate::tui::views::{
32 ActionHint, ModalKind, ModalView, ViewAction, ViewEvent, render_modal_footer,
33 render_panel_scroll_rail, render_underwater_surface,
34 };
35 use crate::workspace_discovery::{DISCOVERY_ALWAYS_DIRS, path_is_excluded_from_discovery};
36
37 /// Maximum number of candidates collected from the initial walk. Keeps memory
38 /// bounded for very large monorepos; matches the limits codex-rs uses for the
39 /// equivalent overlay.
40 const MAX_CANDIDATES: usize = 20_000;
41
42 /// Default walk depth used by the picker's own tests. Production callers pass
43 /// the configured `mention_walk_depth` (default 10, `0` = unlimited) through
44 /// [`FilePickerView::new_with_relevance_and_depth`], mirroring the `Workspace`
45 /// fuzzy index default (`DEFAULT_COMPLETIONS_WALK_DEPTH`).
46 #[cfg(test)]
47 const WALK_DEPTH: usize = 10;
48
49 /// Visible candidate rows in the overlay.
50 const VISIBLE_ROWS: usize = 14;
51
52 const MODIFIED_BOOST: i32 = 360;
53 const MENTIONED_BOOST: i32 = 240;
54 const TOOL_BOOST: i32 = 160;
55
56 /// Working-set hints captured when the picker opens.
57 ///
58 /// The picker keeps this as plain path strings so filtering stays in-memory and
59 /// per-keystroke work remains the same shape as the original fuzzy search.
60 #[derive(Debug, Clone, Default, PartialEq, Eq)]
61 pub struct FilePickerRelevance {
62 modified: HashSet<String>,
63 mentioned: HashSet<String>,
64 tool: HashSet<String>,
65 }
66
67 impl FilePickerRelevance {
68 pub fn mark_modified(&mut self, path: impl Into<String>) {
69 let path = path.into();
70 if !path.is_empty() {
71 self.modified.insert(path);
72 }
73 }
74
75 pub fn mark_mentioned(&mut self, path: impl Into<String>) {
76 let path = path.into();
77 if !path.is_empty() {
78 self.mentioned.insert(path);
79 }
80 }
81
82 pub fn mark_tool(&mut self, path: impl Into<String>) {
83 let path = path.into();
84 if !path.is_empty() {
85 self.tool.insert(path);
86 }
87 }
88
89 fn boost_for(&self, path: &str) -> i32 {
90 let mut boost = 0;
91 if self.modified.contains(path) {
92 boost += MODIFIED_BOOST;
93 }
94 if self.mentioned.contains(path) {
95 boost += MENTIONED_BOOST;
96 }
97 if self.tool.contains(path) {
98 boost += TOOL_BOOST;
99 }
100 boost
101 }
102
103 fn markers_for(&self, path: &str) -> String {
104 let mut markers = String::with_capacity(3);
105 markers.push(if self.modified.contains(path) {
106 'M'
107 } else {
108 ' '
109 });
110 markers.push(if self.mentioned.contains(path) {
111 '@'
112 } else {
113 ' '
114 });
115 markers.push(if self.tool.contains(path) { 'T' } else { ' ' });
116 markers
117 }
118 }
119
120 pub struct FilePickerView {
121 /// All workspace-relative candidate paths, captured once at construction.
122 candidates: Vec<String>,
123 /// Working-set relevance hints, captured once at construction.
124 relevance: FilePickerRelevance,
125 /// Filtered indices into `candidates`, sorted by descending score.
126 filtered: Vec<usize>,
127 /// User's typed query (lowercased on each refilter).
128 query: String,
129 /// Selected row within `filtered`.
130 selected: usize,
131 /// Top of the visible window within `filtered`.
132 scroll: usize,
133 /// Exact visible row targets from the last render for mouse parity.
134 last_row_hitboxes: RefCell<Vec<(u16, usize)>>,
135 /// UI locale captured from the app at construction (#4057 wave 2).
136 locale: Locale,
137 /// True until the background workspace scan delivers (#3905). The picker
138 /// paints immediately in this state instead of blocking the event loop on
139 /// a `git status` subprocess and a 20k-file walk.
140 is_loading: bool,
141 /// Where the background scan drops its result. `None` once drained, or
142 /// when the scan ran synchronously (no tokio runtime, i.e. unit tests).
143 loading_cell: Option<Arc<Mutex<Option<WorkspaceScan>>>>,
144 }
145
146 /// What the off-thread workspace scan produces: the candidate paths and the
147 /// git-reported modified paths, which are the only two blocking parts of
148 /// building this picker.
149 struct WorkspaceScan {
150 candidates: Vec<String>,
151 modified: Vec<String>,
152 }
153
154 impl FilePickerView {
155 /// Build a picker with working-set relevance hints, using the default
156 /// walk depth ([`WALK_DEPTH`]). Test-only convenience; production code uses
157 /// [`FilePickerView::new_with_relevance_and_depth`] with the configured
158 /// `mention_walk_depth`.
159 #[cfg(test)]
160 pub fn new_with_relevance(workspace_root: &Path, relevance: FilePickerRelevance) -> Self {
161 Self::new_with_relevance_and_depth(workspace_root, relevance, WALK_DEPTH, Locale::En)
162 }
163
164 /// Build a picker with working-set relevance hints and an explicit walk
165 /// depth. A depth of `0` disables the depth limit so files in deeply
166 /// nested workspaces (>= 6 levels) remain discoverable (#2488).
167 pub fn new_with_relevance_and_depth(
168 workspace_root: &Path,
169 relevance: FilePickerRelevance,
170 walk_depth: usize,
171 locale: Locale,
172 ) -> Self {
173 let max_depth = if walk_depth == 0 {
174 None
175 } else {
176 Some(walk_depth)
177 };
178
179 // Outside a tokio runtime (plain unit tests) do the work inline, so
180 // tests keep observing a fully-populated picker from the constructor.
181 if tokio::runtime::Handle::try_current().is_err() {
182 let candidates = collect_candidates(workspace_root, max_depth);
183 let mut relevance = relevance;
184 for path in crate::tui::file_picker_relevance::modified_workspace_paths(workspace_root)
185 {
186 relevance.mark_modified(path);
187 }
188 let mut view = Self {
189 candidates,
190 relevance,
191 filtered: Vec::new(),
192 query: String::new(),
193 selected: 0,
194 scroll: 0,
195 last_row_hitboxes: RefCell::new(Vec::new()),
196 locale,
197 is_loading: false,
198 loading_cell: None,
199 };
200 view.refilter();
201 return view;
202 }
203
204 // Both halves of the scan are blocking: `git status` is a subprocess,
205 // and the walk visits up to MAX_CANDIDATES paths. Neither belongs on
206 // the event loop — Ctrl+P used to freeze the whole TUI until both
207 // finished (#3905), the same failure #3899/#3900 fixed for the
208 // adjacent @-mention and file-tree paths.
209 let loading_cell = Arc::new(Mutex::new(None));
210 let cell = loading_cell.clone();
211 let root = workspace_root.to_path_buf();
212 crate::utils::spawn_blocking_supervised("file-picker-scan", move || {
213 let scan = WorkspaceScan {
214 candidates: collect_candidates(&root, max_depth),
215 modified: crate::tui::file_picker_relevance::modified_workspace_paths(&root),
216 };
217 if let Ok(mut guard) = cell.lock() {
218 *guard = Some(scan);
219 }
220 });
221
222 let mut view = Self {
223 candidates: Vec::new(),
224 relevance,
225 filtered: Vec::new(),
226 query: String::new(),
227 selected: 0,
228 scroll: 0,
229 last_row_hitboxes: RefCell::new(Vec::new()),
230 locale,
231 is_loading: true,
232 loading_cell: Some(loading_cell),
233 };
234 view.refilter();
235 view
236 }
237
238 /// Drain the background scan if it has landed. Called from `tick`, which
239 /// the view stack runs on the top view every loop iteration.
240 fn poll_loading(&mut self) {
241 if !self.is_loading {
242 return;
243 }
244 // Take the Arc out temporarily to avoid a double-borrow of self.
245 let Some(cell) = self.loading_cell.take() else {
246 self.is_loading = false;
247 return;
248 };
249 let scan = cell.lock().ok().and_then(|mut guard| guard.take());
250 match scan {
251 Some(scan) => {
252 self.candidates = scan.candidates;
253 for path in scan.modified {
254 self.relevance.mark_modified(path);
255 }
256 self.is_loading = false;
257 // The user may already have typed while the scan ran; refilter
258 // against the query they actually have, not an empty one.
259 self.refilter();
260 }
261 None => self.loading_cell = Some(cell),
262 }
263 }
264
265 fn refilter(&mut self) {
266 let query = self.query.trim().to_lowercase();
267 let mut scored: Vec<(usize, i32, i32, i32)> = if query.is_empty() {
268 self.candidates
269 .iter()
270 .enumerate()
271 .map(|(idx, path)| {
272 let boost = self.relevance.boost_for(path);
273 (idx, boost, 0, boost)
274 })
275 .collect()
276 } else {
277 self.candidates
278 .iter()
279 .enumerate()
280 .filter_map(|(idx, path)| {
281 score(&query, path).map(|fuzzy| {
282 let boost = self.relevance.boost_for(path);
283 (idx, fuzzy + boost, fuzzy, boost)
284 })
285 })
286 .collect()
287 };
288
289 // Higher scores first; tie-break by ascending path length, then lex order
290 // so shorter / more central matches surface above deep nested ones.
291 scored.sort_by(|a, b| {
292 b.1.cmp(&a.1)
293 .then_with(|| b.2.cmp(&a.2))
294 .then_with(|| b.3.cmp(&a.3))
295 .then_with(|| self.candidates[a.0].len().cmp(&self.candidates[b.0].len()))
296 .then_with(|| self.candidates[a.0].cmp(&self.candidates[b.0]))
297 });
298
299 self.filtered = scored.into_iter().map(|(idx, _, _, _)| idx).collect();
300 if self.filtered.is_empty() {
301 self.selected = 0;
302 self.scroll = 0;
303 } else if self.selected >= self.filtered.len() {
304 self.selected = self.filtered.len() - 1;
305 }
306 self.adjust_scroll();
307 }
308
309 fn adjust_scroll(&mut self) {
310 if self.filtered.is_empty() {
311 self.scroll = 0;
312 return;
313 }
314 if self.selected < self.scroll {
315 self.scroll = self.selected;
316 } else if self.selected >= self.scroll + VISIBLE_ROWS {
317 self.scroll = self.selected + 1 - VISIBLE_ROWS;
318 }
319 }
320
321 fn move_selection(&mut self, delta: isize) {
322 if self.filtered.is_empty() {
323 return;
324 }
325 self.selected = crate::tui::list_nav::wrap_index(self.selected, self.filtered.len(), delta);
326 self.adjust_scroll();
327 }
328
329 fn selected_path(&self) -> Option<&str> {
330 let idx = *self.filtered.get(self.selected)?;
331 self.candidates.get(idx).map(String::as_str)
332 }
333
334 /// Visible candidate count for tests / diagnostics.
335 #[cfg(test)]
336 pub fn visible_count(&self) -> usize {
337 self.filtered.len()
338 }
339
340 #[cfg(test)]
341 pub fn query(&self) -> &str {
342 &self.query
343 }
344
345 #[cfg(test)]
346 pub fn selected_for_test(&self) -> Option<&str> {
347 self.selected_path()
348 }
349
350 #[cfg(test)]
351 pub fn markers_for_test(&self, path: &str) -> String {
352 self.relevance.markers_for(path)
353 }
354 }
355
356 impl ModalView for FilePickerView {
357 fn kind(&self) -> ModalKind {
358 ModalKind::FilePicker
359 }
360
361 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
362 self
363 }
364
365 fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
366 match key.code {
367 KeyCode::Esc => ViewAction::Close,
368 KeyCode::Enter => {
369 if let Some(path) = self.selected_path() {
370 let path = path.to_string();
371 return ViewAction::EmitAndClose(ViewEvent::FilePickerSelected { path });
372 }
373 ViewAction::Close
374 }
375 KeyCode::Up => {
376 self.move_selection(-1);
377 ViewAction::None
378 }
379 KeyCode::Down => {
380 self.move_selection(1);
381 ViewAction::None
382 }
383 KeyCode::PageUp => {
384 self.move_selection(-(VISIBLE_ROWS as isize));
385 ViewAction::None
386 }
387 KeyCode::PageDown => {
388 self.move_selection(VISIBLE_ROWS as isize);
389 ViewAction::None
390 }
391 KeyCode::Backspace => {
392 self.query.pop();
393 self.selected = 0;
394 self.scroll = 0;
395 self.refilter();
396 ViewAction::None
397 }
398 KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
399 self.query.clear();
400 self.selected = 0;
401 self.scroll = 0;
402 self.refilter();
403 ViewAction::None
404 }
405 KeyCode::Char(ch)
406 if !key.modifiers.contains(KeyModifiers::CONTROL)
407 && !key.modifiers.contains(KeyModifiers::ALT)
408 && !ch.is_control() =>
409 {
410 self.query.push(ch);
411 self.selected = 0;
412 self.scroll = 0;
413 self.refilter();
414 ViewAction::None
415 }
416 _ => ViewAction::None,
417 }
418 }
419
420 fn handle_mouse(&mut self, mouse: MouseEvent) -> ViewAction {
421 match mouse.kind {
422 MouseEventKind::ScrollUp => {
423 self.move_selection(-1);
424 ViewAction::None
425 }
426 MouseEventKind::ScrollDown => {
427 self.move_selection(1);
428 ViewAction::None
429 }
430 MouseEventKind::Down(MouseButton::Left) => {
431 let hit = self
432 .last_row_hitboxes
433 .borrow()
434 .iter()
435 .find_map(|(y, idx)| (*y == mouse.row).then_some(*idx));
436 let Some(idx) = hit else {
437 return ViewAction::None;
438 };
439 if idx == self.selected {
440 if let Some(path) = self.selected_path() {
441 return ViewAction::EmitAndClose(ViewEvent::FilePickerSelected {
442 path: path.to_string(),
443 });
444 }
445 } else {
446 self.selected = idx;
447 self.adjust_scroll();
448 }
449 ViewAction::None
450 }
451 _ => ViewAction::None,
452 }
453 }
454
455 fn tick(&mut self) -> ViewAction {
456 self.poll_loading();
457 ViewAction::None
458 }
459
460 fn render(&self, area: Rect, buf: &mut Buffer) {
461 let match_count = self.filtered.len();
462 let title = if match_count == 1 {
463 tr(self.locale, MessageId::FilePickerMatchSingular).into_owned()
464 } else {
465 tr(self.locale, MessageId::FilePickerMatchesPlural)
466 .replace("{count}", &match_count.to_string())
467 };
468 let inner = render_underwater_surface(area, buf, title);
469
470 let content = render_modal_footer(
471 inner,
472 buf,
473 &[
474 ActionHint::new("↑/↓", "move"),
475 ActionHint::new("Enter", "insert @path"),
476 ActionHint::new("Esc", "cancel"),
477 ],
478 );
479 let visible = VISIBLE_ROWS.min(content.height.saturating_sub(2) as usize);
480 let content = render_panel_scroll_rail(
481 content,
482 buf,
483 self.filtered.len(),
484 self.scroll,
485 visible,
486 true,
487 );
488
489 let mut lines: Vec<Line<'static>> = Vec::new();
490 // Query line.
491 lines.push(Line::from(vec![
492 Span::styled("> ", Style::default().fg(palette::WHALE_INFO).bold()),
493 Span::raw(self.query.clone()),
494 Span::styled(
495 " ",
496 Style::default()
497 .fg(palette::WHALE_BG)
498 .bg(palette::WHALE_INFO),
499 ),
500 ]));
501 lines.push(Line::from(""));
502
503 let end = (self.scroll + visible).min(self.filtered.len());
504 self.last_row_hitboxes.borrow_mut().clear();
505 if self.is_loading {
506 // "No matches" would be a lie while the walk is still running.
507 lines.push(Line::from(Span::styled(
508 format!(" {}", tr(self.locale, MessageId::FilePickerScanning)),
509 Style::default().fg(palette::TEXT_MUTED),
510 )));
511 } else if self.filtered.is_empty() {
512 lines.push(Line::from(Span::styled(
513 " No matches",
514 Style::default().fg(palette::TEXT_MUTED),
515 )));
516 } else {
517 for idx in self.scroll..end {
518 let path = &self.candidates[self.filtered[idx]];
519 let selected = idx == self.selected;
520 let style = if selected {
521 menu_style::selected_row_bg_style().fg(palette::SELECTION_TEXT)
522 } else {
523 Style::default().fg(palette::TEXT_PRIMARY)
524 };
525 let prefix = format!("{} ", crate::tui::glyphs::selection_marker(selected));
526 let marker_field = if content.width >= 18 {
527 format!("{} ", self.relevance.markers_for(path))
528 } else {
529 String::new()
530 };
531 let reserved = prefix.chars().count() + marker_field.chars().count();
532 let display =
533 truncate_path(path, (content.width as usize).saturating_sub(reserved));
534 let mut line = Line::from(format!("{prefix}{marker_field}{display}"));
535 line.style = style;
536 let y = content
537 .y
538 .saturating_add(u16::try_from(lines.len()).unwrap_or(u16::MAX));
539 self.last_row_hitboxes.borrow_mut().push((y, idx));
540 lines.push(line);
541 }
542 }
543
544 Paragraph::new(lines)
545 .style(Style::default().fg(palette::TEXT_PRIMARY))
546 .render(content, buf);
547 }
548 }
549
550 fn truncate_path(path: &str, max: usize) -> String {
551 if max == 0 {
552 return String::new();
553 }
554 if path.chars().count() <= max {
555 return path.to_string();
556 }
557 let take = max.saturating_sub(1);
558 let truncated: String = path
559 .chars()
560 .rev()
561 .take(take)
562 .collect::<Vec<_>>()
563 .into_iter()
564 .rev()
565 .collect();
566 format!("…{truncated}")
567 }
568
569 /// Single-pass walk that collects workspace-relative paths. `max_depth` of
570 /// `None` walks the whole tree (still bounded by `MAX_CANDIDATES` and
571 /// `.gitignore`); `Some(n)` caps the recursion at `n` levels.
572 fn collect_candidates(root: &Path, max_depth: Option<usize>) -> Vec<String> {
573 let mut builder = WalkBuilder::new(root);
574 builder
575 .hidden(true)
576 .follow_links(false)
577 .max_depth(max_depth)
578 .git_ignore(true)
579 .git_exclude(true)
580 .git_global(true);
581
582 let mut out: Vec<String> = Vec::new();
583 for entry in builder.build().flatten() {
584 if !entry.file_type().is_some_and(|ft| ft.is_file()) {
585 continue;
586 }
587 let path = entry.path();
588 let rel = path.strip_prefix(root).unwrap_or(path);
589 if rel.as_os_str().is_empty() {
590 continue;
591 }
592 let display = path_to_workspace_string(rel);
593 if !display.is_empty() {
594 out.push(display);
595 }
596 if out.len() >= MAX_CANDIDATES {
597 break;
598 }
599 }
600
601 // Whitelist AI-tool dot-directories so they're discoverable even when
602 // gitignored. Walk each one separately with gitignore disabled.
603 for dir in DISCOVERY_ALWAYS_DIRS {
604 let dot_dir = root.join(dir);
605 if !dot_dir.is_dir() {
606 continue;
607 }
608 let mut dot_builder = WalkBuilder::new(&dot_dir);
609 dot_builder
610 .hidden(true)
611 .follow_links(false)
612 .git_ignore(false)
613 .ignore(false)
614 .max_depth(max_depth.map(|d| d.saturating_sub(1)));
615 for entry in dot_builder.build().flatten() {
616 // Exclude machine-generated bulk (e.g. .deepseek/snapshots/).
617 if path_is_excluded_from_discovery(root, entry.path()) {
618 continue;
619 }
620 if !entry.file_type().is_some_and(|ft| ft.is_file()) {
621 continue;
622 }
623 let path = entry.path();
624 let rel = path.strip_prefix(root).unwrap_or(path);
625 if rel.as_os_str().is_empty() {
626 continue;
627 }
628 let display = path_to_workspace_string(rel);
629 if !display.is_empty() {
630 out.push(display);
631 }
632 if out.len() >= MAX_CANDIDATES {
633 break;
634 }
635 }
636 }
637
638 out.sort();
639 out
640 }
641
642 fn path_to_workspace_string(path: &Path) -> String {
643 // Use forward-slash separators for cross-platform display, matching how
644 // @-mentions are spelled in the composer.
645 let mut out = String::new();
646 for (idx, comp) in path.components().enumerate() {
647 if idx > 0 {
648 out.push('/');
649 }
650 out.push_str(&comp.as_os_str().to_string_lossy());
651 }
652 out
653 }
654
655 /// Subsequence scorer with first-letter and boundary bonuses.
656 ///
657 /// Returns `None` if `query` is not a subsequence of `path` (case-insensitive),
658 /// otherwise a positive score where higher is better.
659 ///
660 /// Heuristics (kept deliberately small and predictable):
661 /// * +25 for each match that lands at the start of the path or right after a
662 /// boundary character (`/`, `_`, `-`, `.`, ` `).
663 /// * +10 if the very first character of the query matches the first character
664 /// of the path.
665 /// * +5 per consecutive match (rewards contiguous runs like typing "main" and
666 /// matching `main.rs`).
667 /// * Penalty proportional to the gap between consecutive matches keeps tightly
668 /// matched candidates above scattered ones.
669 pub fn score(query: &str, path: &str) -> Option<i32> {
670 if query.is_empty() {
671 return Some(0);
672 }
673 let q: Vec<char> = query.chars().flat_map(char::to_lowercase).collect();
674 let p: Vec<char> = path.chars().flat_map(char::to_lowercase).collect();
675 if q.len() > p.len() {
676 return None;
677 }
678
679 let mut qi = 0usize;
680 let mut score: i32 = 0;
681 let mut last_match: Option<usize> = None;
682 let mut consecutive = 0i32;
683
684 for (i, ch) in p.iter().enumerate() {
685 if qi >= q.len() {
686 break;
687 }
688 if *ch == q[qi] {
689 // Boundary / start bonus.
690 if i == 0 {
691 score += 25;
692 if qi == 0 {
693 score += 10;
694 }
695 } else if matches!(p[i - 1], '/' | '_' | '-' | '.' | ' ') {
696 score += 25;
697 } else {
698 score += 1;
699 }
700
701 // Consecutive bonus.
702 if last_match == Some(i.saturating_sub(1)) {
703 consecutive += 1;
704 score += 5 * consecutive;
705 } else {
706 consecutive = 0;
707 }
708
709 // Gap penalty.
710 if let Some(prev) = last_match {
711 let gap = i - prev - 1;
712 score -= gap as i32;
713 }
714
715 last_match = Some(i);
716 qi += 1;
717 }
718 }
719
720 if qi == q.len() { Some(score) } else { None }
721 }
722
723 #[cfg(test)]
724 mod tests {
725 use super::*;
726 use std::fs;
727 use std::time::Duration;
728 use tempfile::TempDir;
729
730 #[test]
731 fn score_subsequence_match() {
732 // Identical query matches start with high bonus.
733 let a = score("main", "main.rs").unwrap();
734 let b = score("main", "src/very/deep/main.rs").unwrap();
735 assert!(a > b, "a={a} b={b}");
736 }
737
738 #[test]
739 fn score_rejects_non_subsequence() {
740 assert!(score("zzz", "main.rs").is_none());
741 assert!(score("xyz", "src/lib.rs").is_none());
742 }
743
744 #[test]
745 fn score_boundary_bonus_beats_substring() {
746 // "fp" matches the boundary letters in "file_picker.rs" but only the
747 // first letter in "filepicker.rs" — so the boundary candidate should
748 // win.
749 let boundary = score("fp", "src/file_picker.rs").unwrap();
750 let inline = score("fp", "src/filepicker.rs");
751 // inline doesn't even contain 'p' immediately following 'f'? It does:
752 // f-i-l-e-p-i-c-k-e-r — 'p' is preceded by 'e' (no boundary), so it
753 // gets only the +1 path score, while boundary gets +25 for the 'p'
754 // following the underscore.
755 if let Some(inline_score) = inline {
756 assert!(
757 boundary > inline_score,
758 "boundary={boundary} inline={inline_score}"
759 );
760 }
761 }
762
763 #[test]
764 fn score_case_insensitive() {
765 assert!(score("MAIN", "main.rs").is_some());
766 assert!(score("main", "MAIN.RS").is_some());
767 }
768
769 #[test]
770 fn score_empty_query_returns_zero() {
771 assert_eq!(score("", "anything").unwrap(), 0);
772 }
773
774 #[test]
775 fn picker_typing_narrows_candidates() {
776 let dir = TempDir::new().expect("tempdir");
777 let root = dir.path();
778 fs::create_dir_all(root.join("src")).unwrap();
779 fs::write(root.join("src/main.rs"), "").unwrap();
780 fs::write(root.join("src/lib.rs"), "").unwrap();
781 fs::write(root.join("README.md"), "").unwrap();
782 fs::write(root.join("Cargo.toml"), "").unwrap();
783
784 let mut view = FilePickerView::new_with_relevance(root, FilePickerRelevance::default());
785 // Empty query -> all 4 files visible.
786 assert_eq!(view.visible_count(), 4, "expected all 4 candidates");
787
788 // Typing "main" should narrow to just src/main.rs.
789 for ch in "main".chars() {
790 view.handle_key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
791 }
792 assert_eq!(view.query(), "main");
793 let visible = view.visible_count();
794 assert_eq!(visible, 1, "expected exactly 1 match for 'main'");
795 let selected = view.selected_for_test().expect("selected path");
796 assert!(selected.ends_with("main.rs"), "selected = {selected}");
797 }
798
799 #[test]
800 fn picker_empty_query_prioritizes_working_set_files() {
801 let dir = TempDir::new().expect("tempdir");
802 let root = dir.path();
803 fs::create_dir_all(root.join("src")).unwrap();
804 fs::write(root.join("src/main.rs"), "").unwrap();
805 fs::write(root.join("src/lib.rs"), "").unwrap();
806 fs::write(root.join("README.md"), "").unwrap();
807
808 let mut relevance = FilePickerRelevance::default();
809 relevance.mark_modified("src/lib.rs");
810 let view = FilePickerView::new_with_relevance(root, relevance);
811
812 assert_eq!(view.selected_for_test(), Some("src/lib.rs"));
813 assert_eq!(view.markers_for_test("src/lib.rs"), "M ");
814 }
815
816 #[test]
817 fn picker_fuzzy_query_keeps_working_set_boosts() {
818 let dir = TempDir::new().expect("tempdir");
819 let root = dir.path();
820 fs::create_dir_all(root.join("src")).unwrap();
821 fs::write(root.join("src/alpha.rs"), "").unwrap();
822 fs::write(root.join("src/zeta.rs"), "").unwrap();
823
824 let mut relevance = FilePickerRelevance::default();
825 relevance.mark_mentioned("src/zeta.rs");
826 relevance.mark_tool("src/zeta.rs");
827 let mut view = FilePickerView::new_with_relevance(root, relevance);
828 for ch in "rs".chars() {
829 view.handle_key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
830 }
831
832 assert_eq!(view.selected_for_test(), Some("src/zeta.rs"));
833 assert_eq!(view.markers_for_test("src/zeta.rs"), " @T");
834 }
835
836 #[test]
837 fn picker_backspace_widens_candidates() {
838 let dir = TempDir::new().expect("tempdir");
839 let root = dir.path();
840 fs::write(root.join("a.txt"), "").unwrap();
841 fs::write(root.join("b.txt"), "").unwrap();
842
843 let mut view = FilePickerView::new_with_relevance(root, FilePickerRelevance::default());
844 view.handle_key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE));
845 assert_eq!(view.visible_count(), 1);
846 view.handle_key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
847 assert_eq!(view.visible_count(), 2);
848 }
849
850 #[test]
851 fn picker_enter_emits_event() {
852 let dir = TempDir::new().expect("tempdir");
853 let root = dir.path();
854 fs::write(root.join("only.txt"), "").unwrap();
855
856 let mut view = FilePickerView::new_with_relevance(root, FilePickerRelevance::default());
857 let action = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
858 match action {
859 ViewAction::EmitAndClose(ViewEvent::FilePickerSelected { path }) => {
860 assert!(path.ends_with("only.txt"));
861 }
862 other => panic!("expected EmitAndClose(FilePickerSelected), got {other:?}"),
863 }
864 }
865
866 #[test]
867 fn picker_esc_closes_without_emit() {
868 let dir = TempDir::new().expect("tempdir");
869 let root = dir.path();
870 fs::write(root.join("only.txt"), "").unwrap();
871
872 let mut view = FilePickerView::new_with_relevance(root, FilePickerRelevance::default());
873 let action = view.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
874 assert!(matches!(action, ViewAction::Close));
875 }
876
877 #[test]
878 fn picker_honors_gitignore() {
879 let dir = TempDir::new().expect("tempdir");
880 let root = dir.path();
881 // .gitignore filtering only kicks in inside a git repo or with an
882 // explicit `.ignore` file. Use `.ignore` which `WalkBuilder` honors
883 // even outside of git.
884 fs::write(root.join(".ignore"), "skipme.txt\n").unwrap();
885 fs::write(root.join("keepme.txt"), "").unwrap();
886 fs::write(root.join("skipme.txt"), "").unwrap();
887
888 let view = FilePickerView::new_with_relevance(root, FilePickerRelevance::default());
889 let visible: Vec<_> = view
890 .filtered
891 .iter()
892 .map(|i| view.candidates[*i].as_str())
893 .collect();
894 assert!(visible.iter().any(|p| p.ends_with("keepme.txt")));
895 assert!(
896 !visible.iter().any(|p| p.ends_with("skipme.txt")),
897 "skipme.txt should be filtered by .ignore: {visible:?}"
898 );
899 }
900
901 #[test]
902 fn picker_finds_deeply_nested_files_within_walk_depth() {
903 // #2488: a file inside a 6-level-deep directory sits at component depth
904 // 7 and was excluded by the old depth-6 cap. The default depth (10) now
905 // reaches it, and `0` (unlimited) reaches arbitrarily deep files.
906 let dir = TempDir::new().expect("tempdir");
907 let root = dir.path();
908 let nested = root.join("a/b/c/d/e/f");
909 fs::create_dir_all(&nested).unwrap();
910 fs::write(nested.join("deep.rs"), "deep").unwrap();
911 let deeper = root.join("a/b/c/d/e/f/g/h/i/j/k");
912 fs::create_dir_all(&deeper).unwrap();
913 fs::write(deeper.join("very_deep.rs"), "deeper").unwrap();
914
915 // The old default (6) misses the depth-7 file — the reported bug.
916 let shallow = collect_candidates(root, Some(6));
917 assert!(
918 !shallow.iter().any(|p| p == "a/b/c/d/e/f/deep.rs"),
919 "depth-6 cap should miss the depth-7 file: {shallow:?}"
920 );
921
922 // The new default reaches files inside a 6-level-deep directory.
923 let default = collect_candidates(root, Some(WALK_DEPTH));
924 assert!(
925 default.iter().any(|p| p == "a/b/c/d/e/f/deep.rs"),
926 "default walk depth should reach depth-7 files: {default:?}"
927 );
928
929 // Unlimited (mention_walk_depth = 0) reaches arbitrarily deep files.
930 let unlimited = collect_candidates(root, None);
931 assert!(
932 unlimited
933 .iter()
934 .any(|p| p == "a/b/c/d/e/f/g/h/i/j/k/very_deep.rs"),
935 "unlimited walk should reach very deep files: {unlimited:?}"
936 );
937 }
938
939 #[test]
940 fn picker_skips_generated_worktree_bulk_inside_unignored_dot_dirs() {
941 let dir = TempDir::new().expect("tempdir");
942 let root = dir.path();
943 fs::create_dir_all(root.join("src")).unwrap();
944 fs::write(root.join("src/main.rs"), "fn main() {}").unwrap();
945
946 fs::create_dir_all(root.join(".deepseek/commands")).unwrap();
947 fs::write(root.join(".deepseek/commands/build.md"), "build").unwrap();
948 fs::create_dir_all(root.join(".deepseek/snapshots/deadbeef/.git/objects")).unwrap();
949 fs::write(
950 root.join(".deepseek/snapshots/deadbeef/.git/objects/snapshot.pack"),
951 "pack",
952 )
953 .unwrap();
954
955 fs::create_dir_all(root.join(".claude/commands")).unwrap();
956 fs::write(root.join(".claude/commands/test.md"), "test").unwrap();
957 fs::create_dir_all(root.join(".claude/worktrees/agent/src")).unwrap();
958 fs::write(
959 root.join(".claude/worktrees/agent/src/agent-only.md"),
960 "agent",
961 )
962 .unwrap();
963
964 let candidates = collect_candidates(root, Some(WALK_DEPTH));
965
966 assert!(candidates.iter().any(|path| path == "src/main.rs"));
967 assert!(
968 candidates
969 .iter()
970 .any(|path| path == ".deepseek/commands/build.md"),
971 "normal .deepseek command files should stay discoverable: {candidates:?}",
972 );
973 assert!(
974 candidates
975 .iter()
976 .any(|path| path == ".claude/commands/test.md"),
977 "normal .claude command files should stay discoverable: {candidates:?}",
978 );
979 assert!(
980 candidates
981 .iter()
982 .all(|path| !path.starts_with(".deepseek/snapshots/")),
983 "snapshot side repo files must not enter picker candidates: {candidates:?}",
984 );
985 assert!(
986 candidates
987 .iter()
988 .all(|path| !path.starts_with(".claude/worktrees/")),
989 ".claude worktree files must not enter picker candidates: {candidates:?}",
990 );
991 }
992
993 /// The four terminal sizes the v0.8.66 modal blocker (#3732) requires
994 /// every overlay to remain readable and fully operable at.
995 const BLOCKER_SIZES: [(u16, u16); 4] = [(80, 24), (100, 30), (120, 32), (160, 40)];
996
997 #[test]
998 fn file_picker_is_usable_and_opaque_at_blocker_sizes() {
999 use crate::tui::views::ViewStack;
1000 use ratatui::{buffer::Buffer, layout::Rect};
1001 use unicode_width::UnicodeWidthStr;
1002
1003 let dir = TempDir::new().expect("tempdir");
1004 let root = dir.path();
1005 fs::create_dir_all(root.join("src")).unwrap();
1006 fs::write(root.join("src/main.rs"), "").unwrap();
1007 fs::write(root.join("src/lib.rs"), "").unwrap();
1008 fs::write(root.join("README.md"), "").unwrap();
1009
1010 for (w, h) in BLOCKER_SIZES {
1011 let area = Rect::new(0, 0, w, h);
1012 let mut buf = Buffer::empty(area);
1013 for y in 0..h {
1014 for x in 0..w {
1015 buf[(x, y)].set_symbol("X");
1016 }
1017 }
1018 let mut stack = ViewStack::new();
1019 stack.push(FilePickerView::new_with_relevance(
1020 root,
1021 FilePickerRelevance::default(),
1022 ));
1023 stack.render(area, &mut buf);
1024
1025 let rows: Vec<String> = (0..h)
1026 .map(|y| {
1027 (0..w)
1028 .map(|x| buf[(x, y)].symbol().to_string())
1029 .collect::<String>()
1030 })
1031 .collect();
1032 let text = rows.join("\n");
1033
1034 for label in ["move", "insert @path", "cancel"] {
1035 assert!(text.contains(label), "{w}x{h}: missing footer '{label}'");
1036 }
1037 assert!(
1038 !text.contains('X'),
1039 "{w}x{h}: background bleed-through into modal surface"
1040 );
1041 assert_eq!(
1042 buf[(w / 2, h / 2)].bg,
1043 palette::WHALE_BG,
1044 "{w}x{h}: modal interior must be opaque"
1045 );
1046 for (y, row) in rows.iter().enumerate() {
1047 assert!(
1048 UnicodeWidthStr::width(row.trim_end()) <= w as usize,
1049 "{w}x{h}: row {y} overflows width: {row:?}"
1050 );
1051 }
1052 }
1053 }
1054
1055 /// #3905: opening the picker used to block the event loop on a `git status`
1056 /// subprocess plus a walk of up to MAX_CANDIDATES paths, freezing the whole
1057 /// TUI between Ctrl+P and the picker appearing.
1058 ///
1059 /// Asserting "fast" by wall clock would be a flaky proxy for the real
1060 /// contract, so this asserts the structural property instead: inside a
1061 /// runtime the constructor returns a paintable view that has not yet done
1062 /// the scan, and the results arrive later through `tick`.
1063 #[tokio::test]
1064 async fn opening_the_picker_does_not_block_on_the_workspace_scan() {
1065 let ws = TempDir::new().unwrap();
1066 fs::create_dir_all(ws.path().join("src")).unwrap();
1067 for i in 0..200 {
1068 fs::write(ws.path().join("src").join(format!("f{i}.rs")), "x").unwrap();
1069 }
1070
1071 let mut view = FilePickerView::new_with_relevance_and_depth(
1072 ws.path(),
1073 FilePickerRelevance::default(),
1074 WALK_DEPTH,
1075 Locale::En,
1076 );
1077
1078 assert!(
1079 view.is_loading,
1080 "the constructor must hand back a paintable view, not a finished scan"
1081 );
1082 assert!(
1083 view.candidates.is_empty(),
1084 "no walk may have run on the calling thread"
1085 );
1086
1087 // The view is renderable in the loading state — this is the frame the
1088 // user sees immediately after Ctrl+P.
1089 let area = Rect::new(0, 0, 60, 20);
1090 let mut buf = Buffer::empty(area);
1091 view.render(area, &mut buf);
1092
1093 for _ in 0..500 {
1094 view.tick();
1095 if !view.is_loading {
1096 break;
1097 }
1098 tokio::time::sleep(Duration::from_millis(5)).await;
1099 }
1100
1101 assert!(!view.is_loading, "the background scan must land via tick");
1102 assert_eq!(
1103 view.candidates.len(),
1104 200,
1105 "every workspace file is discovered once the scan lands"
1106 );
1107 assert_eq!(
1108 view.filtered.len(),
1109 200,
1110 "results are refiltered after the scan, not left empty"
1111 );
1112 }
1113
1114 /// A query typed while the scan was still running must survive it.
1115 #[tokio::test]
1116 async fn a_query_typed_during_the_scan_is_applied_when_results_land() {
1117 let ws = TempDir::new().unwrap();
1118 fs::write(ws.path().join("alpha.rs"), "x").unwrap();
1119 fs::write(ws.path().join("beta.rs"), "x").unwrap();
1120
1121 let mut view = FilePickerView::new_with_relevance_and_depth(
1122 ws.path(),
1123 FilePickerRelevance::default(),
1124 WALK_DEPTH,
1125 Locale::En,
1126 );
1127 assert!(view.is_loading);
1128
1129 for ch in "alpha".chars() {
1130 view.handle_key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
1131 }
1132
1133 for _ in 0..500 {
1134 view.tick();
1135 if !view.is_loading {
1136 break;
1137 }
1138 tokio::time::sleep(Duration::from_millis(5)).await;
1139 }
1140
1141 assert!(!view.is_loading);
1142 assert_eq!(view.query, "alpha");
1143 let matched: Vec<&str> = view
1144 .filtered
1145 .iter()
1146 .map(|i| view.candidates[*i].as_str())
1147 .collect();
1148 assert_eq!(
1149 matched,
1150 vec!["alpha.rs"],
1151 "the scan must refilter against the query the user already typed"
1152 );
1153 }
1154 }
1155
1155 lines RUST