1use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
2
3#[derive(Debug, Clone, Copy, PartialEq)]
4pub enum Mode {
5 Normal,
6 SpaceMenu,
7 ServicePicker,
8 ResourceFinder,
9 ColumnSelector,
10 FilterInput,
11 EventFilterInput,
12 InsightsInput,
13 ErrorModal,
14 HelpModal,
15 RegionPicker,
16 ProfilePicker,
17 CalendarPicker,
18 TabPicker,
19 SessionPicker,
20 QuitConfirm,
21}
22
23pub fn handle_key(key: KeyEvent, mode: Mode) -> Option<Action> {
24 match mode {
25 Mode::Normal => match key.code {
26 KeyCode::Char('q') => Some(Action::Quit),
27 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
28 Some(Action::ConfirmQuit)
29 }
30 KeyCode::Char('w') if key.modifiers.contains(KeyModifiers::CONTROL) => {
31 Some(Action::CloseService)
32 }
33 KeyCode::Char('o') if key.modifiers.contains(KeyModifiers::CONTROL) => {
34 Some(Action::OpenInConsole)
35 }
36 KeyCode::Char('l') if key.modifiers == KeyModifiers::CONTROL => {
37 Some(Action::OpenRelatedResources)
38 }
39 KeyCode::Char('r') if key.modifiers == KeyModifiers::CONTROL => Some(Action::Refresh),
40 KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
41 Some(Action::PageUp)
42 }
43 KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
44 Some(Action::PageDown)
45 }
46 KeyCode::Esc => Some(Action::GoBack),
47 KeyCode::Char('i') => Some(Action::StartFilter),
48 KeyCode::Char('c') => Some(Action::OpenCalendar),
49 KeyCode::Down => Some(Action::NextItem),
50 KeyCode::Up => Some(Action::PrevItem),
51 KeyCode::Right => Some(Action::ExpandRow),
52 KeyCode::Left => Some(Action::CollapseRow),
53 KeyCode::Tab => Some(Action::NextDetailTab),
54 KeyCode::BackTab => Some(Action::PrevDetailTab),
55 KeyCode::Enter => Some(Action::Select),
56 KeyCode::Char(' ') => Some(Action::OpenSpaceMenu),
57 KeyCode::Char('p') if key.modifiers.contains(KeyModifiers::CONTROL) => {
58 Some(Action::CopyToClipboard)
59 }
60 KeyCode::Char('p') => Some(Action::OpenColumnSelector),
61 KeyCode::Char('e') => Some(Action::ToggleExactMatch),
62 KeyCode::Char('x') => Some(Action::ToggleShowExpired),
63 KeyCode::Char('s') => Some(Action::CycleSortColumn),
64 KeyCode::Char('o') => Some(Action::ToggleSortDirection),
65 KeyCode::Char('m') if key.modifiers.is_empty() => Some(Action::LoadMore),
66 KeyCode::Char('y') => Some(Action::Yank),
67 KeyCode::Char('[') => Some(Action::PrevTab),
68 KeyCode::Char(']') => Some(Action::NextTab),
69 KeyCode::Char('?') => Some(Action::ShowHelp),
70 KeyCode::Char('N')
71 if key.modifiers.contains(KeyModifiers::CONTROL)
72 && key.modifiers.contains(KeyModifiers::SHIFT) =>
73 {
74 Some(Action::NextTab)
75 }
76 KeyCode::Char('P')
77 if key.modifiers.contains(KeyModifiers::CONTROL)
78 && key.modifiers.contains(KeyModifiers::SHIFT) =>
79 {
80 Some(Action::PrevTab)
81 }
82 KeyCode::Char(c) if c.is_ascii_digit() => Some(Action::FilterInput(c)),
83 KeyCode::Char('P') => Some(Action::ApplyFilter),
84 _ => None,
85 },
86 Mode::SpaceMenu => match key.code {
87 KeyCode::Esc => Some(Action::CloseMenu),
88 KeyCode::Char('o') => Some(Action::OpenServicePicker),
89 KeyCode::Char('r') => Some(Action::OpenRegionPicker),
90 KeyCode::Char('p') => Some(Action::OpenProfilePicker),
91 KeyCode::Char('a') => Some(Action::OpenCloudWatchAlarms),
92 KeyCode::Char('c') => Some(Action::CloseService),
93 KeyCode::Char('b') | KeyCode::Char('t') => Some(Action::OpenTabPicker),
94 KeyCode::Char('s') => Some(Action::OpenSessionPicker),
95 KeyCode::Char('f') => Some(Action::OpenResourceFinder),
96 KeyCode::Char('h') => Some(Action::ShowHelp),
97 _ => None,
98 },
99 Mode::ResourceFinder => match key.code {
100 KeyCode::Esc => Some(Action::CloseMenu),
101 KeyCode::Down => Some(Action::NextItem),
102 KeyCode::Up => Some(Action::PrevItem),
103 KeyCode::PageDown => Some(Action::PageDown),
104 KeyCode::PageUp => Some(Action::PageUp),
105 KeyCode::Enter => Some(Action::Select),
106 KeyCode::BackTab => Some(Action::PrevFilterFocus),
107 KeyCode::Tab if key.modifiers.contains(KeyModifiers::SHIFT) => {
108 Some(Action::PrevFilterFocus)
109 }
110 KeyCode::Tab => Some(Action::NextFilterFocus),
111 KeyCode::Backspace => Some(Action::FilterBackspace),
112 KeyCode::Char('r') if key.modifiers == KeyModifiers::CONTROL => Some(Action::Refresh),
113 KeyCode::Char('w') if key.modifiers == KeyModifiers::CONTROL => {
114 Some(Action::DeleteWord)
115 }
116 KeyCode::Char('c') if key.modifiers == KeyModifiers::CONTROL => Some(Action::CloseMenu),
117 KeyCode::Char(c)
118 if !key
119 .modifiers
120 .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
121 {
122 Some(Action::FilterInput(c))
123 }
124 _ => None,
125 },
126 Mode::ServicePicker => match key.code {
127 KeyCode::Esc => Some(Action::ExitFilterMode),
128 KeyCode::Down => Some(Action::NextItem),
129 KeyCode::Up => Some(Action::PrevItem),
130 KeyCode::Enter => Some(Action::Select),
131 KeyCode::Char('w') if key.modifiers.contains(KeyModifiers::CONTROL) => {
132 Some(Action::DeleteWord)
133 }
134 KeyCode::Left if key.modifiers.contains(KeyModifiers::ALT) => Some(Action::WordLeft),
135 KeyCode::Right if key.modifiers.contains(KeyModifiers::ALT) => Some(Action::WordRight),
136 KeyCode::Char(c) => Some(Action::FilterInput(c)),
137 KeyCode::Backspace => Some(Action::FilterBackspace),
138 _ => None,
139 },
140 Mode::ColumnSelector => match key.code {
141 KeyCode::Esc => Some(Action::CloseColumnSelector),
142 KeyCode::Down => Some(Action::NextItem),
143 KeyCode::Up => Some(Action::PrevItem),
144 KeyCode::Char(' ') | KeyCode::Enter => Some(Action::ToggleColumn),
145 KeyCode::Tab => Some(Action::NextPreferences),
146 KeyCode::BackTab => Some(Action::PrevPreferences),
147 KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
148 Some(Action::PageDown)
149 }
150 KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
151 Some(Action::PageUp)
152 }
153 _ => None,
154 },
155 Mode::FilterInput => match key.code {
156 KeyCode::Esc => Some(Action::CloseMenu),
157 KeyCode::Enter => Some(Action::ApplyFilter),
158 KeyCode::BackTab => Some(Action::PrevFilterFocus),
159 KeyCode::Tab if key.modifiers.contains(KeyModifiers::SHIFT) => {
160 Some(Action::PrevFilterFocus)
161 }
162 KeyCode::Tab => Some(Action::NextFilterFocus),
163 KeyCode::Up => Some(Action::PrevItem),
164 KeyCode::Down => Some(Action::NextItem),
165 KeyCode::Left if key.modifiers.contains(KeyModifiers::ALT) => Some(Action::WordLeft),
166 KeyCode::Right if key.modifiers.contains(KeyModifiers::ALT) => Some(Action::WordRight),
167 KeyCode::Left => Some(Action::PageUp),
168 KeyCode::Right => Some(Action::PageDown),
169 KeyCode::Char(' ') => Some(Action::ToggleFilterCheckbox),
170 KeyCode::Char('w') if key.modifiers.contains(KeyModifiers::CONTROL) => {
171 Some(Action::DeleteWord)
172 }
173 KeyCode::Char(c) if c != ' ' => Some(Action::FilterInput(c)),
174 KeyCode::Backspace => Some(Action::FilterBackspace),
175 _ => None,
176 },
177 Mode::EventFilterInput => match key.code {
178 KeyCode::Esc => Some(Action::CloseMenu),
179 KeyCode::Enter => Some(Action::ApplyFilter),
180 KeyCode::BackTab => Some(Action::PrevFilterFocus),
181 KeyCode::Tab if key.modifiers.contains(KeyModifiers::SHIFT) => {
182 Some(Action::PrevFilterFocus)
183 }
184 KeyCode::Tab => Some(Action::NextFilterFocus),
185 KeyCode::Char(' ') => Some(Action::ToggleFilterCheckbox),
186 KeyCode::Char('w') if key.modifiers.contains(KeyModifiers::CONTROL) => {
187 Some(Action::DeleteWord)
188 }
189 KeyCode::Left if key.modifiers.contains(KeyModifiers::ALT) => Some(Action::WordLeft),
190 KeyCode::Right if key.modifiers.contains(KeyModifiers::ALT) => Some(Action::WordRight),
191 KeyCode::Char(c) if c != ' ' => Some(Action::FilterInput(c)),
192 KeyCode::Backspace => Some(Action::FilterBackspace),
193 _ => None,
194 },
195 Mode::InsightsInput => match key.code {
196 KeyCode::Esc => Some(Action::CloseMenu),
197 KeyCode::Enter => Some(Action::Select),
198 KeyCode::Tab => Some(Action::NextFilterFocus),
199 KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::CONTROL) => {
200 Some(Action::Refresh)
201 }
202 KeyCode::Char('w') if key.modifiers.contains(KeyModifiers::CONTROL) => {
203 Some(Action::DeleteWord)
204 }
205 KeyCode::Left if key.modifiers.contains(KeyModifiers::ALT) => Some(Action::WordLeft),
206 KeyCode::Right if key.modifiers.contains(KeyModifiers::ALT) => Some(Action::WordRight),
207 KeyCode::Down => Some(Action::NextItem),
208 KeyCode::Up => Some(Action::PrevItem),
209 KeyCode::Char(' ') => Some(Action::ToggleFilterCheckbox),
210 KeyCode::Char(c) if c != ' ' => Some(Action::FilterInput(c)),
211 KeyCode::Backspace => Some(Action::FilterBackspace),
212 _ => None,
213 },
214 Mode::ErrorModal => match key.code {
215 KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::CONTROL) => {
216 Some(Action::RetryLoad)
217 }
218 KeyCode::Char('y') => Some(Action::Yank),
219 KeyCode::Char('q') | KeyCode::Esc => Some(Action::Quit),
220 _ => None,
221 },
222 Mode::HelpModal => match key.code {
223 KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') | KeyCode::Char('?') => {
224 Some(Action::CloseMenu)
225 }
226 _ => None,
227 },
228 Mode::QuitConfirm => match key.code {
229 KeyCode::Char('y') | KeyCode::Enter => Some(Action::ConfirmQuit),
230 KeyCode::Char('n') | KeyCode::Esc | KeyCode::Char('q') => Some(Action::CancelQuit),
231 _ => None,
232 },
233 Mode::RegionPicker => match key.code {
234 KeyCode::Esc => Some(Action::ExitFilterMode),
235 KeyCode::Char('i') => Some(Action::EnterFilterMode),
236 KeyCode::Char('j') | KeyCode::Down => Some(Action::NextItem),
237 KeyCode::Char('k') | KeyCode::Up => Some(Action::PrevItem),
238 KeyCode::Enter => Some(Action::Select),
239 KeyCode::Char('l') if key.modifiers.contains(KeyModifiers::CONTROL) => {
240 Some(Action::MeasureLatency)
241 }
242 KeyCode::Char('r') if key.modifiers == KeyModifiers::CONTROL => Some(Action::Refresh),
243 KeyCode::Backspace => Some(Action::FilterBackspace),
244 KeyCode::Char(c) => Some(Action::FilterInput(c)),
245 _ => None,
246 },
247 Mode::CalendarPicker => match key.code {
248 KeyCode::Esc => Some(Action::CloseCalendar),
249 KeyCode::Left => Some(Action::CalendarPrevDay),
250 KeyCode::Down => Some(Action::CalendarNextWeek),
251 KeyCode::Up => Some(Action::CalendarPrevWeek),
252 KeyCode::Right => Some(Action::CalendarNextDay),
253 KeyCode::Char('n') | KeyCode::Tab => Some(Action::CalendarNextMonth),
254 KeyCode::Char('p') | KeyCode::BackTab => Some(Action::CalendarPrevMonth),
255 KeyCode::Enter => Some(Action::CalendarSelect),
256 _ => None,
257 },
258 Mode::TabPicker => match key.code {
259 KeyCode::Esc => Some(Action::CloseMenu),
260 KeyCode::Down => Some(Action::NextItem),
261 KeyCode::Up => Some(Action::PrevItem),
262 KeyCode::Enter => Some(Action::Select),
263 KeyCode::Backspace => Some(Action::FilterBackspace),
264 KeyCode::Char(c) => Some(Action::FilterInput(c)),
265 _ => None,
266 },
267 Mode::SessionPicker => match key.code {
268 KeyCode::Esc => Some(Action::ExitFilterMode),
269 KeyCode::Char('i') => Some(Action::EnterFilterMode),
270 KeyCode::Down => Some(Action::NextItem),
271 KeyCode::Up => Some(Action::PrevItem),
272 KeyCode::Enter => Some(Action::LoadSession),
273 KeyCode::Char('r') if key.modifiers == KeyModifiers::CONTROL => Some(Action::Refresh),
274 KeyCode::Backspace => Some(Action::FilterBackspace),
275 KeyCode::Char(c) => Some(Action::FilterInput(c)),
276 _ => None,
277 },
278 Mode::ProfilePicker => match key.code {
279 KeyCode::Esc => Some(Action::ExitFilterMode),
280 KeyCode::Char('i') => Some(Action::EnterFilterMode),
281 KeyCode::Down => Some(Action::NextItem),
282 KeyCode::Up => Some(Action::PrevItem),
283 KeyCode::Enter => Some(Action::Select),
284 KeyCode::Char('r') if key.modifiers == KeyModifiers::CONTROL => Some(Action::Refresh),
285 KeyCode::Backspace => Some(Action::FilterBackspace),
286 KeyCode::Char(c) => Some(Action::FilterInput(c)),
287 _ => None,
288 },
289 }
290}
291
292#[derive(Debug, Clone, PartialEq)]
293pub enum Action {
294 Quit,
295 ConfirmQuit,
296 CancelQuit,
297 CloseService,
298 NextItem,
299 PrevItem,
300 NextPane,
301 PrevPane,
302 CollapseRow,
303 ExpandRow,
304 Select,
305 OpenSpaceMenu,
306 CloseMenu,
307 OpenServicePicker,
308 OpenResourceFinder,
309 OpenRelatedResources,
310 OpenCloudWatch,
311 OpenCloudWatchSplit,
312 OpenCloudWatchAlarms,
313 FilterInput(char),
314 FilterBackspace,
315 DeleteWord,
316 WordLeft,
317 WordRight,
318 OpenColumnSelector,
319 ToggleColumn,
320 NextPreferences,
321 PrevPreferences,
322 CloseColumnSelector,
323 StartFilter,
324 StartEventFilter,
325 ApplyFilter,
326 ToggleExactMatch,
327 ToggleShowExpired,
328 GoBack,
329 NextFilterFocus,
330 PrevFilterFocus,
331 ToggleFilterCheckbox,
332 CycleSortColumn,
333 ToggleSortDirection,
334 ScrollUp,
335 ScrollDown,
336 PageUp,
337 PageDown,
338 Refresh,
339 MeasureLatency,
340 RetryLoad,
341 LoadMore,
342 Yank,
343 OpenInConsole,
344 OpenInBrowser,
345 ShowHelp,
346 OpenRegionPicker,
347 OpenCalendar,
348 CloseCalendar,
349 CalendarPrevDay,
350 CalendarNextDay,
351 CalendarPrevWeek,
352 CalendarNextWeek,
353 CalendarPrevMonth,
354 CalendarNextMonth,
355 CalendarSelect,
356 NextTab,
357 PrevTab,
358 NextDetailTab,
359 PrevDetailTab,
360 CloseTab,
361 OpenTabPicker,
362 OpenSessionPicker,
363 OpenProfilePicker,
364 LoadSession,
365 SaveSession,
366 CopyToClipboard,
367 EnterFilterMode,
368 ExitFilterMode,
369 Noop,
370}
371
372#[cfg(test)]
373mod tests {
374 use super::*;
375
376 #[test]
377 fn test_space_o_opens_service_menu() {
378 let key = KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE);
379 let action = handle_key(key, Mode::SpaceMenu);
380 assert_eq!(action, Some(Action::OpenServicePicker));
381 }
382
383 #[test]
384 fn test_insights_input_accepts_chars() {
385 let key = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE);
386 let action = handle_key(key, Mode::InsightsInput);
387 assert_eq!(action, Some(Action::FilterInput('a')));
388
389 let key2 = KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE);
390 let action2 = handle_key(key2, Mode::InsightsInput);
391 assert_eq!(action2, Some(Action::FilterInput('1')));
392 }
393
394 #[test]
395 fn test_insights_input_esc_closes() {
396 let key = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
397 let action = handle_key(key, Mode::InsightsInput);
398 assert_eq!(action, Some(Action::CloseMenu));
399 }
400
401 #[test]
402 fn test_service_menu_accepts_input() {
403 let key = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE);
404 let action = handle_key(key, Mode::ServicePicker);
405 assert_eq!(action, Some(Action::FilterInput('c')));
406 }
407
408 #[test]
409 fn test_service_menu_navigation() {
410 let key_down = KeyEvent::new(KeyCode::Down, KeyModifiers::NONE);
411 let action_down = handle_key(key_down, Mode::ServicePicker);
412 assert_eq!(action_down, Some(Action::NextItem));
413
414 let key_up = KeyEvent::new(KeyCode::Up, KeyModifiers::NONE);
415 let action_up = handle_key(key_up, Mode::ServicePicker);
416 assert_eq!(action_up, Some(Action::PrevItem));
417 }
418
419 #[test]
420 fn test_service_menu_backspace() {
421 let key = KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE);
422 let action = handle_key(key, Mode::ServicePicker);
423 assert_eq!(action, Some(Action::FilterBackspace));
424 }
425
426 #[test]
427 fn test_ctrl_shift_n_next_tab() {
428 let key = KeyEvent::new(
429 KeyCode::Char('N'),
430 KeyModifiers::CONTROL | KeyModifiers::SHIFT,
431 );
432 let action = handle_key(key, Mode::Normal);
433 assert_eq!(action, Some(Action::NextTab));
434 }
435
436 #[test]
437 fn test_ctrl_shift_p_prev_tab() {
438 let key = KeyEvent::new(
439 KeyCode::Char('P'),
440 KeyModifiers::CONTROL | KeyModifiers::SHIFT,
441 );
442 let action = handle_key(key, Mode::Normal);
443 assert_eq!(action, Some(Action::PrevTab));
444 }
445
446 #[test]
447 fn test_space_c_close_tab() {
448 let key = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE);
449 let action = handle_key(key, Mode::SpaceMenu);
450 assert_eq!(action, Some(Action::CloseService));
451 }
452
453 #[test]
454 fn test_space_b_window_picker() {
455 let key = KeyEvent::new(KeyCode::Char('b'), KeyModifiers::NONE);
456 let action = handle_key(key, Mode::SpaceMenu);
457 assert_eq!(action, Some(Action::OpenTabPicker));
458 }
459
460 #[test]
461 fn test_window_picker_navigation() {
462 let key_down = KeyEvent::new(KeyCode::Down, KeyModifiers::NONE);
463 let action = handle_key(key_down, Mode::TabPicker);
464 assert_eq!(action, Some(Action::NextItem));
465
466 let key_up = KeyEvent::new(KeyCode::Up, KeyModifiers::NONE);
467 let action_up = handle_key(key_up, Mode::TabPicker);
468 assert_eq!(action_up, Some(Action::PrevItem));
469 }
470
471 #[test]
472 fn test_window_picker_select() {
473 let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
474 let action = handle_key(key, Mode::TabPicker);
475 assert_eq!(action, Some(Action::Select));
476 }
477
478 #[test]
479 fn test_space_opens_space_menu_in_normal_mode() {
480 let key = KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE);
481 let action = handle_key(key, Mode::Normal);
482 assert_eq!(action, Some(Action::OpenSpaceMenu));
483 }
484
485 #[test]
486 fn test_space_menu_o_opens_service_menu() {
487 let key = KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE);
488 let action = handle_key(key, Mode::SpaceMenu);
489 assert_eq!(action, Some(Action::OpenServicePicker));
490 }
491
492 #[test]
493 fn test_ctrl_r_refreshes_profile_picker() {
494 let key = KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL);
495 let action = handle_key(key, Mode::ProfilePicker);
496 assert_eq!(action, Some(Action::Refresh));
497 }
498
499 #[test]
500 fn test_ctrl_r_refreshes_region_picker() {
501 let key = KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL);
502 let action = handle_key(key, Mode::RegionPicker);
503 assert_eq!(action, Some(Action::Refresh));
504 }
505
506 #[test]
507 fn test_ctrl_r_refreshes_session_picker() {
508 let key = KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL);
509 let action = handle_key(key, Mode::SessionPicker);
510 assert_eq!(action, Some(Action::Refresh));
511 }
512
513 #[test]
514 fn test_p_opens_column_selector() {
515 let key = KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE);
516 let action = handle_key(key, Mode::Normal);
517 assert_eq!(
518 action,
519 Some(Action::OpenColumnSelector),
520 "p should open column selector (preferences)"
521 );
522 }
523
524 #[test]
525 fn test_ctrl_p_copies_to_clipboard() {
526 let key = KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL);
527 let action = handle_key(key, Mode::Normal);
528 assert_eq!(
529 action,
530 Some(Action::CopyToClipboard),
531 "Ctrl+P should copy screen to clipboard (print)"
532 );
533 }
534
535 #[test]
536 fn test_y_yanks_selected_item() {
537 let key = KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE);
538 let action = handle_key(key, Mode::Normal);
539 assert_eq!(
540 action,
541 Some(Action::Yank),
542 "y should yank (copy) selected item"
543 );
544 }
545
546 #[test]
547 fn test_space_toggles_checkbox_in_filter_input() {
548 let key = KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE);
549 let action = handle_key(key, Mode::FilterInput);
550 assert_eq!(
551 action,
552 Some(Action::ToggleFilterCheckbox),
553 "Space should toggle checkbox in FilterInput mode"
554 );
555 }
556
557 #[test]
558 fn test_space_not_added_to_filter_text() {
559 let key = KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE);
561 let action = handle_key(key, Mode::FilterInput);
562 assert_ne!(
563 action,
564 Some(Action::FilterInput(' ')),
565 "Space should not be added to filter text"
566 );
567 }
568
569 #[test]
570 fn test_q_in_normal_mode_shows_quit_confirmation() {
571 let key = KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE);
572 let action = handle_key(key, Mode::Normal);
573 assert_eq!(
574 action,
575 Some(Action::Quit),
576 "q in Normal mode must trigger Quit (shows confirmation dialog)"
577 );
578 }
579
580 #[test]
581 fn test_ctrl_c_quits_immediately_without_confirmation() {
582 let key = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
583 let action = handle_key(key, Mode::Normal);
584 assert_eq!(
585 action,
586 Some(Action::ConfirmQuit),
587 "Ctrl+C must quit immediately (ConfirmQuit, not Quit)"
588 );
589 }
590
591 #[test]
592 fn test_q_in_service_picker_shows_quit_confirmation() {
593 let key = KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE);
597 let action = handle_key(key, Mode::ServicePicker);
598 assert_eq!(
599 action,
600 Some(Action::FilterInput('q')),
601 "q in ServicePicker must dispatch FilterInput so it can type 'sqs' in filter"
602 );
603 }
604
605 #[test]
606 fn test_q_in_service_picker_not_filtering_triggers_quit() {
607 use crate::app::App;
609 let mut app = App::new_without_client("default".to_string(), None);
610 app.mode = Mode::ServicePicker;
611 app.service_picker.filter_active = false; app.handle_action(Action::FilterInput('q'));
614
615 assert_eq!(
616 app.mode,
617 Mode::QuitConfirm,
618 "q when not filtering in ServicePicker must show quit confirmation"
619 );
620 }
621
622 #[test]
623 fn test_q_in_service_picker_while_filtering_types_q() {
624 use crate::app::App;
626 let mut app = App::new_without_client("default".to_string(), None);
627 app.mode = Mode::ServicePicker;
628 app.service_picker.filter_active = true; app.handle_action(Action::FilterInput('q'));
631
632 assert_eq!(
633 app.mode,
634 Mode::ServicePicker,
635 "q while filtering must NOT quit"
636 );
637 assert_eq!(
638 app.service_picker.filter, "q",
639 "q while filtering must type 'q' into filter"
640 );
641 }
642
643 #[test]
644 fn test_i_in_service_picker_dispatches_filter_input() {
645 let key = KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE);
646 let action = handle_key(key, Mode::ServicePicker);
647 assert_eq!(
648 action,
649 Some(Action::FilterInput('i')),
650 "i in ServicePicker must dispatch FilterInput('i')"
651 );
652 }
653
654 #[test]
655 fn test_i_in_service_picker_not_filtering_activates_filter() {
656 use crate::app::App;
657 let mut app = App::new_without_client("default".to_string(), None);
658 app.mode = Mode::ServicePicker;
659 app.service_picker.filter_active = false;
660
661 app.handle_action(Action::FilterInput('i'));
662
663 assert!(app.service_picker.filter_active);
664 assert_eq!(
665 app.service_picker.filter, "",
666 "i activates filter but must not type itself"
667 );
668 }
669
670 #[test]
671 fn test_i_in_service_picker_while_filtering_types_i() {
672 use crate::app::App;
673 let mut app = App::new_without_client("default".to_string(), None);
674 app.mode = Mode::ServicePicker;
675 app.service_picker.filter_active = true;
676 app.service_picker.filter = "sqs".to_string();
677
678 app.handle_action(Action::FilterInput('i'));
679
680 assert_eq!(
681 app.service_picker.filter, "sqsi",
682 "i while filtering must type 'i' into filter"
683 );
684 assert_eq!(
685 app.mode,
686 Mode::ServicePicker,
687 "mode must stay ServicePicker"
688 );
689 }
690
691 #[test]
692 fn test_service_filter_types_all_chars_in_sqs() {
693 use crate::app::App;
694 let mut app = App::new_without_client("default".to_string(), None);
695 app.mode = Mode::ServicePicker;
696 app.service_picker.filter_active = true;
697
698 for c in ['s', 'q', 's'] {
699 app.handle_action(Action::FilterInput(c));
700 }
701
702 assert_eq!(app.service_picker.filter, "sqs");
703 assert_eq!(app.mode, Mode::ServicePicker);
704 }
705
706 #[test]
707 fn test_any_char_auto_activates_filter_in_service_picker() {
708 use crate::app::App;
710 let mut app = App::new_without_client("default".to_string(), None);
711 app.mode = Mode::ServicePicker;
712 app.service_picker.filter_active = false;
713
714 app.handle_action(Action::FilterInput('s'));
715
716 assert!(app.service_picker.filter_active);
717 assert_eq!(app.service_picker.filter, "s");
718 }
719
720 #[test]
721 fn test_quit_confirm_mode_y_confirms() {
722 let key = KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE);
723 let action = handle_key(key, Mode::QuitConfirm);
724 assert_eq!(action, Some(Action::ConfirmQuit));
725 }
726
727 #[test]
728 fn test_quit_confirm_mode_enter_confirms() {
729 let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
730 let action = handle_key(key, Mode::QuitConfirm);
731 assert_eq!(action, Some(Action::ConfirmQuit));
732 }
733
734 #[test]
735 fn test_quit_confirm_mode_n_cancels() {
736 let key = KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE);
737 let action = handle_key(key, Mode::QuitConfirm);
738 assert_eq!(action, Some(Action::CancelQuit));
739 }
740
741 #[test]
742 fn test_quit_confirm_mode_esc_cancels() {
743 let key = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
744 let action = handle_key(key, Mode::QuitConfirm);
745 assert_eq!(action, Some(Action::CancelQuit));
746 }
747
748 #[test]
749 fn test_quit_shows_confirmation_dialog_not_immediate_quit() {
750 use crate::app::{App, Service};
751 let mut app = App::new_without_client("default".to_string(), None);
752 app.current_service = Service::CloudWatchLogGroups;
753 app.service_selected = true;
754 app.mode = Mode::Normal;
755
756 app.handle_action(Action::Quit);
757
758 assert_eq!(
759 app.mode,
760 Mode::QuitConfirm,
761 "q must show confirmation dialog"
762 );
763 assert!(
764 app.running,
765 "app must still be running after q (before confirmation)"
766 );
767 }
768
769 #[test]
770 fn test_confirm_quit_actually_quits() {
771 use crate::app::App;
772 let mut app = App::new_without_client("default".to_string(), None);
773 app.mode = Mode::QuitConfirm;
774
775 app.handle_action(Action::ConfirmQuit);
776
777 assert!(!app.running, "ConfirmQuit must set running=false");
778 }
779
780 #[test]
781 fn test_cancel_quit_returns_to_normal() {
782 use crate::app::App;
783 let mut app = App::new_without_client("default".to_string(), None);
784 app.mode = Mode::QuitConfirm;
785
786 app.handle_action(Action::CancelQuit);
787
788 assert_eq!(
789 app.mode,
790 Mode::Normal,
791 "CancelQuit must return to Normal mode"
792 );
793 assert!(app.running, "app must still be running after cancel");
794 }
795}