Skip to main content

zeph_tui/
file_picker.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use std::path::Path;
5use std::sync::Arc;
6use std::time::{Duration, Instant};
7
8use nucleo_matcher::pattern::{AtomKind, CaseMatching, Normalization, Pattern};
9use nucleo_matcher::{Config, Matcher, Utf32Str};
10
11const TTL: Duration = Duration::from_secs(30);
12const MAX_RESULTS: usize = 10;
13/// Hard cap on indexed paths to prevent unbounded memory usage on repos with
14/// large unignored directories.
15const MAX_INDEXED: usize = 50_000;
16
17pub struct FileIndex {
18    paths: Arc<Vec<String>>,
19    built_at: Instant,
20}
21
22impl FileIndex {
23    /// Builds the file index by walking `root` with `.gitignore` awareness.
24    ///
25    /// # Blocking I/O note
26    ///
27    /// This function performs synchronous directory traversal on the calling thread.
28    /// For small to medium repos (< 5 000 files) the cost is negligible (< 20 ms).
29    /// For large monorepos (50 000+ files) consider offloading via
30    /// `tokio::task::spawn_blocking`. A full async build is deferred to a
31    /// follow-up milestone once the UX for "Indexing…" feedback is designed.
32    #[must_use]
33    pub fn build(root: &Path) -> Self {
34        let mut paths = Vec::new();
35        let walker = ignore::WalkBuilder::new(root)
36            .hidden(true) // exclude dotfiles (.env, .ssh/, etc.)
37            .ignore(true)
38            .git_ignore(true)
39            .build();
40
41        for entry in walker.flatten() {
42            if entry.file_type().is_some_and(|ft| ft.is_file()) {
43                let path = entry.path();
44                let rel = path.strip_prefix(root).unwrap_or(path);
45                if let Some(s) = rel.to_str() {
46                    // Normalize Windows backslashes to forward slashes
47                    paths.push(s.replace('\\', "/"));
48                }
49                if paths.len() >= MAX_INDEXED {
50                    tracing::warn!(
51                        max = MAX_INDEXED,
52                        root = %root.display(),
53                        "file index cap reached; some files will not be searchable"
54                    );
55                    break;
56                }
57            }
58        }
59        paths.sort_unstable();
60        Self {
61            paths: Arc::new(paths),
62            built_at: Instant::now(),
63        }
64    }
65
66    #[must_use]
67    pub fn is_stale(&self) -> bool {
68        self.built_at.elapsed() > TTL
69    }
70
71    #[must_use]
72    pub fn paths(&self) -> &[String] {
73        &self.paths
74    }
75
76    #[must_use]
77    pub fn paths_arc(&self) -> Arc<Vec<String>> {
78        Arc::clone(&self.paths)
79    }
80}
81
82#[derive(Clone)]
83pub struct PickerMatch {
84    pub path: String,
85    pub score: u32,
86}
87
88pub struct FilePickerState {
89    pub query: String,
90    pub selected: usize,
91    matches: Vec<PickerMatch>,
92    /// Shared ownership of the file index — no clone on picker open.
93    index: Arc<Vec<String>>,
94    /// Reused across `refilter` calls to avoid per-keystroke heap allocation.
95    matcher: Matcher,
96}
97
98impl FilePickerState {
99    #[must_use]
100    pub fn new(index: &FileIndex) -> Self {
101        let mut state = Self {
102            query: String::new(),
103            selected: 0,
104            matches: Vec::new(),
105            index: index.paths_arc(),
106            matcher: Matcher::new(Config::DEFAULT),
107        };
108        state.refilter();
109        state
110    }
111
112    pub fn update_query(&mut self, query: &str) {
113        query.clone_into(&mut self.query);
114        self.refilter();
115    }
116
117    /// Appends a character to the query and re-filters.
118    pub fn push_char(&mut self, c: char) {
119        self.query.push(c);
120        self.refilter();
121    }
122
123    /// Removes the last character from the query and re-filters.
124    /// Returns `true` if a character was removed, `false` if the query was already empty.
125    pub fn pop_char(&mut self) -> bool {
126        if self.query.pop().is_some() {
127            self.refilter();
128            true
129        } else {
130            false
131        }
132    }
133
134    #[must_use]
135    pub fn matches(&self) -> &[PickerMatch] {
136        &self.matches
137    }
138
139    #[must_use]
140    pub fn selected_path(&self) -> Option<&str> {
141        self.matches.get(self.selected).map(|m| m.path.as_str())
142    }
143
144    pub fn move_selection(&mut self, delta: i32) {
145        let len = self.matches.len();
146        if len == 0 {
147            return;
148        }
149        let len_i = i32::try_from(len).unwrap_or(i32::MAX);
150        let cur_i = i32::try_from(self.selected).unwrap_or(0);
151        let new_i = (cur_i + delta).rem_euclid(len_i);
152        self.selected = usize::try_from(new_i).unwrap_or(0);
153    }
154
155    fn refilter(&mut self) {
156        self.selected = 0;
157        if self.query.is_empty() {
158            self.matches = self
159                .index
160                .iter()
161                .take(MAX_RESULTS)
162                .map(|p| PickerMatch {
163                    path: p.clone(),
164                    score: 0,
165                })
166                .collect();
167            return;
168        }
169
170        let pattern = Pattern::new(
171            &self.query,
172            CaseMatching::Smart,
173            Normalization::Smart,
174            AtomKind::Fuzzy,
175        );
176
177        let mut scored: Vec<PickerMatch> = self
178            .index
179            .iter()
180            .filter_map(|p| {
181                let mut buf = Vec::new();
182                let haystack = Utf32Str::new(p, &mut buf);
183                pattern
184                    .score(haystack, &mut self.matcher)
185                    .map(|score| PickerMatch {
186                        path: p.clone(),
187                        score,
188                    })
189            })
190            .collect();
191
192        scored.sort_unstable_by_key(|entry| std::cmp::Reverse(entry.score));
193        scored.truncate(MAX_RESULTS);
194        self.matches = scored;
195    }
196}
197
198#[cfg(test)]
199mod tests {
200    use std::fs;
201
202    use super::*;
203
204    fn make_index(files: &[&str]) -> FileIndex {
205        let dir = tempfile::tempdir().unwrap();
206        for &f in files {
207            let path = dir.path().join(f);
208            if let Some(parent) = path.parent() {
209                fs::create_dir_all(parent).unwrap();
210            }
211            fs::write(&path, "").unwrap();
212        }
213        FileIndex::build(dir.path())
214    }
215
216    #[test]
217    fn build_collects_files() {
218        let idx = make_index(&["src/main.rs", "src/lib.rs", "README.md"]);
219        assert_eq!(idx.paths().len(), 3);
220        assert!(idx.paths().iter().any(|p| p.ends_with("main.rs")));
221    }
222
223    #[test]
224    fn is_stale_false_when_fresh() {
225        let idx = make_index(&["a.rs"]);
226        assert!(!idx.is_stale());
227    }
228
229    #[test]
230    fn empty_query_returns_up_to_10_files() {
231        let files: Vec<String> = (0..15).map(|i| format!("file{i}.rs")).collect();
232        let refs: Vec<&str> = files.iter().map(String::as_str).collect();
233        let idx = make_index(&refs);
234        let state = FilePickerState::new(&idx);
235        assert_eq!(state.matches().len(), 10);
236    }
237
238    #[test]
239    fn fuzzy_query_filters_results() {
240        let idx = make_index(&["src/main.rs", "src/lib.rs", "tests/foo.rs"]);
241        let mut state = FilePickerState::new(&idx);
242        state.update_query("main");
243        assert!(!state.matches().is_empty());
244        assert!(state.matches().iter().any(|m| m.path.contains("main")));
245    }
246
247    #[test]
248    fn selected_path_returns_first_match() {
249        let idx = make_index(&["alpha.rs", "beta.rs"]);
250        let state = FilePickerState::new(&idx);
251        assert!(state.selected_path().is_some());
252    }
253
254    #[test]
255    fn move_selection_wraps_around() {
256        let idx = make_index(&["a.rs", "b.rs", "c.rs"]);
257        let mut state = FilePickerState::new(&idx);
258        assert_eq!(state.selected, 0);
259        state.move_selection(-1);
260        assert_eq!(state.selected, state.matches().len() - 1);
261    }
262
263    #[test]
264    fn move_selection_noop_when_empty() {
265        let idx = make_index(&["a.rs"]);
266        let mut state = FilePickerState::new(&idx);
267        state.matches = vec![];
268        state.move_selection(1);
269        assert_eq!(state.selected, 0);
270    }
271
272    #[test]
273    fn no_match_query_returns_empty_and_selected_path_none() {
274        let idx = make_index(&["src/main.rs", "src/lib.rs"]);
275        let mut state = FilePickerState::new(&idx);
276        state.update_query("xyznotfound");
277        assert!(state.matches().is_empty());
278        assert!(state.selected_path().is_none());
279    }
280
281    #[test]
282    fn unicode_paths_are_indexed_and_searchable() {
283        let idx = make_index(&["src/данные.rs", "データ/main.rs", "normal.rs"]);
284        assert!(idx.paths().iter().any(|p| p.contains("данные")));
285        assert!(idx.paths().iter().any(|p| p.contains("main")));
286
287        let mut state = FilePickerState::new(&idx);
288        state.update_query("данные");
289        assert!(
290            !state.matches().is_empty(),
291            "expected match for unicode query"
292        );
293    }
294
295    #[test]
296    fn push_char_appends_and_refilters() {
297        let idx = make_index(&["src/main.rs", "src/lib.rs"]);
298        let mut state = FilePickerState::new(&idx);
299        state.push_char('m');
300        state.push_char('a');
301        assert!(state.matches().iter().any(|m| m.path.contains("main")));
302    }
303
304    #[test]
305    fn pop_char_removes_last_and_refilters() {
306        let idx = make_index(&["src/main.rs", "src/lib.rs"]);
307        let mut state = FilePickerState::new(&idx);
308        state.push_char('m');
309        let removed = state.pop_char();
310        assert!(removed);
311        assert!(state.query.is_empty());
312    }
313
314    #[test]
315    fn pop_char_on_empty_returns_false() {
316        let idx = make_index(&["a.rs"]);
317        let mut state = FilePickerState::new(&idx);
318        assert!(!state.pop_char());
319    }
320
321    #[test]
322    fn arc_index_shared_not_cloned() {
323        let idx = make_index(&["a.rs", "b.rs"]);
324        let arc1 = idx.paths_arc();
325        let state = FilePickerState::new(&idx);
326        // Both should point to the same allocation
327        assert!(Arc::ptr_eq(&arc1, &state.index));
328    }
329
330    use proptest::prelude::*;
331
332    proptest! {
333        #![proptest_config(proptest::test_runner::Config::with_cases(200))]
334
335        #[test]
336        fn move_selection_never_panics(
337            n in 1usize..20,
338            delta in -10i32..10,
339        ) {
340            let files: Vec<String> = (0..n).map(|i| format!("f{i}.rs")).collect();
341            let refs: Vec<&str> = files.iter().map(String::as_str).collect();
342            let idx = make_index(&refs);
343            let mut state = FilePickerState::new(&idx);
344            state.move_selection(delta);
345            prop_assert!(state.selected < state.matches().len().max(1));
346        }
347    }
348}