wisp/components/
file_picker.rs1use 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}