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
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 let value = component.as_os_str().to_string_lossy();
74 value.starts_with('.') || matches!(value.as_ref(), "node_modules" | "target")
75 })
76}
77
78impl Component for FilePicker {
79 type Message = FilePickerMessage;
80
81 async fn on_event(&mut self, event: &Event) -> Option<Vec<Self::Message>> {
82 self.combobox.handle_picker_event(event)
83 }
84
85 fn render(&mut self, context: &ViewContext) -> Frame {
86 let mut lines = Vec::new();
87
88 if self.combobox.is_empty() {
89 lines.push(Line::new(" (no matches found)".to_string()));
90 return Frame::new(lines);
91 }
92
93 let item_lines = self.combobox.render_items(context, |file, is_selected, ctx| {
94 let line_text = file.display_name.clone();
95 if is_selected { ctx.theme.selected_row_line(line_text) } else { Line::new(line_text) }
96 });
97 lines.extend(item_lines);
98
99 Frame::new(lines)
100 }
101}
102
103#[cfg(test)]
104mod tests {
105 use super::*;
106 use tui::ViewContext;
107 use tui::test_picker::{rendered_lines_from, rendered_raw_lines_with_context, type_query};
108 use tui::{KeyCode, KeyEvent, KeyModifiers};
109
110 const DEFAULT_SIZE: (u16, u16) = (120, 40);
111
112 fn key(code: KeyCode) -> KeyEvent {
113 KeyEvent::new(code, KeyModifiers::NONE)
114 }
115
116 fn file_match(path: &str) -> FileMatch {
117 FileMatch { path: PathBuf::from(path), display_name: path.to_string() }
118 }
119
120 fn selected_text(picker: &mut FilePicker) -> Option<String> {
121 let context = ViewContext::new(DEFAULT_SIZE);
122 let frame = picker.render(&context);
123 let highlight_bg = context.theme.highlight_bg();
124 frame
125 .lines()
126 .iter()
127 .find(|line| {
128 line.fill() == Some(highlight_bg) || line.spans().iter().any(|s| s.style().bg == Some(highlight_bg))
129 })
130 .map(tui::Line::plain_text)
131 }
132
133 #[test]
134 fn excludes_hidden_and_build_paths() {
135 assert!(should_exclude_path(Path::new(".git/config")));
136 assert!(should_exclude_path(Path::new("node_modules/react/index.js")));
137 assert!(should_exclude_path(Path::new("target/debug/wisp")));
138 assert!(should_exclude_path(Path::new("src/.cache/file.txt")));
139 assert!(!should_exclude_path(Path::new("src/main.rs")));
140 }
141
142 #[tokio::test]
143 async fn query_filters_matches() {
144 let mut picker = FilePicker::new_with_entries(vec![
145 file_match("src/main.rs"),
146 file_match("src/renderer.rs"),
147 file_match("README.md"),
148 ]);
149
150 type_query(&mut picker, "rend").await;
151
152 let lines = rendered_lines_from(&picker.render(&ViewContext::new(DEFAULT_SIZE)));
153 assert_eq!(lines.len(), 1);
154 assert!(lines[0].contains("src/renderer.rs"));
155 }
156
157 #[tokio::test]
158 async fn selection_wraps() {
159 let mut picker = FilePicker::new_with_entries(vec![file_match("a.rs"), file_match("b.rs"), file_match("c.rs")]);
160
161 let first = selected_text(&mut picker).unwrap();
162
163 picker.on_event(&Event::Key(key(KeyCode::Up))).await;
164 let last = selected_text(&mut picker).unwrap();
165 assert_ne!(first, last);
166
167 picker.on_event(&Event::Key(key(KeyCode::Down))).await;
168 let back_to_first = selected_text(&mut picker).unwrap();
169 assert_eq!(first, back_to_first);
170 }
171
172 #[test]
173 fn selected_entry_has_highlight_background() {
174 let mut picker = FilePicker::new_with_entries(vec![file_match("a.rs"), file_match("b.rs"), file_match("c.rs")]);
175 let context = ViewContext::new((80, 24));
176 let frame = picker.render(&context);
177 let selected_line = frame
178 .lines()
179 .iter()
180 .find(|line| line.plain_text().starts_with(" "))
181 .expect("should render a selected line");
182
183 let has_bg = selected_line.spans().iter().any(|span| span.style().bg == Some(context.theme.highlight_bg()));
184 assert!(has_bg, "selected entry should have highlight background");
185 }
186
187 #[test]
188 fn selected_entry_has_text_primary_foreground() {
189 let mut picker = FilePicker::new_with_entries(vec![file_match("a.rs")]);
190 let context = ViewContext::new((80, 24));
191 let lines = rendered_raw_lines_with_context(|ctx| picker.render(ctx), (80, 24));
192 let selected_line =
193 lines.iter().find(|line| line.plain_text().starts_with(" ")).expect("should render a selected line");
194
195 let has_fg = selected_line.spans().iter().any(|span| span.style().fg == Some(context.theme.text_primary()));
196 assert!(has_fg, "selected entry should have text_primary foreground");
197 }
198
199 #[test]
200 fn selected_entry_highlight_fills_full_line_width() {
201 let mut picker = FilePicker::new_with_entries(vec![file_match("a.rs")]);
202 let context = ViewContext::new((20, 24));
203 let highlight_bg = context.theme.highlight_bg();
204 let term = tui::testing::render_component(|ctx| picker.render(ctx), 20, 24);
205 let row = term.get_lines().iter().position(|l| l.starts_with(" a.rs")).expect("should render a selected line");
206 let last_col_style = term.get_style_at(row, 19);
207 assert_eq!(
208 last_col_style.bg,
209 Some(highlight_bg),
210 "selected row should fill the full visible width with highlight background",
211 );
212 }
213
214 #[tokio::test]
215 async fn handle_key_char_updates_query_and_returns_char_typed() {
216 let mut picker = FilePicker::new_with_entries(vec![file_match("src/renderer.rs")]);
217
218 let outcome = picker.on_event(&Event::Key(key(KeyCode::Char('r')))).await;
219
220 assert!(outcome.is_some());
221
222 assert!(matches!(outcome.unwrap().as_slice(), [PickerMessage::CharTyped('r')]));
223 assert_eq!(picker.query(), "r");
224 }
225
226 #[tokio::test]
227 async fn handle_key_whitespace_closes_picker() {
228 let mut picker = FilePicker::new_with_entries(vec![file_match("src/main.rs")]);
229
230 let outcome = picker.on_event(&Event::Key(key(KeyCode::Char(' ')))).await;
231
232 assert!(outcome.is_some());
233
234 assert!(matches!(outcome.unwrap().as_slice(), [PickerMessage::CloseWithChar(' ')]));
235 }
236
237 #[tokio::test]
238 async fn handle_key_enter_requests_confirmation() {
239 let mut picker = FilePicker::new_with_entries(vec![file_match("src/main.rs")]);
240
241 let outcome = picker.on_event(&Event::Key(key(KeyCode::Enter))).await;
242
243 assert!(outcome.is_some());
244
245 assert!(matches!(outcome.unwrap().as_slice(), [PickerMessage::Confirm(_)]));
246 }
247
248 #[tokio::test]
249 async fn backspace_with_empty_query_closes_and_pops() {
250 let mut picker = FilePicker::new_with_entries(vec![file_match("src/main.rs")]);
251
252 let outcome = picker.on_event(&Event::Key(key(KeyCode::Backspace))).await;
253
254 assert!(outcome.is_some());
255
256 assert!(matches!(outcome.unwrap().as_slice(), [PickerMessage::CloseAndPopChar]));
257 }
258
259 #[tokio::test]
260 async fn backspace_with_query_pops_char() {
261 let mut picker = FilePicker::new_with_entries(vec![file_match("src/main.rs")]);
262 type_query(&mut picker, "ma").await;
263
264 let outcome = picker.on_event(&Event::Key(key(KeyCode::Backspace))).await;
265
266 assert!(outcome.is_some());
267
268 assert!(matches!(outcome.unwrap().as_slice(), [PickerMessage::PopChar]));
269 assert_eq!(picker.query(), "m");
270 }
271}