1mod compliance;
9mod components;
10mod dependencies;
11mod graph_changes;
12mod helpers;
13mod licenses;
14mod matrix;
15pub mod mouse;
16pub(crate) use matrix::{pair_diff_scroll, set_pair_diff_scroll};
19mod multi_diff;
20mod quality;
21mod sidebyside;
22mod source;
23mod timeline;
24mod vulnerabilities;
25
26use crate::config::TuiPreferences;
27use crate::tui::toggle_theme;
28use crossterm::event::{
29 self, Event as CrosstermEvent, KeyCode, KeyEvent, KeyModifiers, MouseEvent,
30};
31use std::time::Duration;
32
33pub use mouse::handle_mouse_event;
34
35#[derive(Debug)]
37pub enum Event {
38 Key(KeyEvent),
40 Mouse(MouseEvent),
42 Tick,
44 Resize(u16, u16),
46}
47
48pub struct EventHandler {
50 tick_rate: Duration,
52}
53
54impl EventHandler {
55 pub const fn new(tick_rate: u64) -> Self {
57 Self {
58 tick_rate: Duration::from_millis(tick_rate),
59 }
60 }
61
62 pub fn next(&self) -> Result<Event, std::io::Error> {
64 if event::poll(self.tick_rate)? {
65 match event::read()? {
66 CrosstermEvent::Key(key) => Ok(Event::Key(key)),
67 CrosstermEvent::Mouse(mouse) => Ok(Event::Mouse(mouse)),
68 CrosstermEvent::Resize(width, height) => Ok(Event::Resize(width, height)),
69 _ => Ok(Event::Tick),
70 }
71 } else {
72 Ok(Event::Tick)
73 }
74 }
75}
76
77impl Default for EventHandler {
78 fn default() -> Self {
79 Self::new(250)
80 }
81}
82
83pub fn handle_key_event(app: &mut super::App, key: KeyEvent) {
85 app.clear_status_message();
87
88 if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
90 handle_yank(app);
91 return;
92 }
93
94 if app.overlays.search.active {
96 match key.code {
97 KeyCode::Esc => app.stop_search(),
98 KeyCode::Enter => {
99 app.jump_to_search_result();
101 }
102 KeyCode::Backspace => {
103 app.search_pop();
104 app.execute_search();
106 }
107 KeyCode::Up => app.overlays.search.select_prev(),
108 KeyCode::Down => app.overlays.search.select_next(),
109 KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::CONTROL) => {
111 use crate::tui::app_states::SearchMode;
112 app.overlays.search.mode = match app.overlays.search.mode {
113 SearchMode::Substring => SearchMode::Regex,
114 SearchMode::Regex => SearchMode::Substring,
115 };
116 app.execute_search();
118 let mode_name = app.overlays.search.mode.label();
119 app.set_status_message(format!("Search mode: {mode_name}"));
120 }
121 KeyCode::Char(c) => {
122 app.search_push(c);
123 app.execute_search();
125 }
126 _ => {}
127 }
128 return;
129 }
130
131 if app.overlays.threshold_tuning.visible {
133 match key.code {
134 KeyCode::Esc | KeyCode::Char('q') => {
135 app.overlays.threshold_tuning.visible = false;
136 }
137 KeyCode::Up | KeyCode::Char('k') => {
138 app.overlays.threshold_tuning.increase();
139 app.update_threshold_preview();
140 }
141 KeyCode::Down | KeyCode::Char('j') => {
142 app.overlays.threshold_tuning.decrease();
143 app.update_threshold_preview();
144 }
145 KeyCode::Right | KeyCode::Char('l' | '+' | '=') => {
146 app.overlays.threshold_tuning.fine_increase();
147 app.update_threshold_preview();
148 }
149 KeyCode::Left | KeyCode::Char('h' | '-' | '_') => {
150 app.overlays.threshold_tuning.fine_decrease();
151 app.update_threshold_preview();
152 }
153 KeyCode::Char('r') => {
154 app.overlays.threshold_tuning.reset();
155 app.update_threshold_preview();
156 }
157 KeyCode::Enter => {
158 app.apply_threshold();
159 }
160 _ => {}
161 }
162 return;
163 }
164
165 if app.overlays.view_switcher.visible {
169 match key.code {
170 KeyCode::Esc => app.overlays.view_switcher.hide(),
171 KeyCode::Up | KeyCode::Char('k') => app.overlays.view_switcher.previous(),
172 KeyCode::Down | KeyCode::Char('j') => app.overlays.view_switcher.next(),
173 KeyCode::Enter | KeyCode::Char(' ') => {
174 if let Some(view) = app.overlays.view_switcher.current_view() {
175 app.overlays.view_switcher.hide();
176 mouse::switch_to_view(app, view);
177 }
178 }
179 KeyCode::Char('1') => {
180 app.overlays.view_switcher.hide();
181 mouse::switch_to_view(app, super::app::MultiViewType::MultiDiff);
182 }
183 KeyCode::Char('2') => {
184 app.overlays.view_switcher.hide();
185 mouse::switch_to_view(app, super::app::MultiViewType::Timeline);
186 }
187 KeyCode::Char('3') => {
188 app.overlays.view_switcher.hide();
189 mouse::switch_to_view(app, super::app::MultiViewType::Matrix);
190 }
191 _ => {}
192 }
193 return;
194 }
195
196 if app.overlays.component_deep_dive.visible {
200 match key.code {
201 KeyCode::Esc | KeyCode::Char('q') => app.overlays.component_deep_dive.close(),
202 KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => {
203 app.overlays.component_deep_dive.next_section();
204 }
205 KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => {
206 app.overlays.component_deep_dive.prev_section();
207 }
208 _ => {}
209 }
210 return;
211 }
212
213 if app.has_overlay() {
215 match key.code {
216 KeyCode::Esc | KeyCode::Char('q') => app.close_overlays(),
217 KeyCode::Char('?' | 'K') | KeyCode::F(1) if app.overlays.shortcuts.visible => {
219 app.overlays.shortcuts.hide();
220 }
221 KeyCode::Down | KeyCode::Char('j') if app.overlays.shortcuts.visible => {
222 app.overlays.shortcuts.scroll_down();
223 }
224 KeyCode::Up | KeyCode::Char('k') if app.overlays.shortcuts.visible => {
225 app.overlays.shortcuts.scroll_up();
226 }
227 KeyCode::Char('e') if app.overlays.show_export => app.toggle_export(),
228 KeyCode::Char('j') if app.overlays.show_export => {
230 app.close_overlays();
231 dispatch_export(app, super::export::ExportFormat::Json);
232 }
233 KeyCode::Char('m') if app.overlays.show_export => {
234 app.close_overlays();
235 dispatch_export(app, super::export::ExportFormat::Markdown);
236 }
237 KeyCode::Char('h') if app.overlays.show_export => {
238 app.close_overlays();
239 dispatch_export(app, super::export::ExportFormat::Html);
240 }
241 KeyCode::Char('s') if app.overlays.show_export => {
242 app.close_overlays();
243 dispatch_export(app, super::export::ExportFormat::Sarif);
244 }
245 KeyCode::Char('c') if app.overlays.show_export => {
246 app.close_overlays();
247 dispatch_export(app, super::export::ExportFormat::Csv);
248 }
249 _ if app.overlays.show_legend => app.toggle_legend(),
251 _ => {}
252 }
253 return;
254 }
255
256 let consumed_by_tab = dispatch_tab_key(app, key);
263 let consumed_by_mode = dispatch_mode_key(app, key);
264
265 if !consumed_by_tab && !consumed_by_mode {
266 handle_global_fallback(app, key);
267 }
268}
269
270fn dispatch_tab_key(app: &mut super::App, key: KeyEvent) -> bool {
276 if app.mode != super::AppMode::Diff {
280 return false;
281 }
282 match app.active_tab {
283 super::TabKind::Components => components::handle_components_keys(app, key),
284 super::TabKind::Dependencies => dependencies::handle_dependencies_keys(app, key),
285 super::TabKind::Licenses => licenses::handle_licenses_keys(app, key),
286 super::TabKind::Vulnerabilities => vulnerabilities::handle_vulnerabilities_keys(app, key),
287 super::TabKind::Quality => quality::handle_quality_keys(app, key),
288 super::TabKind::Compliance => compliance::handle_diff_compliance_keys(app, key),
289 super::TabKind::GraphChanges => graph_changes::handle_graph_changes_keys(app, key),
290 super::TabKind::SideBySide => sidebyside::handle_sidebyside_keys(app, key),
291 super::TabKind::Source => source::handle_source_keys(app, key),
292 super::TabKind::Summary => false,
293 }
294}
295
296fn dispatch_mode_key(app: &mut super::App, key: KeyEvent) -> bool {
301 match app.mode {
302 super::AppMode::MultiDiff => multi_diff::handle_multi_diff_keys(app, key),
303 super::AppMode::Timeline => timeline::handle_timeline_keys(app, key),
304 super::AppMode::Matrix => matrix::handle_matrix_keys(app, key),
305 super::AppMode::Diff => false,
306 }
307}
308
309fn handle_global_fallback(app: &mut super::App, key: KeyEvent) {
314 match key.code {
315 KeyCode::Char('q') => {
316 let mut prefs = crate::config::TuiPreferences::load();
318 prefs.last_tab = Some(app.active_tab.as_str().to_string());
319 let _ = prefs.save();
320 app.should_quit = true;
321 }
322 KeyCode::Char('?') => open_shortcuts_overlay(app),
323 KeyCode::Char('e') => app.toggle_export(),
324 KeyCode::Char('l') if app.mode == super::AppMode::Diff => app.toggle_legend(),
328 KeyCode::Char('t') if app.mode == super::AppMode::Diff => {
331 app.toggle_threshold_tuning();
332 }
333 KeyCode::Char('T') => {
334 let before = crate::tui::theme::current_theme_name();
339 let theme_name = toggle_theme();
340 if theme_name != before
341 && let Ok(parsed) = theme_name.parse()
342 {
343 let mut prefs = TuiPreferences::load();
344 prefs.theme = parsed;
345 let _ = prefs.save();
346 }
347 }
348 KeyCode::Char('V') => {
350 if matches!(
351 app.mode,
352 super::AppMode::MultiDiff | super::AppMode::Timeline | super::AppMode::Matrix
353 ) {
354 app.overlays.view_switcher.toggle();
355 }
356 }
357 KeyCode::Char('K') | KeyCode::F(1) => open_shortcuts_overlay(app),
359 KeyCode::Char('D') => {
366 if app.mode == super::AppMode::Diff {
367 helpers::open_diff_component_deep_dive(app);
368 }
369 }
370 KeyCode::Char('P') => {
372 if matches!(app.mode, super::AppMode::Diff) {
373 app.run_compliance_check();
374 }
375 }
376 KeyCode::Char('p') => {
380 if app.mode == super::AppMode::Diff && app.active_tab == super::TabKind::Summary {
381 app.next_policy();
382 }
383 }
384 KeyCode::Char('y') => {
386 handle_yank(app);
387 }
388 KeyCode::Esc => app.close_overlays(),
389 KeyCode::Char('b') | KeyCode::Backspace => {
390 if app.has_navigation_history() {
392 app.navigate_back();
393 }
394 }
395 KeyCode::Tab => {
396 if key.modifiers.contains(KeyModifiers::SHIFT) {
397 app.prev_tab();
398 } else {
399 app.next_tab();
400 }
401 }
402 KeyCode::BackTab if app.mode == super::AppMode::Diff => {
407 app.prev_tab();
408 }
409 KeyCode::Char('/') => app.start_search(),
410 KeyCode::Char(c @ '1'..='6') if app.mode == super::AppMode::Diff => {
413 app.select_tab(match c {
414 '1' => super::TabKind::Summary,
415 '2' => super::TabKind::Components,
416 '3' => super::TabKind::Dependencies,
417 '4' => super::TabKind::Licenses,
418 '5' => super::TabKind::Vulnerabilities,
419 _ => super::TabKind::Quality,
420 });
421 }
422 KeyCode::Char('7') => {
423 if app.mode == super::AppMode::Diff {
425 app.select_tab(super::TabKind::Compliance);
426 }
427 }
428 KeyCode::Char('8') => {
429 if app.mode == super::AppMode::Diff {
431 app.select_tab(super::TabKind::SideBySide);
432 }
433 }
434 KeyCode::Char('9') => {
435 let has_graph = app
439 .data
440 .diff_result
441 .as_ref()
442 .is_some_and(|r| !r.graph_changes.is_empty());
443 if app.mode == super::AppMode::Diff {
444 if has_graph {
445 app.select_tab(super::TabKind::GraphChanges);
446 } else {
447 app.select_tab(super::TabKind::Source);
448 }
449 }
450 }
451 KeyCode::Char('0') => {
452 let has_graph = app
454 .data
455 .diff_result
456 .as_ref()
457 .is_some_and(|r| !r.graph_changes.is_empty());
458 if has_graph && app.mode == super::AppMode::Diff {
459 app.select_tab(super::TabKind::Source);
460 }
461 }
462 KeyCode::Up | KeyCode::Char('k') => app.select_up(),
464 KeyCode::Down | KeyCode::Char('j') => app.select_down(),
465 KeyCode::PageUp => app.page_up(),
466 KeyCode::PageDown => app.page_down(),
467 KeyCode::Home | KeyCode::Char('g') if !key.modifiers.contains(KeyModifiers::SHIFT) => {
468 app.select_first();
469 }
470 KeyCode::End | KeyCode::Char('G') => app.select_last(),
471 _ => {}
472 }
473}
474
475pub fn get_yank_text(app: &super::App) -> Option<String> {
479 match app.active_tab {
480 super::TabKind::Components => helpers::get_selected_component_name(app),
481 super::TabKind::SideBySide => {
484 if app.side_by_side_state().alignment_mode.uses_row_selection() {
485 sidebyside::get_current_row_info(app)
486 } else {
487 None
488 }
489 }
490 super::TabKind::Vulnerabilities => {
491 let idx = app.vulnerabilities_state().selected;
492 let result = app.data.diff_result.as_ref()?;
493 let vulns: Vec<_> = result
494 .vulnerabilities
495 .introduced
496 .iter()
497 .chain(result.vulnerabilities.resolved.iter())
498 .collect();
499 vulns.get(idx).map(|v| v.id.clone())
500 }
501 super::TabKind::Dependencies => {
502 let node = app.dependencies_state().get_selected_node_id()?;
506 if node.starts_with("__") {
507 return None;
508 }
509 let idx = app.dependencies_state().selected;
510 let result = app.data.diff_result.as_ref()?;
511 let deps: Vec<_> = result
512 .dependencies
513 .added
514 .iter()
515 .chain(result.dependencies.removed.iter())
516 .collect();
517 deps.get(idx)
518 .map(|dep| format!("{} → {}", dep.from, dep.to))
519 }
520 super::TabKind::Licenses => {
521 let idx = app.licenses_state().selected;
522 let result = app.data.diff_result.as_ref()?;
523 let licenses: Vec<_> = result
524 .licenses
525 .new_licenses
526 .iter()
527 .chain(result.licenses.removed_licenses.iter())
528 .collect();
529 licenses.get(idx).map(|lic| lic.license.clone())
530 }
531 super::TabKind::Quality => {
532 let report = app
533 .data
534 .new_quality
535 .as_ref()
536 .or(app.data.old_quality.as_ref())?;
537 report
538 .recommendations
539 .get(app.quality_state().selected_recommendation)
540 .map(|rec| rec.message.clone())
541 }
542 super::TabKind::Compliance => {
543 if app.diff_compliance_state().view_mode
546 == super::app_states::DiffComplianceViewMode::Overview
547 {
548 return None;
549 }
550 let results = app
551 .data
552 .new_compliance_results
553 .as_ref()
554 .or(app.data.old_compliance_results.as_ref())?;
555 let result = results.get(app.diff_compliance_state().selected_standard)?;
556 result
557 .violations
558 .get(app.diff_compliance_state().selected_violation)
559 .map(|v| v.message.clone())
560 }
561 super::TabKind::Source => {
562 let source = app.source_state();
563 let panel = match source.active_side {
564 crate::tui::app_states::SourceSide::Old => &source.old_panel,
565 crate::tui::app_states::SourceSide::New => &source.new_panel,
566 };
567 match panel.view_mode {
568 super::app_states::SourceViewMode::Tree => {
569 panel.cached_flat_items.get(panel.selected).map(|item| {
571 if !item.value_preview.is_empty() {
572 let v = &item.value_preview;
574 if v.starts_with('"') && v.ends_with('"') && v.len() >= 2 {
575 v[1..v.len() - 1].to_string()
576 } else {
577 v.clone()
578 }
579 } else {
580 item.node_id.clone()
581 }
582 })
583 }
584 super::app_states::SourceViewMode::Raw => panel
585 .raw_lines
586 .get(panel.selected)
587 .map(|line| line.trim().to_string()),
588 }
589 }
590 _ => None,
591 }
592}
593
594fn handle_yank(app: &mut super::App) {
596 let Some(text) = get_yank_text(app) else {
597 app.set_status_message("Nothing selected to copy");
598 return;
599 };
600
601 if crate::tui::clipboard::copy_to_clipboard(&text) {
602 let display = if text.len() > 50 {
603 let end = crate::tui::shared::floor_char_boundary(&text, 47);
604 format!("{}...", &text[..end])
605 } else {
606 text
607 };
608 app.set_status_message(format!("Copied: {display}"));
609 } else {
610 app.set_status_message("Failed to copy to clipboard");
611 }
612}
613
614fn dispatch_export(app: &mut super::App, format: crate::tui::export::ExportFormat) {
617 if app.mode == super::AppMode::Diff && app.active_tab == super::TabKind::Compliance {
620 app.export_compliance(format);
621 } else {
622 app.export(format);
623 }
624}
625
626fn open_shortcuts_overlay(app: &mut super::App) {
629 let context = match app.mode {
630 super::AppMode::MultiDiff => super::app::ShortcutsContext::MultiDiff,
631 super::AppMode::Timeline => super::app::ShortcutsContext::Timeline,
632 super::AppMode::Matrix => super::app::ShortcutsContext::Matrix,
633 super::AppMode::Diff => super::app::ShortcutsContext::Diff,
634 };
635 let tab = app.active_view_state().map(|v| {
636 (
637 app.active_tab.title().to_string(),
638 v.shortcuts()
639 .into_iter()
640 .map(|s| (s.key, s.description))
641 .collect::<Vec<_>>(),
642 )
643 });
644 match tab {
645 Some((title, items)) => app
646 .overlays
647 .shortcuts
648 .show_with_tab(context, Some(title), items),
649 None => app.overlays.shortcuts.show(context),
650 }
651}
652
653#[cfg(test)]
654mod dispatch_precedence_tests {
655 use super::{dispatch_tab_key, handle_key_event};
671 use crate::tui::test_support::{DEMO_NEW, DEMO_OLD, demo_diff, pin_theme};
672 use crate::tui::{App, TabKind};
673 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
674
675 fn diff_app(active_tab: TabKind) -> App {
676 pin_theme();
677 let (diff, old, new) = demo_diff();
678 let mut app = App::new_diff(diff, old, new, DEMO_OLD, DEMO_NEW);
679 app.active_tab = active_tab;
680 app
681 }
682
683 fn k(code: KeyCode) -> KeyEvent {
684 KeyEvent::new(code, KeyModifiers::NONE)
685 }
686
687 #[test]
694 fn nav_keys_route_to_the_correct_layer() {
695 for tab in [
696 TabKind::SideBySide,
697 TabKind::Quality,
698 TabKind::Compliance,
699 TabKind::GraphChanges,
700 TabKind::Licenses,
701 TabKind::Dependencies,
702 ] {
703 let mut app = diff_app(tab);
704 assert!(
705 dispatch_tab_key(&mut app, k(KeyCode::Char('j'))),
706 "{tab:?} must consume 'j' locally (global select_down is a no-op there)"
707 );
708 assert!(
709 dispatch_tab_key(&mut app, k(KeyCode::Char('k'))),
710 "{tab:?} must consume 'k' locally"
711 );
712 }
713
714 for tab in [
715 TabKind::Components,
716 TabKind::Vulnerabilities,
717 TabKind::Source,
718 ] {
719 let mut app = diff_app(tab);
720 assert!(
721 !dispatch_tab_key(&mut app, k(KeyCode::Char('j'))),
722 "{tab:?} must defer 'j' to the global list navigation"
723 );
724 assert!(
725 !dispatch_tab_key(&mut app, k(KeyCode::Char('k'))),
726 "{tab:?} must defer 'k' to the global list navigation"
727 );
728 }
729 }
730
731 #[test]
734 fn tab_bindings_win_over_colliding_global_bindings() {
735 assert!(
738 dispatch_tab_key(&mut diff_app(TabKind::SideBySide), k(KeyCode::Char('/'))),
739 "SideBySide consumes '/' for its own search"
740 );
741 assert!(
742 !dispatch_tab_key(&mut diff_app(TabKind::Components), k(KeyCode::Char('/'))),
743 "Components has no '/'; it falls through to the global search overlay"
744 );
745
746 assert!(
750 !dispatch_tab_key(&mut diff_app(TabKind::Components), k(KeyCode::Char('1'))),
751 "Components must NOT consume bare '1'; digits are tab jumps"
752 );
753 assert!(
754 !dispatch_tab_key(&mut diff_app(TabKind::SideBySide), k(KeyCode::Char('1'))),
755 "SideBySide must NOT consume bare '1'; digits are tab jumps"
756 );
757 assert!(
758 !dispatch_tab_key(&mut diff_app(TabKind::Summary), k(KeyCode::Char('1'))),
759 "Summary has no handler; '1' falls through to the global tab-select"
760 );
761 let mut app = diff_app(TabKind::Components);
763 assert!(
764 dispatch_tab_key(&mut app, k(KeyCode::Char('Q'))),
765 "'Q' opens the quick-filter picker"
766 );
767 assert!(
768 dispatch_tab_key(&mut app, k(KeyCode::Char('1'))),
769 "digits toggle filters while the picker is open"
770 );
771
772 for tab in [TabKind::Components, TabKind::SideBySide] {
774 assert!(
775 dispatch_tab_key(&mut diff_app(tab), k(KeyCode::Char('p'))),
776 "{tab:?} consumes 'p' for panel focus, not global next-policy"
777 );
778 }
779 }
780
781 #[test]
785 fn universal_keys_are_never_consumed_by_a_tab() {
786 for tab in [
787 TabKind::Components,
788 TabKind::Licenses,
789 TabKind::SideBySide,
790 TabKind::Vulnerabilities,
791 TabKind::Dependencies,
792 TabKind::Source,
793 TabKind::Quality,
794 TabKind::Compliance,
795 TabKind::GraphChanges,
796 ] {
797 assert!(
798 !dispatch_tab_key(&mut diff_app(tab), k(KeyCode::Tab)),
799 "'Tab' must fall through to global tab-switching on {tab:?}"
800 );
801 assert!(
802 !dispatch_tab_key(&mut diff_app(tab), k(KeyCode::BackTab)),
803 "'BackTab' must fall through to global tab-switching on {tab:?}"
804 );
805 assert!(
806 !dispatch_tab_key(&mut diff_app(tab), k(KeyCode::Char('q'))),
807 "'q' (quit) must fall through to the global fallback on {tab:?}"
808 );
809 }
810 }
811
812 #[test]
815 fn global_fallback_only_fires_for_unconsumed_keys() {
816 let mut app = diff_app(TabKind::Components);
819 handle_key_event(&mut app, k(KeyCode::Char('1')));
820 assert_eq!(
821 app.active_tab,
822 TabKind::Summary,
823 "'1' on Components must jump to Summary like the tab bar says"
824 );
825
826 let mut app = diff_app(TabKind::Components);
828 handle_key_event(&mut app, k(KeyCode::Char('Q')));
829 handle_key_event(&mut app, k(KeyCode::Char('1')));
830 assert_eq!(
831 app.active_tab,
832 TabKind::Components,
833 "'1' inside the Q picker toggles a filter; no tab switch"
834 );
835 assert!(
836 app.components_state().security_filter.has_active_filters(),
837 "the digit must have toggled a quick filter"
838 );
839
840 let mut app = diff_app(TabKind::SideBySide);
842 handle_key_event(&mut app, k(KeyCode::Char('/')));
843 assert!(
844 !app.overlays.search.active,
845 "SideBySide '/' must not open the global search overlay"
846 );
847 assert!(
848 app.side_by_side_state().search_active,
849 "SideBySide '/' activates the tab-local search"
850 );
851
852 let mut app = diff_app(TabKind::Components);
854 handle_key_event(&mut app, k(KeyCode::Tab));
855 assert_ne!(
856 app.active_tab,
857 TabKind::Components,
858 "Tab must switch tabs from Components, not toggle panel focus"
859 );
860 }
861
862 #[test]
866 fn enter_opens_populated_deep_dive_on_components() {
867 let mut app = diff_app(TabKind::Components);
868 handle_key_event(&mut app, k(KeyCode::Enter));
869 let dive = &app.overlays.component_deep_dive;
870 assert!(dive.visible, "Enter must open the component deep dive");
871 assert!(dive.component_id.is_some(), "no 'ID: Unknown' modal");
872 assert!(
873 !dive.collected_data.version_history.is_empty(),
874 "deep dive must open populated, not hollow"
875 );
876
877 handle_key_event(&mut app, k(KeyCode::Tab));
878 assert_eq!(
879 app.overlays.component_deep_dive.active_section, 1,
880 "Tab must switch deep-dive sections as the footer advertises"
881 );
882 handle_key_event(&mut app, k(KeyCode::Esc));
883 assert!(!app.overlays.component_deep_dive.visible);
884 }
885
886 #[test]
890 fn t_opens_threshold_tuning_except_on_dependencies() {
891 let mut app = diff_app(TabKind::Summary);
892 handle_key_event(&mut app, k(KeyCode::Char('t')));
893 assert!(
894 app.overlays.threshold_tuning.visible,
895 "'t' must open threshold tuning on Summary"
896 );
897 let before = app.overlays.threshold_tuning.threshold;
898 handle_key_event(&mut app, k(KeyCode::Char('-')));
899 assert!(
900 app.overlays.threshold_tuning.threshold < before,
901 "'-' must fine-decrease as the overlay footer advertises"
902 );
903 handle_key_event(&mut app, k(KeyCode::Esc));
904 assert!(!app.overlays.threshold_tuning.visible);
905
906 let mut app = diff_app(TabKind::Dependencies);
907 handle_key_event(&mut app, k(KeyCode::Char('t')));
908 assert!(
909 !app.overlays.threshold_tuning.visible,
910 "Dependencies 't' stays the transitive toggle"
911 );
912 }
913
914 #[test]
917 fn p_policy_cycle_is_scoped_to_summary_with_status() {
918 let mut app = diff_app(TabKind::Summary);
919 let before = app.compliance_state.policy_preset;
920 handle_key_event(&mut app, k(KeyCode::Char('p')));
921 assert_ne!(app.compliance_state.policy_preset, before);
922 assert!(
923 app.status_message.is_some(),
924 "policy cycling must never be silent"
925 );
926
927 let mut app = diff_app(TabKind::Vulnerabilities);
928 let before = app.compliance_state.policy_preset;
929 handle_key_event(&mut app, k(KeyCode::Char('p')));
930 assert_eq!(
931 app.compliance_state.policy_preset, before,
932 "'p' must not mutate policy state from tabs without the widget"
933 );
934 }
935
936 fn multi_apps() -> Vec<App> {
941 use crate::tui::test_support::{demo_matrix, demo_multi_diff, demo_timeline};
942 pin_theme();
943 vec![
944 App::new_multi_diff(demo_multi_diff()),
945 App::new_timeline(demo_timeline()),
946 App::new_matrix(demo_matrix()),
947 ]
948 }
949
950 #[test]
955 fn multi_modes_never_dispatch_to_hidden_tab_handlers() {
956 for mut app in multi_apps() {
957 for hidden_tab in [TabKind::Components, TabKind::SideBySide] {
958 app.active_tab = hidden_tab;
959 for key in ['f', '1', '/'] {
960 assert!(
961 !dispatch_tab_key(&mut app, k(KeyCode::Char(key))),
962 "{:?} with hidden {hidden_tab:?} must not consume '{key}' in an invisible tab handler",
963 app.mode
964 );
965 }
966 }
967 }
968 }
969
970 #[test]
974 fn multi_mode_export_dialog_is_real_and_legend_is_gated() {
975 for mut app in multi_apps() {
976 let mode = app.mode;
977 handle_key_event(&mut app, k(KeyCode::Char('e')));
978 assert!(
979 app.overlays.show_export,
980 "'e' must open the export dialog in {mode:?}"
981 );
982 handle_key_event(&mut app, k(KeyCode::Char('m')));
985 assert!(!app.overlays.show_export, "the dialog closes on a pick");
986 assert!(
987 app.status_message
988 .as_deref()
989 .is_some_and(|m| m.contains("Export failed")),
990 "non-JSON pick must explain itself in {mode:?}, got {:?}",
991 app.status_message
992 );
993
994 handle_key_event(&mut app, k(KeyCode::Char('l')));
995 assert!(
996 !app.overlays.show_legend,
997 "the Diff-only legend must never open in {mode:?}"
998 );
999 }
1000 }
1001
1002 #[test]
1004 fn legend_closes_on_any_key() {
1005 let mut app = diff_app(TabKind::Summary);
1006 handle_key_event(&mut app, k(KeyCode::Char('l')));
1007 assert!(app.overlays.show_legend, "'l' opens the legend in Diff");
1008 handle_key_event(&mut app, k(KeyCode::Char('x')));
1009 assert!(
1010 !app.overlays.show_legend,
1011 "any key must close the legend as its footer promises"
1012 );
1013 }
1014
1015 #[test]
1019 fn digit_tab_select_is_inert_in_multi_modes() {
1020 for mut app in multi_apps() {
1021 for hidden_tab in [TabKind::Components, TabKind::SideBySide] {
1022 app.active_tab = hidden_tab;
1023 handle_key_event(&mut app, k(KeyCode::Char('2')));
1024 assert_eq!(
1025 app.active_tab, hidden_tab,
1026 "'2' in {:?} must not mutate the hidden active_tab",
1027 app.mode
1028 );
1029 }
1030 }
1031 }
1032}