1use anyhow::Result;
4use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
5
6use super::app::{App, AppMode};
7
8pub fn handle_event(app: &mut App, event: Event) -> Result<bool> {
12 match event {
13 Event::Key(key) => Ok(handle_key(app, key)),
14 Event::Resize(width, _height) => {
15 app.update_columns(width);
16 Ok(false)
17 }
18 _ => Ok(false),
19 }
20}
21
22fn handle_key(app: &mut App, key: KeyEvent) -> bool {
26 if matches!(
28 (key.code, key.modifiers),
29 (KeyCode::Char('c'), KeyModifiers::CONTROL)
30 ) && !matches!(app.mode(), AppMode::Filter { .. } | AppMode::Args { .. })
31 {
32 app.quit();
33 return true;
34 }
35
36 match app.mode().clone() {
37 AppMode::Normal => handle_normal_mode(app, key),
38 AppMode::Filter { query } => handle_filter_mode(app, key, &query),
39 AppMode::Help => handle_help_mode(app, key),
40 AppMode::Error { .. } => handle_error_mode(app, key),
41 AppMode::MultiSelect { selected } => handle_multiselect_mode(app, key, &selected),
42 AppMode::Args {
43 script_index,
44 input,
45 } => handle_args_mode(app, key, script_index, &input),
46 AppMode::WorkspaceSelect => handle_workspace_select_mode(app, key),
47 }
48
49 app.should_quit()
50}
51
52fn handle_normal_mode(app: &mut App, key: KeyEvent) {
72 match key.code {
73 KeyCode::Up | KeyCode::Char('k') => app.move_up(),
75 KeyCode::Down | KeyCode::Char('j') => app.move_down(),
76 KeyCode::Left | KeyCode::Char('h') => app.move_left(),
77 KeyCode::Right | KeyCode::Char('l') => app.move_right(),
78 KeyCode::Home | KeyCode::Char('g') => app.move_to_first(),
79 KeyCode::End | KeyCode::Char('G') => app.move_to_last(),
80
81 KeyCode::Enter | KeyCode::Char('o') => {
83 app.run_selected();
84 }
85
86 KeyCode::Char(c) if c.is_ascii_digit() && c != '0' => {
88 let num = c.to_digit(10).unwrap() as usize;
89 app.run_numbered(num);
90 }
91
92 KeyCode::Char('/') => {
94 app.set_mode(AppMode::Filter {
95 query: String::new(),
96 });
97 }
98
99 KeyCode::Char('s') => {
101 app.cycle_sort_mode();
102 }
103
104 KeyCode::Char('a') => {
106 app.enter_args_mode();
107 }
108
109 KeyCode::Char('m') => {
111 app.toggle_multi_select();
112 }
113
114 KeyCode::Char('?') => {
116 app.toggle_help();
117 }
118
119 KeyCode::Char('q') => {
121 app.quit();
122 }
123
124 KeyCode::Char('w') if app.is_monorepo() => {
126 app.back_to_workspace_select();
127 }
128
129 _ => {}
130 }
131}
132
133fn handle_workspace_select_mode(app: &mut App, key: KeyEvent) {
146 match key.code {
147 KeyCode::Up | KeyCode::Char('k') => app.workspace_move_up(),
149 KeyCode::Down | KeyCode::Char('j') => app.workspace_move_down(),
150 KeyCode::Left | KeyCode::Char('h') => app.workspace_move_left(),
151 KeyCode::Right | KeyCode::Char('l') => app.workspace_move_right(),
152
153 KeyCode::Enter => {
155 app.select_current_workspace();
156 }
157
158 KeyCode::Char(c) if c.is_ascii_digit() && c != '0' => {
160 let num = c.to_digit(10).unwrap() as usize;
161 app.select_workspace_by_number(num);
162 }
163
164 KeyCode::Char('?') => {
166 app.toggle_help();
167 }
168
169 KeyCode::Char('q') | KeyCode::Esc => {
171 app.quit();
172 }
173
174 _ => {}
175 }
176}
177
178fn handle_filter_mode(app: &mut App, key: KeyEvent, current_query: &str) {
186 match key.code {
187 KeyCode::Esc => {
189 app.clear_filter();
190 app.set_mode(AppMode::Normal);
191 }
192
193 KeyCode::Enter => {
195 app.run_selected();
196 }
197
198 KeyCode::Backspace => {
200 let mut query = current_query.to_string();
201 query.pop();
202 if query.is_empty() {
203 app.clear_filter();
204 app.set_mode(AppMode::Normal);
205 } else {
206 app.set_filter(query);
207 }
208 }
209
210 KeyCode::Up => app.move_up(),
212 KeyCode::Down => app.move_down(),
213 KeyCode::Left => app.move_left(),
214 KeyCode::Right => app.move_right(),
215
216 KeyCode::Char(c) => {
218 if c.is_ascii_digit() && c != '0' && current_query.is_empty() {
220 let num = c.to_digit(10).unwrap() as usize;
221 app.run_numbered(num);
222 } else {
223 let mut query = current_query.to_string();
224 query.push(c);
225 app.set_filter(query);
226 }
227 }
228
229 _ => {}
230 }
231}
232
233fn handle_help_mode(app: &mut App, key: KeyEvent) {
237 match key.code {
238 KeyCode::Esc
239 | KeyCode::Char('?')
240 | KeyCode::Char('q')
241 | KeyCode::Enter
242 | KeyCode::Char(_) => {
243 app.set_mode(AppMode::Normal);
244 }
245 _ => {
246 app.set_mode(AppMode::Normal);
248 }
249 }
250}
251
252fn handle_error_mode(app: &mut App, _key: KeyEvent) {
256 app.set_mode(AppMode::Normal);
258}
259
260fn handle_multiselect_mode(
268 app: &mut App,
269 key: KeyEvent,
270 current_selected: &std::collections::HashSet<usize>,
271) {
272 match key.code {
273 KeyCode::Esc => {
275 app.set_mode(AppMode::Normal);
276 }
277
278 KeyCode::Char(' ') => {
280 app.toggle_current_selection();
281 }
282
283 KeyCode::Enter => {
285 app.run_multi_selected();
286 }
287
288 KeyCode::Char('a') => {
290 let mut selected = current_selected.clone();
291 for i in 0..app.visible_count() {
292 selected.insert(i);
293 }
294 app.set_mode(AppMode::MultiSelect { selected });
295 }
296
297 KeyCode::Char('n') => {
299 app.set_mode(AppMode::MultiSelect {
300 selected: std::collections::HashSet::new(),
301 });
302 }
303
304 KeyCode::Up | KeyCode::Char('k') => app.move_up(),
306 KeyCode::Down | KeyCode::Char('j') => app.move_down(),
307 KeyCode::Left | KeyCode::Char('h') => app.move_left(),
308 KeyCode::Right | KeyCode::Char('l') => app.move_right(),
309 KeyCode::Home | KeyCode::Char('g') => app.move_to_first(),
310 KeyCode::End | KeyCode::Char('G') => app.move_to_last(),
311
312 _ => {}
313 }
314}
315
316fn handle_args_mode(app: &mut App, key: KeyEvent, script_index: usize, current_input: &str) {
323 match key.code {
324 KeyCode::Esc => {
326 app.set_mode(AppMode::Normal);
327 }
328
329 KeyCode::Enter => {
331 app.run_with_args(current_input.to_string());
332 }
333
334 KeyCode::Backspace => {
336 let mut input = current_input.to_string();
337 input.pop();
338 app.set_mode(AppMode::Args {
339 script_index,
340 input,
341 });
342 }
343
344 KeyCode::Char(c) => {
346 let mut input = current_input.to_string();
347 input.push(c);
348 app.set_mode(AppMode::Args {
349 script_index,
350 input,
351 });
352 }
353
354 _ => {}
355 }
356}
357
358#[cfg(test)]
359mod tests {
360 use super::*;
361 use crate::config::Config;
362 use crate::history::History;
363 use crate::package::{Runner, Script, Scripts};
364 use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
365 use std::path::PathBuf;
366
367 fn create_test_scripts() -> Scripts {
368 let mut scripts = Scripts::new();
369 scripts.add(Script::new("dev", "vite"));
370 scripts.add(Script::new("build", "vite build"));
371 scripts.add(Script::new("test", "vitest"));
372 scripts.add(Script::new("lint", "eslint ."));
373 scripts.add(Script::new("format", "prettier --write ."));
374 scripts
375 }
376
377 fn create_test_app() -> App {
378 let scripts = create_test_scripts();
379 let config = Config::default();
380 let history = History::new();
381 App::new(
382 scripts,
383 config,
384 history,
385 "test-project".to_string(),
386 PathBuf::from("/test/project"),
387 Runner::Npm,
388 )
389 }
390
391 fn key_event(code: KeyCode) -> KeyEvent {
392 KeyEvent {
393 code,
394 modifiers: KeyModifiers::NONE,
395 kind: KeyEventKind::Press,
396 state: KeyEventState::NONE,
397 }
398 }
399
400 fn key_event_with_modifiers(code: KeyCode, modifiers: KeyModifiers) -> KeyEvent {
401 KeyEvent {
402 code,
403 modifiers,
404 kind: KeyEventKind::Press,
405 state: KeyEventState::NONE,
406 }
407 }
408
409 #[test]
412 fn test_normal_mode_navigation_arrows() {
413 let mut app = create_test_app();
414 app.update_columns(100); handle_normal_mode(&mut app, key_event(KeyCode::Down));
417 assert!(app.selected_index() > 0 || app.visible_count() <= 1);
418
419 handle_normal_mode(&mut app, key_event(KeyCode::Up));
420 assert_eq!(app.selected_index(), 0);
421 }
422
423 #[test]
424 fn test_normal_mode_navigation_vim() {
425 let mut app = create_test_app();
426 app.update_columns(100);
427
428 handle_normal_mode(&mut app, key_event(KeyCode::Char('j')));
429 let after_j = app.selected_index();
430
431 handle_normal_mode(&mut app, key_event(KeyCode::Char('k')));
432 assert_eq!(app.selected_index(), 0);
433
434 handle_normal_mode(&mut app, key_event(KeyCode::Char('l')));
436 assert_eq!(app.selected_index(), 1);
437
438 handle_normal_mode(&mut app, key_event(KeyCode::Char('h')));
439 assert_eq!(app.selected_index(), 0);
440
441 assert!(after_j > 0 || app.columns() == 1);
443 }
444
445 #[test]
446 fn test_normal_mode_navigation_home_end() {
447 let mut app = create_test_app();
448
449 handle_normal_mode(&mut app, key_event(KeyCode::End));
450 assert_eq!(app.selected_index(), app.visible_count() - 1);
451
452 handle_normal_mode(&mut app, key_event(KeyCode::Home));
453 assert_eq!(app.selected_index(), 0);
454 }
455
456 #[test]
457 fn test_normal_mode_navigation_g_shift_g() {
458 let mut app = create_test_app();
459
460 handle_normal_mode(&mut app, key_event(KeyCode::Char('G')));
461 assert_eq!(app.selected_index(), app.visible_count() - 1);
462
463 handle_normal_mode(&mut app, key_event(KeyCode::Char('g')));
464 assert_eq!(app.selected_index(), 0);
465 }
466
467 #[test]
468 fn test_normal_mode_run_selected() {
469 let mut app = create_test_app();
470
471 handle_normal_mode(&mut app, key_event(KeyCode::Enter));
472 assert!(app.should_quit());
473 assert!(app.script_to_run().is_some());
474 }
475
476 #[test]
477 fn test_normal_mode_run_selected_o() {
478 let mut app = create_test_app();
479
480 handle_normal_mode(&mut app, key_event(KeyCode::Char('o')));
481 assert!(app.should_quit());
482 assert!(app.script_to_run().is_some());
483 }
484
485 #[test]
486 fn test_normal_mode_quick_select() {
487 let mut app = create_test_app();
488
489 handle_normal_mode(&mut app, key_event(KeyCode::Char('3')));
490 assert!(app.should_quit());
491 assert!(app.script_to_run().is_some());
492 assert_eq!(app.selected_index(), 2);
493 }
494
495 #[test]
496 fn test_normal_mode_enter_filter() {
497 let mut app = create_test_app();
498
499 handle_normal_mode(&mut app, key_event(KeyCode::Char('/')));
500 assert!(matches!(app.mode(), AppMode::Filter { .. }));
501 }
502
503 #[test]
504 fn test_normal_mode_cycle_sort() {
505 let mut app = create_test_app();
506 let initial_sort = app.sort_mode();
507
508 handle_normal_mode(&mut app, key_event(KeyCode::Char('s')));
509 assert_ne!(app.sort_mode(), initial_sort);
510 }
511
512 #[test]
513 fn test_normal_mode_enter_args() {
514 let mut app = create_test_app();
515
516 handle_normal_mode(&mut app, key_event(KeyCode::Char('a')));
517 assert!(matches!(app.mode(), AppMode::Args { .. }));
518 }
519
520 #[test]
521 fn test_normal_mode_enter_multiselect() {
522 let mut app = create_test_app();
523
524 handle_normal_mode(&mut app, key_event(KeyCode::Char('m')));
525 assert!(matches!(app.mode(), AppMode::MultiSelect { .. }));
526 }
527
528 #[test]
529 fn test_normal_mode_toggle_help() {
530 let mut app = create_test_app();
531
532 handle_normal_mode(&mut app, key_event(KeyCode::Char('?')));
533 assert!(matches!(app.mode(), AppMode::Help));
534 }
535
536 #[test]
537 fn test_normal_mode_quit_q() {
538 let mut app = create_test_app();
539
540 handle_normal_mode(&mut app, key_event(KeyCode::Char('q')));
541 assert!(app.should_quit());
542 }
543
544 #[test]
545 fn test_normal_mode_quit_ctrl_c() {
546 let mut app = create_test_app();
547
548 let result = handle_key(
549 &mut app,
550 key_event_with_modifiers(KeyCode::Char('c'), KeyModifiers::CONTROL),
551 );
552 assert!(result);
553 assert!(app.should_quit());
554 }
555
556 #[test]
559 fn test_filter_mode_type_character() {
560 let mut app = create_test_app();
561 app.set_mode(AppMode::Filter {
562 query: String::new(),
563 });
564
565 handle_filter_mode(&mut app, key_event(KeyCode::Char('t')), "");
566 assert_eq!(app.filter_text(), "t");
567 }
568
569 #[test]
570 fn test_filter_mode_type_multiple_characters() {
571 let mut app = create_test_app();
572 app.set_mode(AppMode::Filter {
573 query: String::new(),
574 });
575
576 handle_filter_mode(&mut app, key_event(KeyCode::Char('t')), "");
577 handle_filter_mode(&mut app, key_event(KeyCode::Char('e')), "t");
578 handle_filter_mode(&mut app, key_event(KeyCode::Char('s')), "te");
579 assert_eq!(app.filter_text(), "tes");
580 }
581
582 #[test]
583 fn test_filter_mode_backspace() {
584 let mut app = create_test_app();
585 app.set_filter("test".to_string());
586
587 handle_filter_mode(&mut app, key_event(KeyCode::Backspace), "test");
588 assert_eq!(app.filter_text(), "tes");
589 }
590
591 #[test]
592 fn test_filter_mode_backspace_exits_when_empty() {
593 let mut app = create_test_app();
594 app.set_filter("t".to_string());
595
596 handle_filter_mode(&mut app, key_event(KeyCode::Backspace), "t");
597 assert!(matches!(app.mode(), AppMode::Normal));
598 assert_eq!(app.filter_text(), "");
599 }
600
601 #[test]
602 fn test_filter_mode_escape_clears_and_exits() {
603 let mut app = create_test_app();
604 app.set_filter("test".to_string());
605
606 handle_filter_mode(&mut app, key_event(KeyCode::Esc), "test");
607 assert!(matches!(app.mode(), AppMode::Normal));
608 assert_eq!(app.filter_text(), "");
609 }
610
611 #[test]
612 fn test_filter_mode_enter_runs_script() {
613 let mut app = create_test_app();
614 app.set_filter("dev".to_string());
615
616 handle_filter_mode(&mut app, key_event(KeyCode::Enter), "dev");
617 assert!(app.should_quit());
618 assert!(app.script_to_run().is_some());
619 }
620
621 #[test]
622 fn test_filter_mode_navigation() {
623 let mut app = create_test_app();
624 app.update_columns(100);
625 app.set_mode(AppMode::Filter {
626 query: String::new(),
627 });
628
629 handle_filter_mode(&mut app, key_event(KeyCode::Down), "");
630 let pos = app.selected_index();
631 assert!(pos > 0 || app.columns() == 1);
632
633 handle_filter_mode(&mut app, key_event(KeyCode::Up), "");
634 assert_eq!(app.selected_index(), 0);
635 }
636
637 #[test]
638 fn test_filter_mode_quick_select_empty_query() {
639 let mut app = create_test_app();
640 app.set_mode(AppMode::Filter {
641 query: String::new(),
642 });
643
644 handle_filter_mode(&mut app, key_event(KeyCode::Char('2')), "");
645 assert!(app.should_quit());
646 assert_eq!(app.selected_index(), 1);
647 }
648
649 #[test]
652 fn test_multiselect_toggle_selection() {
653 let mut app = create_test_app();
654 app.toggle_multi_select();
655
656 handle_multiselect_mode(
657 &mut app,
658 key_event(KeyCode::Char(' ')),
659 &std::collections::HashSet::new(),
660 );
661 let selected = app.multi_selected_indices().unwrap();
662 assert!(selected.contains(&0));
663 }
664
665 #[test]
666 fn test_multiselect_run_selected() {
667 let mut app = create_test_app();
668 app.toggle_multi_select();
669 app.toggle_current_selection();
670
671 let selected = app.multi_selected_indices().unwrap().clone();
672 handle_multiselect_mode(&mut app, key_event(KeyCode::Enter), &selected);
673 assert!(app.should_quit());
674 }
675
676 #[test]
677 fn test_multiselect_select_all() {
678 let mut app = create_test_app();
679 app.toggle_multi_select();
680
681 handle_multiselect_mode(
682 &mut app,
683 key_event(KeyCode::Char('a')),
684 &std::collections::HashSet::new(),
685 );
686 let selected = app.multi_selected_indices().unwrap();
687 assert_eq!(selected.len(), app.visible_count());
688 }
689
690 #[test]
691 fn test_multiselect_select_none() {
692 let mut app = create_test_app();
693 app.toggle_multi_select();
694 app.toggle_current_selection();
695 app.move_right();
696 app.toggle_current_selection();
697
698 let current_selected = app.multi_selected_indices().unwrap().clone();
699 handle_multiselect_mode(&mut app, key_event(KeyCode::Char('n')), ¤t_selected);
700 let selected = app.multi_selected_indices().unwrap();
701 assert!(selected.is_empty());
702 }
703
704 #[test]
705 fn test_multiselect_escape() {
706 let mut app = create_test_app();
707 app.toggle_multi_select();
708
709 handle_multiselect_mode(
710 &mut app,
711 key_event(KeyCode::Esc),
712 &std::collections::HashSet::new(),
713 );
714 assert!(matches!(app.mode(), AppMode::Normal));
715 }
716
717 #[test]
718 fn test_multiselect_navigation() {
719 let mut app = create_test_app();
720 app.update_columns(100);
721 app.toggle_multi_select();
722
723 handle_multiselect_mode(
724 &mut app,
725 key_event(KeyCode::Char('j')),
726 &std::collections::HashSet::new(),
727 );
728 assert!(app.selected_index() > 0 || app.columns() == 1);
729
730 handle_multiselect_mode(
731 &mut app,
732 key_event(KeyCode::Char('k')),
733 &std::collections::HashSet::new(),
734 );
735 assert_eq!(app.selected_index(), 0);
736 }
737
738 #[test]
741 fn test_args_mode_type_character() {
742 let mut app = create_test_app();
743 app.enter_args_mode();
744
745 handle_args_mode(&mut app, key_event(KeyCode::Char('-')), 0, "");
746
747 if let AppMode::Args { input, .. } = app.mode() {
748 assert_eq!(input, "-");
749 } else {
750 panic!("Expected Args mode");
751 }
752 }
753
754 #[test]
755 fn test_args_mode_type_multiple() {
756 let mut app = create_test_app();
757 app.enter_args_mode();
758
759 handle_args_mode(&mut app, key_event(KeyCode::Char('-')), 0, "");
760 handle_args_mode(&mut app, key_event(KeyCode::Char('-')), 0, "-");
761 handle_args_mode(&mut app, key_event(KeyCode::Char('w')), 0, "--");
762
763 if let AppMode::Args { input, .. } = app.mode() {
764 assert_eq!(input, "--w");
765 } else {
766 panic!("Expected Args mode");
767 }
768 }
769
770 #[test]
771 fn test_args_mode_backspace() {
772 let mut app = create_test_app();
773 app.set_mode(AppMode::Args {
774 script_index: 0,
775 input: "--watch".to_string(),
776 });
777
778 handle_args_mode(&mut app, key_event(KeyCode::Backspace), 0, "--watch");
779
780 if let AppMode::Args { input, .. } = app.mode() {
781 assert_eq!(input, "--watc");
782 } else {
783 panic!("Expected Args mode");
784 }
785 }
786
787 #[test]
788 fn test_args_mode_enter_runs_with_args() {
789 let mut app = create_test_app();
790 app.set_mode(AppMode::Args {
791 script_index: 0,
792 input: "--watch".to_string(),
793 });
794
795 handle_args_mode(&mut app, key_event(KeyCode::Enter), 0, "--watch");
796 assert!(app.should_quit());
797
798 let run = app.script_to_run().unwrap();
799 assert_eq!(run.args, Some("--watch".to_string()));
800 }
801
802 #[test]
803 fn test_args_mode_escape_cancels() {
804 let mut app = create_test_app();
805 app.set_mode(AppMode::Args {
806 script_index: 0,
807 input: "--watch".to_string(),
808 });
809
810 handle_args_mode(&mut app, key_event(KeyCode::Esc), 0, "--watch");
811 assert!(matches!(app.mode(), AppMode::Normal));
812 assert!(!app.should_quit());
813 }
814
815 #[test]
818 fn test_help_mode_any_key_closes() {
819 let mut app = create_test_app();
820 app.set_mode(AppMode::Help);
821
822 handle_help_mode(&mut app, key_event(KeyCode::Char('x')));
823 assert!(matches!(app.mode(), AppMode::Normal));
824 }
825
826 #[test]
827 fn test_help_mode_escape_closes() {
828 let mut app = create_test_app();
829 app.set_mode(AppMode::Help);
830
831 handle_help_mode(&mut app, key_event(KeyCode::Esc));
832 assert!(matches!(app.mode(), AppMode::Normal));
833 }
834
835 #[test]
838 fn test_error_mode_any_key_dismisses() {
839 let mut app = create_test_app();
840 app.set_mode(AppMode::Error {
841 message: "Test error".to_string(),
842 });
843
844 handle_error_mode(&mut app, key_event(KeyCode::Enter));
845 assert!(matches!(app.mode(), AppMode::Normal));
846 }
847
848 #[test]
851 fn test_resize_updates_columns() {
852 let mut app = create_test_app();
853 app.update_columns(50);
854 assert_eq!(app.columns(), 1);
855
856 let result = handle_event(&mut app, Event::Resize(100, 50)).unwrap();
857 assert!(!result);
858 assert_eq!(app.columns(), 3);
859 }
860
861 #[test]
864 fn test_handle_event_key() {
865 let mut app = create_test_app();
866
867 let result = handle_event(&mut app, Event::Key(key_event(KeyCode::Char('q')))).unwrap();
868 assert!(result);
869 assert!(app.should_quit());
870 }
871
872 #[test]
873 fn test_handle_event_unknown() {
874 let mut app = create_test_app();
875
876 let result = handle_event(&mut app, Event::FocusGained).unwrap();
878 assert!(!result);
879 assert!(!app.should_quit());
880 }
881}