返回 DeepSeek-TUI-2026
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 6, hidden=true, follow_links=false,
5 //! `.gitignore` honored). Subsequent keystrokes filter the cached candidate
6 //! list in memory using a small subsequence + first-letter-bonus scorer — no
7 //! per-keystroke disk traversal.
8 //!
9 //! Enter emits a [`ViewEvent::FilePickerSelected`] which the UI handler turns
10 //! into an `@<path>` insertion at the composer cursor.
11
12 use std::collections::HashSet;
13 use std::path::Path;
14
15 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
16 use ignore::WalkBuilder;
17 use ratatui::{
18 buffer::Buffer,
19 layout::Rect,
20 prelude::Stylize,
21 style::{Modifier, Style},
22 text::{Line, Span},
23 widgets::{Block, Borders, Clear, Padding, Paragraph, Widget},
24 };
25
26 use crate::palette;
27 use crate::tui::views::{ModalKind, ModalView, ViewAction, ViewEvent};
28
29 /// Maximum number of candidates collected from the initial walk. Keeps memory
30 /// bounded for very large monorepos; matches the limits codex-rs uses for the
31 /// equivalent overlay.
32 const MAX_CANDIDATES: usize = 20_000;
33
34 /// Walk depth for the initial scan. Mirrors the `Workspace` fuzzy index.
35 const WALK_DEPTH: usize = 6;
36
37 /// Visible candidate rows in the overlay.
38 const VISIBLE_ROWS: usize = 14;
39
40 const MODIFIED_BOOST: i32 = 360;
41 const MENTIONED_BOOST: i32 = 240;
42 const TOOL_BOOST: i32 = 160;
43
44 /// Working-set hints captured when the picker opens.
45 ///
46 /// The picker keeps this as plain path strings so filtering stays in-memory and
47 /// per-keystroke work remains the same shape as the original fuzzy search.
48 #[derive(Debug, Clone, Default, PartialEq, Eq)]
49 pub struct FilePickerRelevance {
50 modified: HashSet<String>,
51 mentioned: HashSet<String>,
52 tool: HashSet<String>,
53 }
54
55 impl FilePickerRelevance {
56 pub fn mark_modified(&mut self, path: impl Into<String>) {
57 let path = path.into();
58 if !path.is_empty() {
59 self.modified.insert(path);
60 }
61 }
62
63 pub fn mark_mentioned(&mut self, path: impl Into<String>) {
64 let path = path.into();
65 if !path.is_empty() {
66 self.mentioned.insert(path);
67 }
68 }
69
70 pub fn mark_tool(&mut self, path: impl Into<String>) {
71 let path = path.into();
72 if !path.is_empty() {
73 self.tool.insert(path);
74 }
75 }
76
77 fn boost_for(&self, path: &str) -> i32 {
78 let mut boost = 0;
79 if self.modified.contains(path) {
80 boost += MODIFIED_BOOST;
81 }
82 if self.mentioned.contains(path) {
83 boost += MENTIONED_BOOST;
84 }
85 if self.tool.contains(path) {
86 boost += TOOL_BOOST;
87 }
88 boost
89 }
90
91 fn markers_for(&self, path: &str) -> String {
92 let mut markers = String::with_capacity(3);
93 markers.push(if self.modified.contains(path) {
94 'M'
95 } else {
96 ' '
97 });
98 markers.push(if self.mentioned.contains(path) {
99 '@'
100 } else {
101 ' '
102 });
103 markers.push(if self.tool.contains(path) { 'T' } else { ' ' });
104 markers
105 }
106 }
107
108 pub struct FilePickerView {
109 /// All workspace-relative candidate paths, captured once at construction.
110 candidates: Vec<String>,
111 /// Working-set relevance hints, captured once at construction.
112 relevance: FilePickerRelevance,
113 /// Filtered indices into `candidates`, sorted by descending score.
114 filtered: Vec<usize>,
115 /// User's typed query (lowercased on each refilter).
116 query: String,
117 /// Selected row within `filtered`.
118 selected: usize,
119 /// Top of the visible window within `filtered`.
120 scroll: usize,
121 }
122
123 impl FilePickerView {
124 /// Build a picker with working-set relevance hints.
125 pub fn new_with_relevance(workspace_root: &Path, relevance: FilePickerRelevance) -> Self {
126 let candidates = collect_candidates(workspace_root);
127 let mut view = Self {
128 candidates,
129 relevance,
130 filtered: Vec::new(),
131 query: String::new(),
132 selected: 0,
133 scroll: 0,
134 };
135 view.refilter();
136 view
137 }
138
139 fn refilter(&mut self) {
140 let query = self.query.trim().to_lowercase();
141 let mut scored: Vec<(usize, i32, i32, i32)> = if query.is_empty() {
142 self.candidates
143 .iter()
144 .enumerate()
145 .map(|(idx, path)| {
146 let boost = self.relevance.boost_for(path);
147 (idx, boost, 0, boost)
148 })
149 .collect()
150 } else {
151 self.candidates
152 .iter()
153 .enumerate()
154 .filter_map(|(idx, path)| {
155 score(&query, path).map(|fuzzy| {
156 let boost = self.relevance.boost_for(path);
157 (idx, fuzzy + boost, fuzzy, boost)
158 })
159 })
160 .collect()
161 };
162
163 // Higher scores first; tie-break by ascending path length, then lex order
164 // so shorter / more central matches surface above deep nested ones.
165 scored.sort_by(|a, b| {
166 b.1.cmp(&a.1)
167 .then_with(|| b.2.cmp(&a.2))
168 .then_with(|| b.3.cmp(&a.3))
169 .then_with(|| self.candidates[a.0].len().cmp(&self.candidates[b.0].len()))
170 .then_with(|| self.candidates[a.0].cmp(&self.candidates[b.0]))
171 });
172
173 self.filtered = scored.into_iter().map(|(idx, _, _, _)| idx).collect();
174 if self.filtered.is_empty() {
175 self.selected = 0;
176 self.scroll = 0;
177 } else if self.selected >= self.filtered.len() {
178 self.selected = self.filtered.len() - 1;
179 }
180 self.adjust_scroll();
181 }
182
183 fn adjust_scroll(&mut self) {
184 if self.filtered.is_empty() {
185 self.scroll = 0;
186 return;
187 }
188 if self.selected < self.scroll {
189 self.scroll = self.selected;
190 } else if self.selected >= self.scroll + VISIBLE_ROWS {
191 self.scroll = self.selected + 1 - VISIBLE_ROWS;
192 }
193 }
194
195 fn move_selection(&mut self, delta: isize) {
196 if self.filtered.is_empty() {
197 return;
198 }
199 let max = self.filtered.len() - 1;
200 let next = if delta.is_negative() {
201 self.selected.saturating_sub(delta.unsigned_abs())
202 } else {
203 (self.selected + delta as usize).min(max)
204 };
205 self.selected = next;
206 self.adjust_scroll();
207 }
208
209 fn selected_path(&self) -> Option<&str> {
210 let idx = *self.filtered.get(self.selected)?;
211 self.candidates.get(idx).map(String::as_str)
212 }
213
214 /// Visible candidate count for tests / diagnostics.
215 #[cfg(test)]
216 pub fn visible_count(&self) -> usize {
217 self.filtered.len()
218 }
219
220 #[cfg(test)]
221 pub fn query(&self) -> &str {
222 &self.query
223 }
224
225 #[cfg(test)]
226 pub fn selected_for_test(&self) -> Option<&str> {
227 self.selected_path()
228 }
229
230 #[cfg(test)]
231 pub fn markers_for_test(&self, path: &str) -> String {
232 self.relevance.markers_for(path)
233 }
234 }
235
236 impl ModalView for FilePickerView {
237 fn kind(&self) -> ModalKind {
238 ModalKind::FilePicker
239 }
240
241 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
242 self
243 }
244
245 fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
246 match key.code {
247 KeyCode::Esc => ViewAction::Close,
248 KeyCode::Enter => {
249 if let Some(path) = self.selected_path() {
250 let path = path.to_string();
251 return ViewAction::EmitAndClose(ViewEvent::FilePickerSelected { path });
252 }
253 ViewAction::Close
254 }
255 KeyCode::Up => {
256 self.move_selection(-1);
257 ViewAction::None
258 }
259 KeyCode::Down => {
260 self.move_selection(1);
261 ViewAction::None
262 }
263 KeyCode::PageUp => {
264 self.move_selection(-(VISIBLE_ROWS as isize));
265 ViewAction::None
266 }
267 KeyCode::PageDown => {
268 self.move_selection(VISIBLE_ROWS as isize);
269 ViewAction::None
270 }
271 KeyCode::Backspace => {
272 self.query.pop();
273 self.selected = 0;
274 self.scroll = 0;
275 self.refilter();
276 ViewAction::None
277 }
278 KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
279 self.query.clear();
280 self.selected = 0;
281 self.scroll = 0;
282 self.refilter();
283 ViewAction::None
284 }
285 KeyCode::Char(ch)
286 if !key.modifiers.contains(KeyModifiers::CONTROL)
287 && !key.modifiers.contains(KeyModifiers::ALT)
288 && !ch.is_control() =>
289 {
290 self.query.push(ch);
291 self.selected = 0;
292 self.scroll = 0;
293 self.refilter();
294 ViewAction::None
295 }
296 _ => ViewAction::None,
297 }
298 }
299
300 fn render(&self, area: Rect, buf: &mut Buffer) {
301 let popup_width = 80.min(area.width.saturating_sub(4));
302 let popup_height = ((VISIBLE_ROWS as u16) + 6).min(area.height.saturating_sub(4));
303
304 let popup_area = Rect {
305 x: area.x + (area.width.saturating_sub(popup_width)) / 2,
306 y: area.y + (area.height.saturating_sub(popup_height)) / 2,
307 width: popup_width,
308 height: popup_height,
309 };
310
311 Clear.render(popup_area, buf);
312
313 let title = Line::from(vec![Span::styled(
314 " File Picker ",
315 Style::default()
316 .fg(palette::DEEPSEEK_BLUE)
317 .add_modifier(Modifier::BOLD),
318 )]);
319 let footer_text = format!(
320 " {} match{} ↑/↓ select Enter insert @path Esc close ",
321 self.filtered.len(),
322 if self.filtered.len() == 1 { "" } else { "es" },
323 );
324 let block = Block::default()
325 .title(title)
326 .title_bottom(Line::from(Span::styled(
327 footer_text,
328 Style::default().fg(palette::TEXT_MUTED),
329 )))
330 .borders(Borders::ALL)
331 .border_style(Style::default().fg(palette::BORDER_COLOR))
332 .style(Style::default().bg(palette::DEEPSEEK_INK))
333 .padding(Padding::uniform(1));
334
335 let inner = block.inner(popup_area);
336 block.render(popup_area, buf);
337
338 let mut lines: Vec<Line<'static>> = Vec::new();
339 // Query line.
340 lines.push(Line::from(vec![
341 Span::styled("> ", Style::default().fg(palette::DEEPSEEK_SKY).bold()),
342 Span::raw(self.query.clone()),
343 Span::styled(
344 " ",
345 Style::default()
346 .fg(palette::DEEPSEEK_INK)
347 .bg(palette::DEEPSEEK_SKY),
348 ),
349 ]));
350 lines.push(Line::from(""));
351
352 let visible = VISIBLE_ROWS.min(inner.height.saturating_sub(2) as usize);
353 let end = (self.scroll + visible).min(self.filtered.len());
354 if self.filtered.is_empty() {
355 lines.push(Line::from(Span::styled(
356 " No matches",
357 Style::default().fg(palette::TEXT_MUTED),
358 )));
359 } else {
360 for idx in self.scroll..end {
361 let path = &self.candidates[self.filtered[idx]];
362 let selected = idx == self.selected;
363 let style = if selected {
364 Style::default()
365 .fg(palette::SELECTION_TEXT)
366 .bg(palette::SELECTION_BG)
367 } else {
368 Style::default().fg(palette::TEXT_PRIMARY)
369 };
370 let prefix = if selected { "▶ " } else { " " };
371 let marker_field = if inner.width >= 18 {
372 format!("{} ", self.relevance.markers_for(path))
373 } else {
374 String::new()
375 };
376 let reserved = prefix.chars().count() + marker_field.chars().count();
377 let display = truncate_path(path, (inner.width as usize).saturating_sub(reserved));
378 let mut line = Line::from(format!("{prefix}{marker_field}{display}"));
379 line.style = style;
380 lines.push(line);
381 }
382 }
383
384 Paragraph::new(lines)
385 .style(Style::default().fg(palette::TEXT_PRIMARY))
386 .render(inner, buf);
387 }
388 }
389
390 fn truncate_path(path: &str, max: usize) -> String {
391 if max == 0 {
392 return String::new();
393 }
394 if path.chars().count() <= max {
395 return path.to_string();
396 }
397 let take = max.saturating_sub(1);
398 let truncated: String = path
399 .chars()
400 .rev()
401 .take(take)
402 .collect::<Vec<_>>()
403 .into_iter()
404 .rev()
405 .collect();
406 format!("…{truncated}")
407 }
408
409 /// Single-pass walk that collects workspace-relative paths.
410 fn collect_candidates(root: &Path) -> Vec<String> {
411 let mut builder = WalkBuilder::new(root);
412 builder
413 .hidden(true)
414 .follow_links(false)
415 .max_depth(Some(WALK_DEPTH))
416 .git_ignore(true)
417 .git_exclude(true)
418 .git_global(true);
419
420 let mut out: Vec<String> = Vec::new();
421 for entry in builder.build().flatten() {
422 if !entry.file_type().is_some_and(|ft| ft.is_file()) {
423 continue;
424 }
425 let path = entry.path();
426 let rel = path.strip_prefix(root).unwrap_or(path);
427 if rel.as_os_str().is_empty() {
428 continue;
429 }
430 let display = path_to_workspace_string(rel);
431 if !display.is_empty() {
432 out.push(display);
433 }
434 if out.len() >= MAX_CANDIDATES {
435 break;
436 }
437 }
438 out.sort();
439 out
440 }
441
442 fn path_to_workspace_string(path: &Path) -> String {
443 // Use forward-slash separators for cross-platform display, matching how
444 // @-mentions are spelled in the composer.
445 let mut out = String::new();
446 for (idx, comp) in path.components().enumerate() {
447 if idx > 0 {
448 out.push('/');
449 }
450 out.push_str(&comp.as_os_str().to_string_lossy());
451 }
452 out
453 }
454
455 /// Subsequence scorer with first-letter and boundary bonuses.
456 ///
457 /// Returns `None` if `query` is not a subsequence of `path` (case-insensitive),
458 /// otherwise a positive score where higher is better.
459 ///
460 /// Heuristics (kept deliberately small and predictable):
461 /// * +25 for each match that lands at the start of the path or right after a
462 /// boundary character (`/`, `_`, `-`, `.`, ` `).
463 /// * +10 if the very first character of the query matches the first character
464 /// of the path.
465 /// * +5 per consecutive match (rewards contiguous runs like typing "main" and
466 /// matching `main.rs`).
467 /// * Penalty proportional to the gap between consecutive matches keeps tightly
468 /// matched candidates above scattered ones.
469 pub fn score(query: &str, path: &str) -> Option<i32> {
470 if query.is_empty() {
471 return Some(0);
472 }
473 let q: Vec<char> = query.chars().flat_map(char::to_lowercase).collect();
474 let p: Vec<char> = path.chars().flat_map(char::to_lowercase).collect();
475 if q.len() > p.len() {
476 return None;
477 }
478
479 let mut qi = 0usize;
480 let mut score: i32 = 0;
481 let mut last_match: Option<usize> = None;
482 let mut consecutive = 0i32;
483
484 for (i, ch) in p.iter().enumerate() {
485 if qi >= q.len() {
486 break;
487 }
488 if *ch == q[qi] {
489 // Boundary / start bonus.
490 if i == 0 {
491 score += 25;
492 if qi == 0 {
493 score += 10;
494 }
495 } else if matches!(p[i - 1], '/' | '_' | '-' | '.' | ' ') {
496 score += 25;
497 } else {
498 score += 1;
499 }
500
501 // Consecutive bonus.
502 if last_match == Some(i.saturating_sub(1)) {
503 consecutive += 1;
504 score += 5 * consecutive;
505 } else {
506 consecutive = 0;
507 }
508
509 // Gap penalty.
510 if let Some(prev) = last_match {
511 let gap = i - prev - 1;
512 score -= gap as i32;
513 }
514
515 last_match = Some(i);
516 qi += 1;
517 }
518 }
519
520 if qi == q.len() { Some(score) } else { None }
521 }
522
523 #[cfg(test)]
524 mod tests {
525 use super::*;
526 use std::fs;
527 use tempfile::TempDir;
528
529 #[test]
530 fn score_subsequence_match() {
531 // Identical query matches start with high bonus.
532 let a = score("main", "main.rs").unwrap();
533 let b = score("main", "src/very/deep/main.rs").unwrap();
534 assert!(a > b, "a={} b={}", a, b);
535 }
536
537 #[test]
538 fn score_rejects_non_subsequence() {
539 assert!(score("zzz", "main.rs").is_none());
540 assert!(score("xyz", "src/lib.rs").is_none());
541 }
542
543 #[test]
544 fn score_boundary_bonus_beats_substring() {
545 // "fp" matches the boundary letters in "file_picker.rs" but only the
546 // first letter in "filepicker.rs" — so the boundary candidate should
547 // win.
548 let boundary = score("fp", "src/file_picker.rs").unwrap();
549 let inline = score("fp", "src/filepicker.rs");
550 // inline doesn't even contain 'p' immediately following 'f'? It does:
551 // f-i-l-e-p-i-c-k-e-r — 'p' is preceded by 'e' (no boundary), so it
552 // gets only the +1 path score, while boundary gets +25 for the 'p'
553 // following the underscore.
554 if let Some(inline_score) = inline {
555 assert!(
556 boundary > inline_score,
557 "boundary={} inline={}",
558 boundary,
559 inline_score
560 );
561 }
562 }
563
564 #[test]
565 fn score_case_insensitive() {
566 assert!(score("MAIN", "main.rs").is_some());
567 assert!(score("main", "MAIN.RS").is_some());
568 }
569
570 #[test]
571 fn score_empty_query_returns_zero() {
572 assert_eq!(score("", "anything").unwrap(), 0);
573 }
574
575 #[test]
576 fn picker_typing_narrows_candidates() {
577 let dir = TempDir::new().expect("tempdir");
578 let root = dir.path();
579 fs::create_dir_all(root.join("src")).unwrap();
580 fs::write(root.join("src/main.rs"), "").unwrap();
581 fs::write(root.join("src/lib.rs"), "").unwrap();
582 fs::write(root.join("README.md"), "").unwrap();
583 fs::write(root.join("Cargo.toml"), "").unwrap();
584
585 let mut view = FilePickerView::new_with_relevance(root, FilePickerRelevance::default());
586 // Empty query -> all 4 files visible.
587 assert_eq!(view.visible_count(), 4, "expected all 4 candidates");
588
589 // Typing "main" should narrow to just src/main.rs.
590 for ch in "main".chars() {
591 view.handle_key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
592 }
593 assert_eq!(view.query(), "main");
594 let visible = view.visible_count();
595 assert_eq!(visible, 1, "expected exactly 1 match for 'main'");
596 let selected = view.selected_for_test().expect("selected path");
597 assert!(selected.ends_with("main.rs"), "selected = {selected}");
598 }
599
600 #[test]
601 fn picker_empty_query_prioritizes_working_set_files() {
602 let dir = TempDir::new().expect("tempdir");
603 let root = dir.path();
604 fs::create_dir_all(root.join("src")).unwrap();
605 fs::write(root.join("src/main.rs"), "").unwrap();
606 fs::write(root.join("src/lib.rs"), "").unwrap();
607 fs::write(root.join("README.md"), "").unwrap();
608
609 let mut relevance = FilePickerRelevance::default();
610 relevance.mark_modified("src/lib.rs");
611 let view = FilePickerView::new_with_relevance(root, relevance);
612
613 assert_eq!(view.selected_for_test(), Some("src/lib.rs"));
614 assert_eq!(view.markers_for_test("src/lib.rs"), "M ");
615 }
616
617 #[test]
618 fn picker_fuzzy_query_keeps_working_set_boosts() {
619 let dir = TempDir::new().expect("tempdir");
620 let root = dir.path();
621 fs::create_dir_all(root.join("src")).unwrap();
622 fs::write(root.join("src/alpha.rs"), "").unwrap();
623 fs::write(root.join("src/zeta.rs"), "").unwrap();
624
625 let mut relevance = FilePickerRelevance::default();
626 relevance.mark_mentioned("src/zeta.rs");
627 relevance.mark_tool("src/zeta.rs");
628 let mut view = FilePickerView::new_with_relevance(root, relevance);
629 for ch in "rs".chars() {
630 view.handle_key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
631 }
632
633 assert_eq!(view.selected_for_test(), Some("src/zeta.rs"));
634 assert_eq!(view.markers_for_test("src/zeta.rs"), " @T");
635 }
636
637 #[test]
638 fn picker_backspace_widens_candidates() {
639 let dir = TempDir::new().expect("tempdir");
640 let root = dir.path();
641 fs::write(root.join("a.txt"), "").unwrap();
642 fs::write(root.join("b.txt"), "").unwrap();
643
644 let mut view = FilePickerView::new_with_relevance(root, FilePickerRelevance::default());
645 view.handle_key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE));
646 assert_eq!(view.visible_count(), 1);
647 view.handle_key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
648 assert_eq!(view.visible_count(), 2);
649 }
650
651 #[test]
652 fn picker_enter_emits_event() {
653 let dir = TempDir::new().expect("tempdir");
654 let root = dir.path();
655 fs::write(root.join("only.txt"), "").unwrap();
656
657 let mut view = FilePickerView::new_with_relevance(root, FilePickerRelevance::default());
658 let action = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
659 match action {
660 ViewAction::EmitAndClose(ViewEvent::FilePickerSelected { path }) => {
661 assert!(path.ends_with("only.txt"));
662 }
663 other => panic!("expected EmitAndClose(FilePickerSelected), got {other:?}"),
664 }
665 }
666
667 #[test]
668 fn picker_esc_closes_without_emit() {
669 let dir = TempDir::new().expect("tempdir");
670 let root = dir.path();
671 fs::write(root.join("only.txt"), "").unwrap();
672
673 let mut view = FilePickerView::new_with_relevance(root, FilePickerRelevance::default());
674 let action = view.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
675 assert!(matches!(action, ViewAction::Close));
676 }
677
678 #[test]
679 fn picker_honors_gitignore() {
680 let dir = TempDir::new().expect("tempdir");
681 let root = dir.path();
682 // .gitignore filtering only kicks in inside a git repo or with an
683 // explicit `.ignore` file. Use `.ignore` which `WalkBuilder` honors
684 // even outside of git.
685 fs::write(root.join(".ignore"), "skipme.txt\n").unwrap();
686 fs::write(root.join("keepme.txt"), "").unwrap();
687 fs::write(root.join("skipme.txt"), "").unwrap();
688
689 let view = FilePickerView::new_with_relevance(root, FilePickerRelevance::default());
690 let visible: Vec<_> = view
691 .filtered
692 .iter()
693 .map(|i| view.candidates[*i].as_str())
694 .collect();
695 assert!(visible.iter().any(|p| p.ends_with("keepme.txt")));
696 assert!(
697 !visible.iter().any(|p| p.ends_with("skipme.txt")),
698 "skipme.txt should be filtered by .ignore: {visible:?}"
699 );
700 }
701 }
702
702 lines RUST