Skip to main content

wisp/components/
file_picker.rs

1use ignore::WalkBuilder;
2use std::env::current_dir;
3use std::path::{Path, PathBuf};
4use tui::{Combobox, Component, Event, Frame, Line, PickerMessage, Searchable, ViewContext};
5
6const MAX_INDEXED_FILES: usize = 50_000;
7
8pub struct FilePicker {
9    combobox: Combobox<FileMatch>,
10}
11
12pub type FilePickerMessage = PickerMessage<FileMatch>;
13
14#[derive(Debug, Clone)]
15pub struct FileMatch {
16    pub path: PathBuf,
17    pub display_name: String,
18}
19
20impl Searchable for FileMatch {
21    fn search_text(&self) -> String {
22        self.display_name.clone()
23    }
24}
25
26impl FilePicker {
27    pub fn new() -> Self {
28        let root = current_dir().unwrap_or_else(|_| PathBuf::from("."));
29        let mut entries = Vec::new();
30
31        let walker = WalkBuilder::new(&root)
32            .git_ignore(true)
33            .git_global(true)
34            .git_exclude(true)
35            .hidden(false)
36            .parents(true)
37            .build();
38
39        for entry in walker.flatten().take(MAX_INDEXED_FILES) {
40            let path = entry.path();
41            if !entry.file_type().is_some_and(|ft| ft.is_file()) || should_exclude_path(path) {
42                continue;
43            }
44
45            let display_name = path
46                .strip_prefix(&root)
47                .unwrap_or(path)
48                .to_string_lossy()
49                .replace('\\', "/");
50
51            entries.push(FileMatch {
52                path: path.to_path_buf(),
53                display_name,
54            });
55        }
56
57        entries.sort_by(|a, b| a.display_name.cmp(&b.display_name));
58
59        Self {
60            combobox: Combobox::new(entries),
61        }
62    }
63
64    pub fn query(&self) -> &str {
65        self.combobox.query()
66    }
67
68    #[cfg(test)]
69    fn new_with_entries(entries: Vec<FileMatch>) -> Self {
70        Self {
71            combobox: Combobox::new(entries),
72        }
73    }
74}
75
76impl Default for FilePicker {
77    fn default() -> Self {
78        Self::new()
79    }
80}
81
82fn should_exclude_path(path: &Path) -> bool {
83    path.components().any(|component| {
84        let value = component.as_os_str().to_string_lossy();
85        value.starts_with('.') || matches!(value.as_ref(), "node_modules" | "target")
86    })
87}
88
89impl Component for FilePicker {
90    type Message = FilePickerMessage;
91
92    async fn on_event(&mut self, event: &Event) -> Option<Vec<Self::Message>> {
93        self.combobox.handle_picker_event(event)
94    }
95
96    fn render(&mut self, context: &ViewContext) -> Frame {
97        let mut lines = Vec::new();
98
99        if self.combobox.is_empty() {
100            lines.push(Line::new("  (no matches found)".to_string()));
101            return Frame::new(lines);
102        }
103
104        let item_lines = self
105            .combobox
106            .render_items(context, |file, is_selected, ctx| {
107                let line_text = file.display_name.clone();
108                if is_selected {
109                    let mut line = Line::with_style(line_text, ctx.theme.selected_row_style());
110                    line.extend_bg_to_width(ctx.size.width as usize);
111                    line
112                } else {
113                    Line::new(line_text)
114                }
115            });
116        lines.extend(item_lines);
117
118        Frame::new(lines)
119    }
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125    use tui::ViewContext;
126    use tui::test_picker::{rendered_lines_from, rendered_raw_lines_with_context, type_query};
127    use tui::{KeyCode, KeyEvent, KeyModifiers};
128
129    const DEFAULT_SIZE: (u16, u16) = (120, 40);
130
131    fn key(code: KeyCode) -> KeyEvent {
132        KeyEvent::new(code, KeyModifiers::NONE)
133    }
134
135    fn file_match(path: &str) -> FileMatch {
136        FileMatch {
137            path: PathBuf::from(path),
138            display_name: path.to_string(),
139        }
140    }
141
142    fn selected_text(picker: &mut FilePicker) -> Option<String> {
143        let context = ViewContext::new(DEFAULT_SIZE);
144        let frame = picker.render(&context);
145        frame
146            .lines()
147            .iter()
148            .find(|line| line.plain_text().starts_with("  "))
149            .map(|line| line.plain_text())
150    }
151
152    #[test]
153    fn excludes_hidden_and_build_paths() {
154        assert!(should_exclude_path(Path::new(".git/config")));
155        assert!(should_exclude_path(Path::new(
156            "node_modules/react/index.js"
157        )));
158        assert!(should_exclude_path(Path::new("target/debug/wisp")));
159        assert!(should_exclude_path(Path::new("src/.cache/file.txt")));
160        assert!(!should_exclude_path(Path::new("src/main.rs")));
161    }
162
163    #[tokio::test]
164    async fn query_filters_matches() {
165        let mut picker = FilePicker::new_with_entries(vec![
166            file_match("src/main.rs"),
167            file_match("src/renderer.rs"),
168            file_match("README.md"),
169        ]);
170
171        type_query(&mut picker, "rend").await;
172
173        let lines = rendered_lines_from(&picker.render(&ViewContext::new(DEFAULT_SIZE)));
174        assert_eq!(lines.len(), 1);
175        assert!(lines[0].contains("src/renderer.rs"));
176    }
177
178    #[tokio::test]
179    async fn selection_wraps() {
180        let mut picker = FilePicker::new_with_entries(vec![
181            file_match("a.rs"),
182            file_match("b.rs"),
183            file_match("c.rs"),
184        ]);
185
186        let first = selected_text(&mut picker).unwrap();
187
188        picker.on_event(&Event::Key(key(KeyCode::Up))).await;
189        let last = selected_text(&mut picker).unwrap();
190        assert_ne!(first, last);
191
192        picker.on_event(&Event::Key(key(KeyCode::Down))).await;
193        let back_to_first = selected_text(&mut picker).unwrap();
194        assert_eq!(first, back_to_first);
195    }
196
197    #[test]
198    fn selected_entry_has_highlight_background() {
199        let mut picker = FilePicker::new_with_entries(vec![
200            file_match("a.rs"),
201            file_match("b.rs"),
202            file_match("c.rs"),
203        ]);
204        let context = ViewContext::new((80, 24));
205        let frame = picker.render(&context);
206        let selected_line = frame
207            .lines()
208            .iter()
209            .find(|line| line.plain_text().starts_with("  "))
210            .expect("should render a selected line");
211
212        let has_bg = selected_line
213            .spans()
214            .iter()
215            .any(|span| span.style().bg == Some(context.theme.highlight_bg()));
216        assert!(has_bg, "selected entry should have highlight background");
217    }
218
219    #[test]
220    fn selected_entry_has_text_primary_foreground() {
221        let mut picker = FilePicker::new_with_entries(vec![file_match("a.rs")]);
222        let context = ViewContext::new((80, 24));
223        let lines = rendered_raw_lines_with_context(|ctx| picker.render(ctx), (80, 24));
224        let selected_line = lines
225            .iter()
226            .find(|line| line.plain_text().starts_with("  "))
227            .expect("should render a selected line");
228
229        let has_fg = selected_line
230            .spans()
231            .iter()
232            .any(|span| span.style().fg == Some(context.theme.text_primary()));
233        assert!(has_fg, "selected entry should have text_primary foreground");
234    }
235
236    #[test]
237    fn selected_entry_highlight_fills_full_line_width() {
238        let mut picker = FilePicker::new_with_entries(vec![file_match("a.rs")]);
239        let context = ViewContext::new((20, 24));
240        let lines = rendered_raw_lines_with_context(|ctx| picker.render(ctx), (20, 24));
241        let selected_line = lines
242            .iter()
243            .find(|line| line.plain_text().starts_with("  "))
244            .expect("should render a selected line");
245
246        assert_eq!(
247            selected_line.display_width(),
248            context.size.width as usize,
249            "selected row should fill the full visible width",
250        );
251    }
252
253    #[tokio::test]
254    async fn handle_key_char_updates_query_and_returns_char_typed() {
255        let mut picker = FilePicker::new_with_entries(vec![file_match("src/renderer.rs")]);
256
257        let outcome = picker.on_event(&Event::Key(key(KeyCode::Char('r')))).await;
258
259        assert!(outcome.is_some());
260
261        assert!(matches!(
262            outcome.unwrap().as_slice(),
263            [PickerMessage::CharTyped('r')]
264        ));
265        assert_eq!(picker.query(), "r");
266    }
267
268    #[tokio::test]
269    async fn handle_key_whitespace_closes_picker() {
270        let mut picker = FilePicker::new_with_entries(vec![file_match("src/main.rs")]);
271
272        let outcome = picker.on_event(&Event::Key(key(KeyCode::Char(' ')))).await;
273
274        assert!(outcome.is_some());
275
276        assert!(matches!(
277            outcome.unwrap().as_slice(),
278            [PickerMessage::CloseWithChar(' ')]
279        ));
280    }
281
282    #[tokio::test]
283    async fn handle_key_enter_requests_confirmation() {
284        let mut picker = FilePicker::new_with_entries(vec![file_match("src/main.rs")]);
285
286        let outcome = picker.on_event(&Event::Key(key(KeyCode::Enter))).await;
287
288        assert!(outcome.is_some());
289
290        assert!(matches!(
291            outcome.unwrap().as_slice(),
292            [PickerMessage::Confirm(_)]
293        ));
294    }
295
296    #[tokio::test]
297    async fn backspace_with_empty_query_closes_and_pops() {
298        let mut picker = FilePicker::new_with_entries(vec![file_match("src/main.rs")]);
299
300        let outcome = picker.on_event(&Event::Key(key(KeyCode::Backspace))).await;
301
302        assert!(outcome.is_some());
303
304        assert!(matches!(
305            outcome.unwrap().as_slice(),
306            [PickerMessage::CloseAndPopChar]
307        ));
308    }
309
310    #[tokio::test]
311    async fn backspace_with_query_pops_char() {
312        let mut picker = FilePicker::new_with_entries(vec![file_match("src/main.rs")]);
313        type_query(&mut picker, "ma").await;
314
315        let outcome = picker.on_event(&Event::Key(key(KeyCode::Backspace))).await;
316
317        assert!(outcome.is_some());
318
319        assert!(matches!(
320            outcome.unwrap().as_slice(),
321            [PickerMessage::PopChar]
322        ));
323        assert_eq!(picker.query(), "m");
324    }
325}