1use std::borrow::Cow;
5use std::cell::Cell;
6
7use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
8
9use crate::file_set::FileSet;
10use crate::input::Command;
11use crate::overlay::{Overlay, OverlayContext, OverlayFrame, OverlayOutcome};
12
13pub struct FilePicker {
14 filter: String,
15 cursor: usize, visible: Vec<usize>, rows_offset: Cell<usize>, saved_lines: Vec<usize>,
21 paths: Vec<String>,
23 current_index: usize,
26}
27
28impl FilePicker {
29 pub fn new(file_set: &FileSet, saved_lines: Vec<usize>) -> Self {
30 let paths: Vec<String> = (0..file_set.len())
31 .map(|i| file_set.nth(i).map(|p| p.display().to_string()).unwrap_or_default())
32 .collect();
33 let visible: Vec<usize> = (0..file_set.len()).collect();
34 let cursor = file_set.current_index().min(visible.len().saturating_sub(1));
35 Self {
36 filter: String::new(),
37 cursor,
38 visible,
39 rows_offset: Cell::new(0),
40 saved_lines,
41 paths,
42 current_index: file_set.current_index(),
43 }
44 }
45
46 fn recompute_visible(&mut self) {
47 let needle = self.filter.to_lowercase();
48 if needle.is_empty() {
49 self.visible = (0..self.paths.len()).collect();
50 } else {
51 self.visible = (0..self.paths.len())
52 .filter(|&i| self.paths[i].to_lowercase().contains(&needle))
53 .collect();
54 }
55 if self.cursor >= self.visible.len() {
56 self.cursor = self.visible.len().saturating_sub(1);
57 }
58 self.rows_offset.set(0);
59 }
60}
61
62impl Overlay for FilePicker {
63 fn handle_key(&mut self, key: KeyEvent) -> OverlayOutcome {
64 if key.code == KeyCode::Char('d') && key.modifiers.contains(KeyModifiers::CONTROL) {
66 if self.paths.len() <= 1 {
68 return OverlayOutcome::Refuse("can't remove last file");
69 }
70 let target = match self.visible.get(self.cursor) {
71 Some(&t) => t,
72 None => return OverlayOutcome::Stay,
73 };
74 return OverlayOutcome::Apply(Command::DropFileAt(target));
75 }
76 match (key.code, key.modifiers) {
77 (KeyCode::Esc, _) => {
78 if self.filter.is_empty() {
79 OverlayOutcome::Close
80 } else {
81 self.filter.clear();
82 self.recompute_visible();
83 OverlayOutcome::Stay
84 }
85 }
86 (KeyCode::Up, _) => {
87 self.cursor = self.cursor.saturating_sub(1);
88 OverlayOutcome::Stay
89 }
90 (KeyCode::Char('k'), m) if m == KeyModifiers::NONE => {
93 self.cursor = self.cursor.saturating_sub(1);
94 OverlayOutcome::Stay
95 }
96 (KeyCode::Down, _) => {
97 if self.cursor + 1 < self.visible.len() {
98 self.cursor += 1;
99 }
100 OverlayOutcome::Stay
101 }
102 (KeyCode::Char('j'), m) if m == KeyModifiers::NONE => {
103 if self.cursor + 1 < self.visible.len() {
104 self.cursor += 1;
105 }
106 OverlayOutcome::Stay
107 }
108 (KeyCode::PageUp, _) => {
109 self.cursor = self.cursor.saturating_sub(10);
110 OverlayOutcome::Stay
111 }
112 (KeyCode::PageDown, _) => {
113 self.cursor = (self.cursor + 10).min(self.visible.len().saturating_sub(1));
114 OverlayOutcome::Stay
115 }
116 (KeyCode::Home, _) => { self.cursor = 0; OverlayOutcome::Stay }
117 (KeyCode::End, _) => {
118 self.cursor = self.visible.len().saturating_sub(1);
119 OverlayOutcome::Stay
120 }
121 (KeyCode::Enter, _) => {
122 match self.visible.get(self.cursor) {
123 Some(&i) => OverlayOutcome::CloseAnd(Command::SelectFile(i)),
124 None => OverlayOutcome::Stay,
125 }
126 }
127 (KeyCode::Backspace, _) => {
128 self.filter.pop();
129 self.recompute_visible();
130 OverlayOutcome::Stay
131 }
132 (KeyCode::Char(c), m) if !m.contains(KeyModifiers::CONTROL) && !m.contains(KeyModifiers::ALT) => {
133 self.filter.push(c);
134 self.recompute_visible();
135 OverlayOutcome::Stay
136 }
137 _ => OverlayOutcome::Stay,
138 }
139 }
140
141 fn handle_mouse(&mut self, ev: crossterm::event::MouseEvent, _body_rows: u16) -> OverlayOutcome {
142 use crossterm::event::{MouseButton, MouseEventKind};
143 match ev.kind {
144 MouseEventKind::ScrollDown => {
145 if self.cursor + 1 < self.visible.len() {
146 self.cursor += 1;
147 }
148 OverlayOutcome::Stay
149 }
150 MouseEventKind::ScrollUp => {
151 self.cursor = self.cursor.saturating_sub(1);
152 OverlayOutcome::Stay
153 }
154 MouseEventKind::Down(MouseButton::Left) => {
155 let row = ev.row as usize;
158 if row < 2 { return OverlayOutcome::Stay; }
159 let visible_idx = row - 2 + self.rows_offset.get();
160 if visible_idx >= self.visible.len() { return OverlayOutcome::Stay; }
161 self.cursor = visible_idx;
162 OverlayOutcome::CloseAnd(Command::SelectFile(self.visible[self.cursor]))
163 }
164 _ => OverlayOutcome::Stay,
165 }
166 }
167
168 fn render(&self, width: u16, height: u16) -> OverlayFrame {
169 let mut body = Vec::with_capacity(height as usize);
170 let title = if self.filter.is_empty() {
172 format!("Files ({})", self.visible.len())
173 } else {
174 format!(
175 "Files ({} of {} matching \"{}\")",
176 self.visible.len(), self.paths.len(), self.filter,
177 )
178 };
179 body.push(title);
180 body.push(String::new());
181
182 let name_col = self.visible.iter()
184 .map(|&i| self.paths[i].chars().count())
185 .max()
186 .unwrap_or(0)
187 .min(width.saturating_sub(20) as usize);
188
189 let visible_rows = (height as usize).saturating_sub(3); let mut offset = self.rows_offset.get();
193 if visible_rows > 0 {
194 if self.cursor < offset {
195 offset = self.cursor;
197 } else if self.cursor >= offset + visible_rows {
198 offset = self.cursor + 1 - visible_rows;
200 }
201 }
203 self.rows_offset.set(offset);
204
205 for (row, &i) in self.visible.iter().enumerate().skip(offset).take(visible_rows) {
206 let is_cursor = row == self.cursor;
207 let is_current = i == self.current_index;
208 let gutter = if is_cursor { ">" } else { " " };
209 let line_n = self.saved_lines.get(i).copied().unwrap_or(0).max(1);
210 let trailer = if is_current { " \u{2190} current" } else { "" };
211 let path = &self.paths[i];
212 let path_display: String = if path.chars().count() > name_col && name_col > 0 {
215 let mut s: String = path.chars().take(name_col.saturating_sub(1)).collect();
216 s.push('\u{2026}'); s
218 } else {
219 path.clone()
220 };
221 body.push(format!(
222 "{gutter} {path_display:<name_col$} L{line_n}{trailer}",
223 ));
224 }
225
226 let status = "[filter] \u{2191}\u{2193} Enter Ctrl-D remove Esc".to_string();
227 OverlayFrame { body, status }
228 }
229
230 fn title(&self) -> Cow<'_, str> { Cow::Borrowed("Files") }
231
232 fn refresh(&mut self, ctx: OverlayContext) {
233 self.paths = (0..ctx.file_set.len())
235 .map(|i| ctx.file_set.nth(i).map(|p| p.display().to_string()).unwrap_or_default())
236 .collect();
237 self.saved_lines.truncate(self.paths.len());
239 while self.saved_lines.len() < self.paths.len() {
240 self.saved_lines.push(0);
241 }
242 self.current_index = ctx.file_set.current_index();
243 self.recompute_visible();
244 }
245}
246
247#[cfg(test)]
248mod tests {
249 use super::*;
250 use crossterm::event::{KeyEvent as KE, MouseButton, MouseEvent, MouseEventKind};
251 use std::path::PathBuf;
252
253 fn fs(names: &[&str]) -> FileSet {
254 FileSet::new(names.iter().map(PathBuf::from).collect())
255 }
256
257 fn picker(names: &[&str]) -> FilePicker {
258 FilePicker::new(&fs(names), vec![0; names.len()])
259 }
260
261 fn key(code: KeyCode, mods: KeyModifiers) -> KE {
262 KE::new(code, mods)
263 }
264
265 #[test]
266 fn starts_with_cursor_on_current_file() {
267 let mut f = fs(&["a", "b", "c"]);
268 f.set_current_index(1);
269 let p = FilePicker::new(&f, vec![0, 0, 0]);
270 assert_eq!(p.cursor, 1);
271 assert_eq!(p.visible, vec![0, 1, 2]);
272 }
273
274 #[test]
275 fn down_arrow_moves_cursor() {
276 let mut p = picker(&["a", "b", "c"]);
277 assert!(matches!(p.handle_key(key(KeyCode::Down, KeyModifiers::NONE)), OverlayOutcome::Stay));
278 assert_eq!(p.cursor, 1);
279 }
280
281 #[test]
282 fn up_arrow_at_top_is_clamped() {
283 let mut p = picker(&["a", "b"]);
284 p.handle_key(key(KeyCode::Up, KeyModifiers::NONE));
285 assert_eq!(p.cursor, 0);
286 }
287
288 #[test]
289 fn typing_filters_visible_list() {
290 let mut p = picker(&["alpha", "beta", "alpine"]);
291 p.handle_key(key(KeyCode::Char('a'), KeyModifiers::NONE));
292 p.handle_key(key(KeyCode::Char('l'), KeyModifiers::NONE));
293 assert_eq!(p.filter, "al");
294 assert_eq!(p.visible, vec![0, 2]);
295 }
296
297 #[test]
298 fn filter_is_case_insensitive() {
299 let mut p = picker(&["Alpha", "beta", "ALPINE"]);
300 p.handle_key(key(KeyCode::Char('a'), KeyModifiers::NONE));
301 p.handle_key(key(KeyCode::Char('l'), KeyModifiers::NONE));
302 assert_eq!(p.visible, vec![0, 2]);
303 }
304
305 #[test]
306 fn backspace_trims_filter_and_restores_visibility() {
307 let mut p = picker(&["alpha", "uno"]);
308 p.handle_key(key(KeyCode::Char('a'), KeyModifiers::NONE));
309 assert_eq!(p.visible.len(), 1);
310 p.handle_key(key(KeyCode::Backspace, KeyModifiers::NONE));
311 assert_eq!(p.filter, "");
312 assert_eq!(p.visible, vec![0, 1]);
313 }
314
315 #[test]
316 fn esc_clears_filter_first_then_closes() {
317 let mut p = picker(&["a", "b"]);
318 p.handle_key(key(KeyCode::Char('a'), KeyModifiers::NONE));
319 let first = p.handle_key(key(KeyCode::Esc, KeyModifiers::NONE));
320 assert!(matches!(first, OverlayOutcome::Stay));
321 assert_eq!(p.filter, "");
322 let second = p.handle_key(key(KeyCode::Esc, KeyModifiers::NONE));
323 assert!(matches!(second, OverlayOutcome::Close));
324 }
325
326 #[test]
327 fn enter_emits_select_file_with_visible_index() {
328 let mut p = picker(&["a", "b", "c"]);
329 p.handle_key(key(KeyCode::Down, KeyModifiers::NONE)); let out = p.handle_key(key(KeyCode::Enter, KeyModifiers::NONE));
331 match out {
332 OverlayOutcome::CloseAnd(Command::SelectFile(i)) => assert_eq!(i, 1),
333 other => panic!("expected SelectFile(1), got {other:?}"),
334 }
335 }
336
337 #[test]
338 fn ctrl_d_with_n_equals_1_refuses() {
339 let mut p = picker(&["only"]);
340 let out = p.handle_key(key(KeyCode::Char('d'), KeyModifiers::CONTROL));
341 assert!(matches!(out, OverlayOutcome::Refuse(_)));
342 }
343
344 #[test]
345 fn ctrl_d_with_n_gt_1_applies_drop() {
346 let mut p = picker(&["a", "b"]);
347 let out = p.handle_key(key(KeyCode::Char('d'), KeyModifiers::CONTROL));
348 match out {
349 OverlayOutcome::Apply(Command::DropFileAt(i)) => assert_eq!(i, 0),
350 other => panic!("expected Apply(DropFileAt(0)), got {other:?}"),
351 }
352 }
353
354 #[test]
355 fn cursor_clamped_when_filter_shrinks_visible() {
356 let mut p = picker(&["alpha", "beta", "gamma"]);
357 p.handle_key(key(KeyCode::End, KeyModifiers::NONE)); p.handle_key(key(KeyCode::Char('b'), KeyModifiers::NONE)); assert_eq!(p.cursor, 0);
360 }
361
362 #[test]
363 fn filter_uses_substring_not_prefix() {
364 let mut p = picker(&["app.rs", "build.log", "src/logger.rs"]);
367 p.handle_key(key(KeyCode::Char('l'), KeyModifiers::NONE));
368 p.handle_key(key(KeyCode::Char('o'), KeyModifiers::NONE));
369 p.handle_key(key(KeyCode::Char('g'), KeyModifiers::NONE));
370 assert_eq!(p.visible, vec![1, 2], "substring filter should match 'log' anywhere in path");
374 }
375
376 #[test]
377 fn enter_on_empty_visible_is_noop() {
378 let mut p = picker(&["alpha", "beta"]);
380 p.handle_key(key(KeyCode::Char('z'), KeyModifiers::NONE));
382 assert!(p.visible.is_empty());
383 let out = p.handle_key(key(KeyCode::Enter, KeyModifiers::NONE));
384 assert!(matches!(out, OverlayOutcome::Stay));
385 }
386
387 #[test]
388 fn refresh_after_drop_rebuilds_visible() {
389 let mut fs = fs(&["a", "b", "c"]);
390 let mut p = FilePicker::new(&fs, vec![0, 0, 0]);
391 p.handle_key(key(KeyCode::Down, KeyModifiers::NONE)); fs.delete_current().unwrap();
394 p.refresh(OverlayContext { file_set: &fs });
395 assert_eq!(p.paths.len(), 2);
396 assert!(p.cursor < p.paths.len());
397 }
398
399 #[test]
400 fn render_lists_all_files_with_position() {
401 let p = FilePicker::new(&fs(&["a.log", "b.log"]), vec![1, 42]);
402 let frame = p.render(80, 10);
403 assert_eq!(frame.body[0], "Files (2)");
404 assert!(frame.body.iter().any(|l| l.contains("a.log") && l.contains("L1")));
406 assert!(frame.body.iter().any(|l| l.contains("b.log") && l.contains("L42")));
407 }
408
409 #[test]
410 fn render_marks_current_with_arrow() {
411 let mut f = fs(&["a", "b"]);
412 f.set_current_index(1);
413 let p = FilePicker::new(&f, vec![0, 0]);
414 let frame = p.render(80, 10);
415 let current_line = frame.body.iter().find(|l| l.contains("b")).expect("b line");
416 assert!(current_line.contains("\u{2190} current"), "current marker missing: {current_line:?}");
417 }
418
419 #[test]
420 fn render_title_updates_when_filtering() {
421 let mut p = picker(&["alpha", "beta", "alpine"]);
422 p.handle_key(KE::new(KeyCode::Char('a'), KeyModifiers::NONE));
423 p.handle_key(KE::new(KeyCode::Char('l'), KeyModifiers::NONE));
424 let frame = p.render(80, 10);
425 assert_eq!(frame.body[0], "Files (2 of 3 matching \"al\")");
426 }
427
428 #[test]
429 fn render_status_shows_keybindings() {
430 let p = picker(&["a"]);
431 let frame = p.render(80, 10);
432 assert!(frame.status.contains("Enter"), "status missing Enter hint");
433 assert!(frame.status.contains("Ctrl-D"), "status missing Ctrl-D hint");
434 assert!(frame.status.contains("Esc"), "status missing Esc hint");
435 }
436
437 #[test]
438 fn scroll_offset_keeps_cursor_in_band_stably() {
439 let names: Vec<String> = (0..20).map(|n| format!("file_{n:02}")).collect();
441 let refs: Vec<&str> = names.iter().map(String::as_str).collect();
442 let mut p = picker(&refs);
443 for _ in 0..10 {
445 p.handle_key(KE::new(KeyCode::Down, KeyModifiers::NONE));
446 }
447 let _ = p.render(80, 8); assert_eq!(p.rows_offset.get(), 6);
451
452 p.handle_key(KE::new(KeyCode::Up, KeyModifiers::NONE));
455 p.handle_key(KE::new(KeyCode::Up, KeyModifiers::NONE));
456 let _ = p.render(80, 8);
457 assert_eq!(p.rows_offset.get(), 6, "window should be stable while cursor is in band");
458
459 for _ in 0..5 {
461 p.handle_key(KE::new(KeyCode::Up, KeyModifiers::NONE));
462 }
463 let _ = p.render(80, 8);
465 assert_eq!(p.rows_offset.get(), 3);
466 }
467
468 #[test]
469 fn long_paths_are_truncated_with_ellipsis() {
470 let p = FilePicker::new(
472 &fs(&["short.rs", "very/long/nested/path/to/some_module.rs"]),
473 vec![0, 0],
474 );
475 let frame = p.render(40, 10);
477 let long_row = frame.body.iter().find(|l| l.contains('\u{2026}')).expect("ellipsis row");
478 assert!(long_row.contains("L1"), "L<line> column should still be visible: {long_row:?}");
480 }
481
482 fn mouse(kind: MouseEventKind, row: u16) -> MouseEvent {
483 MouseEvent { kind, column: 0, row, modifiers: KeyModifiers::NONE }
484 }
485
486 #[test]
487 fn left_click_sets_cursor_and_selects() {
488 let mut p = picker(&["a", "b", "c"]);
491 let out = p.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 3), 10);
492 match out {
493 OverlayOutcome::CloseAnd(Command::SelectFile(i)) => assert_eq!(i, 1),
494 other => panic!("expected SelectFile(1), got {other:?}"),
495 }
496 }
497
498 #[test]
499 fn scrollwheel_moves_cursor() {
500 let mut p = picker(&["a", "b", "c"]);
501 p.handle_mouse(mouse(MouseEventKind::ScrollDown, 0), 10);
502 assert_eq!(p.cursor, 1);
503 p.handle_mouse(mouse(MouseEventKind::ScrollUp, 0), 10);
504 assert_eq!(p.cursor, 0);
505 }
506}