Skip to main content

oxicode/tui_vt/
file_search.rs

1//! @-triggered fuzzy file picker for the composer (grok-build parity).
2//!
3//! Detects `@` at a word boundary in the input buffer, opens a fuzzy
4//! file-search dropdown above the composer, and inserts the selected
5//! path (optionally with a line range) back into the buffer as plain text.
6//!
7//! Design (Option B from the plan): the input buffer stays a plain `String`.
8//! `@path:N-M` references are inserted as text; the agent's `read` tool
9//! already parses path + line-range from text. No chip model, no forked
10//! TextArea — the dropdown is the only new UI surface.
11
12use std::path::Path;
13
14use nucleo::pattern::{AtomKind, CaseMatching, Normalization, Pattern};
15use nucleo::{Matcher, Utf32Str};
16
17/// Maximum number of files to index from the workspace.
18const MAX_FILES: usize = 5000;
19/// Maximum number of results to show in the dropdown.
20pub const MAX_RESULTS: usize = 20;
21/// Maximum number of root-level files shown when the query is empty.
22const MAX_EMPTY_QUERY: usize = 12;
23
24/// One fuzzy-matched file result.
25#[derive(Clone, Debug, PartialEq, Eq)]
26pub struct FileSearchResult {
27    /// Workspace-relative path.
28    pub path: String,
29    /// Fuzzy match score (higher = better). 0 when unranked (empty query).
30    pub score: u32,
31}
32
33/// State for the active file-search dropdown. Stored in
34/// `RenderState.file_search` while the picker is open.
35#[derive(Clone, Debug)]
36pub struct FileSearchState {
37    /// The query text typed after `@` (excluding `@` and any `!` prefix).
38    pub query: String,
39    /// Byte offset of the `@` trigger in the input buffer.
40    pub at_offset: usize,
41    /// Whether hidden files are included in the results.
42    pub hidden_mode: bool,
43    /// Current ranked results.
44    pub results: Vec<FileSearchResult>,
45    /// Currently-selected result index (wraps on navigation).
46    pub selected: usize,
47    /// Cached workspace file list. Built once on open, re-filtered per query.
48    pub index: Vec<String>,
49    /// Whether the line-range sub-mode is active (user typed `:` or `Ctrl+L`).
50    pub line_mode: bool,
51}
52
53/// The parsed `@`-reference at the cursor position, if any. Returned by
54/// [`parse_at_cursor`] — a pure function with no I/O, fully unit-testable.
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct AtToken {
57    /// Byte offset of the `@` character in the buffer.
58    pub at_offset: usize,
59    /// The path portion of the query (after `@`/`@!`, before `:` or end).
60    pub path_query: String,
61    /// Parsed line range if the user typed `:N` or `:N-M`.
62    pub line_range: Option<(usize, usize)>,
63    /// Whether the user typed `@!` (hidden-file toggle requested).
64    pub hidden_request: bool,
65}
66
67/// Detect an `@`-file-reference trigger at the cursor position.
68///
69/// # Rules (grok-build parity)
70/// - `@` must be at a word boundary: preceded by whitespace, start of
71///   buffer, or a non-alphanumeric delimiter.
72/// - **Email guard**: `@` preceded by alphanumeric or `_` is NOT a trigger
73///   (`foo@bar.com`, `user_name@host`).
74/// - `@!` toggles hidden-file display (the `!` is consumed).
75/// - `path:N` or `path:N-M` parses a line range.
76/// - Any whitespace in the text after `@` closes the token (no trigger).
77///
78/// Returns `None` when no trigger is active.
79pub fn parse_at_cursor(buffer: &str, cursor: usize) -> Option<AtToken> {
80    let cursor = cursor.min(buffer.len());
81    let before = &buffer[..cursor];
82
83    // Find the nearest `@` at or before the cursor.
84    let at_rel = before.rfind('@')?;
85    let at_offset = at_rel;
86
87    // Word-boundary / email guard: the char immediately before `@` must be
88    // whitespace, start of buffer, or a non-identifier delimiter.
89    if at_offset > 0 {
90        let prev = before[..at_offset].chars().last().unwrap_or(' ');
91        if prev.is_alphanumeric() || prev == '_' {
92            return None;
93        }
94    }
95
96    // The token extends from after `@` to the cursor. Any whitespace
97    // closes the token — once closed, it's no longer an active trigger.
98    let after_at = &buffer[at_offset + 1..cursor];
99    if after_at.chars().any(|c| c.is_whitespace()) {
100        return None;
101    }
102
103    // `@!` — hidden mode request. The `!` is consumed by the toggle.
104    let (hidden_request, rest) = if let Some(stripped) = after_at.strip_prefix('!') {
105        (true, stripped)
106    } else {
107        (false, after_at)
108    };
109
110    // Split `path:line-range`.
111    let (path_query, line_range) = split_path_and_range(rest);
112
113    Some(AtToken {
114        at_offset,
115        path_query,
116        line_range,
117        hidden_request,
118    })
119}
120
121/// Split `path:N` or `path:N-M` into `(path, Some((start, end)))`.
122/// A path with no colon returns `(path, None)`.
123fn split_path_and_range(rest: &str) -> (String, Option<(usize, usize)>) {
124    // The line-range colon is the LAST colon in the token — a path like
125    // `C:\foo` on Windows or `a:b.rs` (unlikely) should not split here.
126    // For Unix paths we split on the first colon after the last separator,
127    // but practically: the range colon is followed only by digits/dash.
128    if let Some(colon) = rest.rfind(':')
129        && rest[colon + 1..]
130            .chars()
131            .all(|c| c.is_ascii_digit() || c == '-')
132    {
133        let path_part = &rest[..colon];
134        let range_part = &rest[colon + 1..];
135        // Only split when the range actually parses. An invalid range
136        // (`:0`, `:25-10`) means the colon is part of the path — keep it.
137        if let Some(range) = parse_line_range(range_part) {
138            return (path_part.to_string(), Some(range));
139        }
140    }
141    (rest.to_string(), None)
142}
143
144/// Parse `N` or `N-M` into `(start, end)`. Returns `None` on malformed input.
145fn parse_line_range(s: &str) -> Option<(usize, usize)> {
146    if s.is_empty() {
147        return None;
148    }
149    if let Some(dash) = s.find('-') {
150        let start: usize = s[..dash].parse().ok()?;
151        let end: usize = s[dash + 1..].parse().ok()?;
152        (start > 0 && end >= start).then_some((start, end))
153    } else {
154        let n: usize = s.parse().ok()?;
155        (n > 0).then_some((n, n))
156    }
157}
158
159/// Walk the workspace and build a file index. Respects `.gitignore`,
160/// `.git/info/exclude`, and the global gitignore. Hidden files (dotfiles)
161/// are excluded by default and toggled on via the `@!` gesture.
162///
163/// Caps at `MAX_FILES` entries to bound latency on large repos.
164pub fn build_index(cwd: &Path, hidden: bool) -> Vec<String> {
165    let mut files = Vec::new();
166    let mut builder = ignore::WalkBuilder::new(cwd);
167    builder
168        .hidden(!hidden) // skip dotfiles when !hidden
169        .git_ignore(true)
170        .git_global(true)
171        .git_exclude(true)
172        .ignore(true)
173        .parents(true)
174        .threads(2);
175
176    // Skip the cwd itself and common large/build dirs even when not
177    // gitignored, to keep the index snappy on monorepos.
178    let walker = builder.build();
179    for entry in walker.flatten() {
180        if files.len() >= MAX_FILES {
181            break;
182        }
183        if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
184            continue;
185        }
186        if let Ok(rel) = entry.path().strip_prefix(cwd) {
187            // Skip the git internals even when hidden mode is on.
188            if rel.starts_with(".git") {
189                continue;
190            }
191            if let Some(s) = rel.to_str() {
192                files.push(s.to_string());
193            }
194        }
195    }
196    files
197}
198
199/// Fuzzy-filter `index` by `query`, returning ranked results.
200///
201/// When `query` is empty, returns root-level files (no path separator)
202/// sorted alphabetically — the most likely "quick pick" candidates.
203pub fn search(index: &[String], query: &str, max: usize) -> Vec<FileSearchResult> {
204    if query.is_empty() {
205        let mut roots: Vec<&String> = index
206            .iter()
207            .filter(|p| !p.contains(std::path::MAIN_SEPARATOR))
208            .collect();
209        roots.sort();
210        return roots
211            .into_iter()
212            .take(max.min(MAX_EMPTY_QUERY))
213            .map(|p| FileSearchResult {
214                path: p.clone(),
215                score: 0,
216            })
217            .collect();
218    }
219
220    let pattern = Pattern::new(
221        query,
222        CaseMatching::Smart,
223        Normalization::Smart,
224        AtomKind::Fuzzy,
225    );
226    let mut matcher = Matcher::new(nucleo::Config::DEFAULT);
227
228    let mut scored: Vec<FileSearchResult> = index
229        .iter()
230        .filter_map(|path| {
231            let haystack = Utf32Str::Ascii(path.as_bytes());
232            let score = pattern.score(haystack, &mut matcher)?;
233            Some(FileSearchResult {
234                path: path.clone(),
235                score,
236            })
237        })
238        .collect();
239
240    scored.sort_by(|a, b| b.score.cmp(&a.score).then_with(|| a.path.cmp(&b.path)));
241    scored.truncate(max);
242    scored
243}
244
245/// Build the text to insert into the buffer when the user accepts a result.
246///
247/// - Normal: `@path ` (trailing space so the user can continue typing).
248/// - Line mode: `@path:N-M ` when a range was parsed, else `@path:`.
249pub fn insertion_text(path: &str, line_range: Option<(usize, usize)>, line_mode: bool) -> String {
250    if line_mode {
251        match line_range {
252            Some((start, end)) if start == end => format!("@{path}:{start} "),
253            Some((start, end)) => format!("@{path}:{start}-{end} "),
254            None => format!("@{path}:"),
255        }
256    } else {
257        format!("@{path} ")
258    }
259}
260
261/// Open a new file-search state at the given `@` offset, building the
262/// workspace index. Called from the input thread when `@` is first detected.
263pub fn open(cwd: &Path, at_offset: usize, hidden_mode: bool) -> FileSearchState {
264    let index = build_index(cwd, hidden_mode);
265    let results = search(&index, "", MAX_RESULTS);
266    FileSearchState {
267        query: String::new(),
268        at_offset,
269        hidden_mode,
270        results,
271        selected: 0,
272        index,
273        line_mode: false,
274    }
275}
276
277impl FileSearchState {
278    /// Re-filter the index by `query`. Resets selection to the top result.
279    pub fn refresh(&mut self, query: &str) {
280        self.query = query.to_string();
281        self.results = search(&self.index, query, MAX_RESULTS);
282        self.selected = 0;
283    }
284
285    /// Move the selection up (wraps to bottom).
286    pub fn up(&mut self) {
287        if !self.results.is_empty() {
288            self.selected = if self.selected == 0 {
289                self.results.len() - 1
290            } else {
291                self.selected - 1
292            };
293        }
294    }
295
296    /// Move the selection down (wraps to top).
297    pub fn down(&mut self) {
298        if !self.results.is_empty() {
299            self.selected = if self.selected + 1 >= self.results.len() {
300                0
301            } else {
302                self.selected + 1
303            };
304        }
305    }
306
307    /// The currently-selected result, if any.
308    pub fn selected_result(&self) -> Option<&FileSearchResult> {
309        self.results.get(self.selected)
310    }
311
312    /// Toggle hidden-file display and rebuild the index.
313    pub fn toggle_hidden(&mut self, cwd: &Path) {
314        self.hidden_mode = !self.hidden_mode;
315        self.index = build_index(cwd, self.hidden_mode);
316        self.refresh(&self.query.clone());
317    }
318}
319
320// ─────────────────────────────────────────────────────────────────────────
321// Tests
322// ─────────────────────────────────────────────────────────────────────────
323
324#[cfg(test)]
325mod tests {
326    use super::*;
327
328    // ── parse_at_cursor: trigger detection ──
329
330    #[test]
331    fn at_at_buffer_start_triggers() {
332        let tok = parse_at_cursor("@foo", 4).unwrap();
333        assert_eq!(tok.at_offset, 0);
334        assert_eq!(tok.path_query, "foo");
335        assert_eq!(tok.line_range, None);
336        assert!(!tok.hidden_request);
337    }
338
339    #[test]
340    fn at_after_space_triggers() {
341        let tok = parse_at_cursor("hello @foo", 10).unwrap();
342        assert_eq!(tok.at_offset, 6);
343        assert_eq!(tok.path_query, "foo");
344    }
345
346    #[test]
347    fn email_is_not_a_trigger() {
348        assert!(parse_at_cursor("foo@bar.com", 11).is_none());
349        assert!(parse_at_cursor("user_name@host", 14).is_none());
350    }
351
352    #[test]
353    fn at_after_underscore_blocked() {
354        assert!(parse_at_cursor("_@foo", 5).is_none());
355    }
356
357    #[test]
358    fn at_after_punctuation_triggers() {
359        // Non-alphanumeric delimiters are valid word boundaries.
360        let tok = parse_at_cursor("(@foo", 5).unwrap();
361        assert_eq!(tok.path_query, "foo");
362    }
363
364    #[test]
365    fn whitespace_closes_token() {
366        // `@foo bar` — cursor past the space → no active trigger.
367        assert!(parse_at_cursor("@foo bar", 8).is_none());
368    }
369
370    #[test]
371    fn hidden_toggle_detected() {
372        let tok = parse_at_cursor("@!foo", 5).unwrap();
373        assert!(tok.hidden_request);
374        assert_eq!(tok.path_query, "foo");
375    }
376
377    #[test]
378    fn hidden_toggle_alone() {
379        let tok = parse_at_cursor("@!", 2).unwrap();
380        assert!(tok.hidden_request);
381        assert!(tok.path_query.is_empty());
382    }
383
384    #[test]
385    fn line_range_single() {
386        let tok = parse_at_cursor("@foo:42", 7).unwrap();
387        assert_eq!(tok.path_query, "foo");
388        assert_eq!(tok.line_range, Some((42, 42)));
389    }
390
391    #[test]
392    fn line_range_multi() {
393        let tok = parse_at_cursor("@foo:10-25", 10).unwrap();
394        assert_eq!(tok.path_query, "foo");
395        assert_eq!(tok.line_range, Some((10, 25)));
396    }
397
398    #[test]
399    fn line_range_invalid_zero() {
400        let tok = parse_at_cursor("@foo:0", 6).unwrap();
401        assert_eq!(tok.path_query, "foo:0");
402        assert_eq!(tok.line_range, None);
403    }
404
405    #[test]
406    fn line_range_inverted_rejected() {
407        let tok = parse_at_cursor("@foo:25-10", 10).unwrap();
408        assert_eq!(tok.path_query, "foo:25-10");
409        assert_eq!(tok.line_range, None);
410    }
411
412    #[test]
413    fn no_at_symbol_no_trigger() {
414        assert!(parse_at_cursor("hello world", 11).is_none());
415    }
416
417    #[test]
418    fn bare_at_symbol_triggers_empty_query() {
419        let tok = parse_at_cursor("@", 1).unwrap();
420        assert!(tok.path_query.is_empty());
421        assert!(!tok.hidden_request);
422    }
423
424    // ── insertion_text ──
425
426    #[test]
427    fn insertion_normal() {
428        assert_eq!(insertion_text("src/foo.rs", None, false), "@src/foo.rs ");
429    }
430
431    #[test]
432    fn insertion_line_mode_no_range() {
433        assert_eq!(insertion_text("foo.rs", None, true), "@foo.rs:");
434    }
435
436    #[test]
437    fn insertion_line_mode_single() {
438        assert_eq!(
439            insertion_text("foo.rs", Some((10, 10)), true),
440            "@foo.rs:10 "
441        );
442    }
443
444    #[test]
445    fn insertion_line_mode_range() {
446        assert_eq!(
447            insertion_text("foo.rs", Some((10, 25)), true),
448            "@foo.rs:10-25 "
449        );
450    }
451
452    // ── search ranking ──
453
454    #[test]
455    fn empty_query_returns_root_files() {
456        let index = vec![
457            "src/main.rs".into(),
458            "README.md".into(),
459            "Cargo.toml".into(),
460            "src/lib.rs".into(),
461        ];
462        let results = search(&index, "", 20);
463        // Root files (no separator), sorted alphabetically.
464        let paths: Vec<&str> = results.iter().map(|r| r.path.as_str()).collect();
465        assert_eq!(paths, vec!["Cargo.toml", "README.md"]);
466    }
467
468    #[test]
469    fn fuzzy_query_ranks_by_score() {
470        let index = vec![
471            "src/main.rs".into(),
472            "src/maine.rs".into(),
473            "README.md".into(),
474        ];
475        let results = search(&index, "main", 20);
476        assert!(!results.is_empty());
477        // "main.rs" and "maune.rs" should rank above "README.md".
478        assert_eq!(results[0].path, "src/main.rs");
479    }
480
481    #[test]
482    fn search_truncates_to_max() {
483        let index: Vec<String> = (0..100).map(|i| format!("file_{i}.rs")).collect();
484        let results = search(&index, "file", 5);
485        assert_eq!(results.len(), 5);
486    }
487
488    #[test]
489    fn no_matches_returns_empty() {
490        let index = vec!["foo.rs".into()];
491        let results = search(&index, "zzzzzzzzz", 20);
492        assert!(results.is_empty());
493    }
494
495    // ── FileSearchState navigation ──
496
497    #[test]
498    fn nav_down_wraps() {
499        let mut state = FileSearchState {
500            query: String::new(),
501            at_offset: 0,
502            hidden_mode: false,
503            results: vec![
504                FileSearchResult {
505                    path: "a".into(),
506                    score: 0,
507                },
508                FileSearchResult {
509                    path: "b".into(),
510                    score: 0,
511                },
512                FileSearchResult {
513                    path: "c".into(),
514                    score: 0,
515                },
516            ],
517            selected: 0,
518            index: vec![],
519            line_mode: false,
520        };
521        state.down();
522        assert_eq!(state.selected, 1);
523        state.down();
524        assert_eq!(state.selected, 2);
525        state.down();
526        assert_eq!(state.selected, 0); // wraps
527    }
528
529    #[test]
530    fn nav_up_wraps() {
531        let mut state = FileSearchState {
532            query: String::new(),
533            at_offset: 0,
534            hidden_mode: false,
535            results: vec![
536                FileSearchResult {
537                    path: "a".into(),
538                    score: 0,
539                },
540                FileSearchResult {
541                    path: "b".into(),
542                    score: 0,
543                },
544            ],
545            selected: 0,
546            index: vec![],
547            line_mode: false,
548        };
549        state.up();
550        assert_eq!(state.selected, 1); // wraps to bottom
551    }
552
553    #[test]
554    fn refresh_resets_selection() {
555        let mut state = FileSearchState {
556            query: String::new(),
557            at_offset: 0,
558            hidden_mode: false,
559            results: vec![],
560            selected: 5,
561            index: vec!["foo.rs".into(), "bar.rs".into()],
562            line_mode: false,
563        };
564        state.refresh("foo");
565        assert_eq!(state.selected, 0);
566        assert_eq!(state.results.len(), 1);
567    }
568}