| 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 |