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