Skip to main content

sbom_tools/tui/events/
mod.rs

1//! Event handling for the TUI.
2//!
3//! This module provides event handling for the TUI, including:
4//! - Key and mouse event polling
5//! - Event dispatch to the appropriate handlers
6//! - Integration with the `EventResult` type from `traits`
7
8mod compliance;
9mod components;
10mod dependencies;
11mod graph_changes;
12mod helpers;
13mod licenses;
14mod matrix;
15pub mod mouse;
16// Pair-diff modal scroll state: shared with the render side
17// (views::matrix), which clamps the offset against the modal's line count.
18pub(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/// Application event
36#[derive(Debug)]
37pub enum Event {
38    /// Key press event
39    Key(KeyEvent),
40    /// Mouse event
41    Mouse(MouseEvent),
42    /// Terminal tick (for animations)
43    Tick,
44    /// Resize event
45    Resize(u16, u16),
46}
47
48/// Event handler
49pub struct EventHandler {
50    /// Tick rate in milliseconds
51    tick_rate: Duration,
52}
53
54impl EventHandler {
55    /// Create a new event handler
56    pub const fn new(tick_rate: u64) -> Self {
57        Self {
58            tick_rate: Duration::from_millis(tick_rate),
59        }
60    }
61
62    /// Poll for the next event
63    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
83/// Handle key events and update app state
84pub fn handle_key_event(app: &mut super::App, key: KeyEvent) {
85    // Clear any status message on key press
86    app.clear_status_message();
87
88    // Ctrl+C copies the selected item (universal shortcut)
89    if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
90        handle_yank(app);
91        return;
92    }
93
94    // Handle search mode separately
95    if app.overlays.search.active {
96        match key.code {
97            KeyCode::Esc => app.stop_search(),
98            KeyCode::Enter => {
99                // Jump to selected search result
100                app.jump_to_search_result();
101            }
102            KeyCode::Backspace => {
103                app.search_pop();
104                // Live search as user types
105                app.execute_search();
106            }
107            KeyCode::Up => app.overlays.search.select_prev(),
108            KeyCode::Down => app.overlays.search.select_next(),
109            // Ctrl+R toggles between substring and regex search mode
110            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                // Re-execute search with new mode
117                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                // Live search as user types
124                app.execute_search();
125            }
126            _ => {}
127        }
128        return;
129    }
130
131    // Handle threshold tuning overlay
132    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    // Handle view switcher overlay (for multi-comparison modes). Must run
166    // BEFORE the generic has_overlay() branch: that branch would swallow the
167    // switcher's j/k/Enter/1-3 keys and leave it navigable only by Esc.
168    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    // Handle component deep dive modal. Also before has_overlay(): the
197    // generic branch would swallow Tab/arrows, making the advertised
198    // "Tab/Arrow switch section" footer a lie.
199    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    // Handle overlays (help, export, legend)
214    if app.has_overlay() {
215        match key.code {
216            KeyCode::Esc | KeyCode::Char('q') => app.close_overlays(),
217            // The single ?/K overlay toggles closed on the keys that open it.
218            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            // Export format selection in export dialog
229            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            // The legend promises "Press any key to close" — honor it.
250            _ if app.overlays.show_legend => app.toggle_legend(),
251            _ => {}
252        }
253        return;
254    }
255
256    // Tab- and mode-specific handlers get first crack at the key. Whatever they
257    // consume never reaches the global fallback below, so a tab-local binding
258    // (SideBySide's `/` search, `p` panel focus, …) wins over a colliding
259    // global binding instead of both firing. Bare digits are deliberately NOT
260    // bound by any tab: they always reach the global digit tab-select, matching
261    // the tab bar labels (Components' quick filters live behind the 'Q' modal).
262    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
270/// Dispatch a key to the active tab's handler.
271///
272/// Returns `true` if the tab consumed the key, meaning it must not fall through
273/// to [`handle_global_fallback`]. Tabs without a dedicated handler (Summary)
274/// never consume, so global bindings and list navigation still apply on them.
275fn dispatch_tab_key(app: &mut super::App, key: KeyEvent) -> bool {
276    // Gated to Diff mode: the multi modes have no visible tabs — their
277    // `active_tab` is a stale preference restore (see `App::base`) — so no
278    // key may leak into an invisible tab handler.
279    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
296/// Dispatch a key to the active multi-comparison mode handler.
297///
298/// Returns `true` if the mode consumed the key. The single-pair Diff mode
299/// has no mode handler and never consumes here.
300fn 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
309/// Global key bindings — the fallback layer.
310///
311/// Invoked only for keys that no active tab or mode handler consumed, so a
312/// tab-local binding always takes precedence over a colliding global one.
313fn handle_global_fallback(app: &mut super::App, key: KeyEvent) {
314    match key.code {
315        KeyCode::Char('q') => {
316            // Save last active tab before quitting
317            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        // The color legend is a Diff-mode overlay; the multi-mode render
325        // branches never paint it, so opening it there would create an
326        // invisible key-swallowing modal.
327        KeyCode::Char('l') if app.mode == super::AppMode::Diff => app.toggle_legend(),
328        // Threshold tuning overlay. Diff mode only; tabs that bind 't'
329        // locally (Dependencies: transitive toggle) consume it first.
330        KeyCode::Char('t') if app.mode == super::AppMode::Diff => {
331            app.toggle_threshold_tuning();
332        }
333        KeyCode::Char('T') => {
334            // Toggle theme (dark -> light -> high-contrast) and save preference.
335            // Monochrome is sticky (NO_COLOR): the toggle is a no-op there, and
336            // skipping the save keeps the user's colored preference intact for
337            // sessions without NO_COLOR.
338            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        // View switcher (V key in multi-comparison modes)
349        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        // Keyboard shortcuts overlay ('?' routes here too: one surface)
358        KeyCode::Char('K') | KeyCode::F(1) => open_shortcuts_overlay(app),
359        // Component deep dive (D key). Diff mode only here: MultiDiff and
360        // Timeline consume 'D' in their mode handlers (populated open), and
361        // Matrix consumes it with an explanatory status message — its rows
362        // are SBOMs, not components. The helper resolves the target from the
363        // ACTIVE tab's own selection (Components/Graph/Vulnerabilities) and
364        // explains itself on tabs with no component context (#203).
365        KeyCode::Char('D') => {
366            if app.mode == super::AppMode::Diff {
367                helpers::open_diff_component_deep_dive(app);
368            }
369        }
370        // Policy/Compliance check (P key)
371        KeyCode::Char('P') => {
372            if matches!(app.mode, super::AppMode::Diff) {
373                app.run_compliance_check();
374            }
375        }
376        // Cycle policy preset. Scoped to the Summary tab — the only tab that
377        // renders the policy widget — so 'p' can no longer invisibly mutate
378        // policy state (and discard a computed check) from other tabs.
379        KeyCode::Char('p') => {
380            if app.mode == super::AppMode::Diff && app.active_tab == super::TabKind::Summary {
381                app.next_policy();
382            }
383        }
384        // Yank (copy) selected item to clipboard
385        KeyCode::Char('y') => {
386            handle_yank(app);
387        }
388        KeyCode::Esc => app.close_overlays(),
389        KeyCode::Char('b') | KeyCode::Backspace => {
390            // Navigate back using breadcrumbs
391            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        // Real terminals report Shift+Tab as BackTab (never Tab+SHIFT), so
403        // the modifier check above only serves synthetic events. Gated to
404        // Diff mode: the multi-mode handlers consume Tab as a panel toggle
405        // and BackTab must not mutate their hidden diff active_tab.
406        KeyCode::BackTab if app.mode == super::AppMode::Diff => {
407            app.prev_tab();
408        }
409        KeyCode::Char('/') => app.start_search(),
410        // Digit tab-select only in Diff mode: the multi modes have no visible
411        // tabs, so a digit must never mutate their hidden active_tab.
412        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            // Compliance only in diff mode
424            if app.mode == super::AppMode::Diff {
425                app.select_tab(super::TabKind::Compliance);
426            }
427        }
428        KeyCode::Char('8') => {
429            // Side-by-side only in diff mode
430            if app.mode == super::AppMode::Diff {
431                app.select_tab(super::TabKind::SideBySide);
432            }
433        }
434        KeyCode::Char('9') => {
435            // Graph changes tab when graph diff data is available, otherwise
436            // Source. Diff mode only (belt-and-braces: multi modes never have
437            // diff_result, but they must not reach a tab select either way).
438            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            // Source tab as 10th tab (only when graph changes exist)
453            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        // Navigation
463        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
475/// Get the text that would be copied for the current selection in diff mode.
476///
477/// Returns `None` if nothing is selected or the tab has no copyable item.
478pub 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        // Ctrl+C shares the same row resolution as the tab-local 'y' (in
482        // Grouped mode there is no row cursor, so nothing to copy).
483        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            // No copy target when the tree cursor sits on a placeholder
503            // banner row ("__…") or nothing at all — the detail panel says
504            // "Select a dependency node", so the footer must not offer one.
505            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            // Overview mode renders no violation list ("j/k navigate (0)"),
544            // so offering to copy an invisible violation would be a lie.
545            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                    // Cache is already warm from rendering
570                    panel.cached_flat_items.get(panel.selected).map(|item| {
571                        if !item.value_preview.is_empty() {
572                            // Strip surrounding quotes for string values
573                            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
594/// Handle `y` / `Ctrl+C` to copy the focused item to clipboard.
595fn 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
614/// Route an export to either the standard reporter pipeline or the
615/// compliance-specific exporter depending on the active tab.
616fn dispatch_export(app: &mut super::App, format: crate::tui::export::ExportFormat) {
617    // Compliance routing is Diff-only: in the multi modes `active_tab` is a
618    // stale preference restore and must not redirect the export.
619    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
626/// Open the unified ?/K shortcuts overlay: mode-derived context plus a
627/// This-Tab section from the active tab's `ViewState::shortcuts()`.
628fn 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    //! Single-dispatch precedence contract for the diff key handler.
656    //!
657    //! The dispatcher gives the active tab (and multi-comparison mode) first
658    //! crack at every key via [`dispatch_tab_key`]/[`dispatch_mode_key`]; only
659    //! keys they *don't* consume reach [`handle_global_fallback`]. These tests
660    //! lock that contract per-tab: a tab-local binding always wins over a
661    //! colliding global one, universal chrome keys are never shadowed, and
662    //! navigation is never starved on the tabs whose global `select_*` is a
663    //! no-op.
664    //!
665    //! Routing is asserted directly on the `bool` returned by `dispatch_tab_key`
666    //! (data-independent — it does not depend on how many rows the demo fixture
667    //! happens to have); the individual views' navigation *effects* are covered
668    //! by each view's own unit tests.
669
670    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    /// Navigation must reach every list-bearing tab. Tabs whose global
688    /// `select_*` is a no-op (SideBySide/Quality/Compliance/GraphChanges) — plus
689    /// Licenses/Dependencies, whose views own navigation — MUST consume `j`/`k`
690    /// locally. Tabs that navigate through the global list handler
691    /// (Components/Vulnerabilities/Source) MUST let `j`/`k` fall through. Either
692    /// way navigation is never starved.
693    #[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    /// The confirmed global/tab key collisions resolve tab-first: the tab owns
732    /// the key and the colliding global binding never fires.
733    #[test]
734    fn tab_bindings_win_over_colliding_global_bindings() {
735        // '/': SideBySide has its own search; Components has none and defers to
736        // the global search overlay.
737        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        // Bare digits always fall through to the global tab-select — no tab
747        // may shadow the tab-bar's advertised digit jumps. Quick filters are
748        // only reachable through the 'Q' picker modal.
749        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        // Inside the 'Q' picker the digits DO toggle filters.
762        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        // 'p': panel focus toggle vs. the global next-policy binding.
773        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    /// Universal chrome keys must never be shadowed by a tab: `Tab` (tab
782    /// switching) and `q` (quit) fall through on *every* tab — including the
783    /// tabs that used to bind `Tab` to panel focus.
784    #[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    /// End-to-end through the real dispatcher: the global fallback applies only
813    /// to keys the active tab did not consume.
814    #[test]
815    fn global_fallback_only_fires_for_unconsumed_keys() {
816        // Bare digits jump tabs from every tab — including Components, which
817        // used to shadow them with quick filters.
818        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        // With the 'Q' picker open, digits toggle filters and the tab stays.
827        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        // '/' on SideBySide opens the tab-local search, not the global overlay.
841        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        // 'Tab' switches tabs from Components (the view no longer steals it).
853        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    /// Enter (and 'D') on the Components tab opens the deep dive WITH data,
863    /// and its advertised Tab/arrow section switching actually works (the
864    /// dedicated branch must run before the generic overlay swallower).
865    #[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    /// 't' opens the threshold-tuning overlay on tabs that don't bind 't'
887    /// locally; Dependencies keeps its transitive toggle. The overlay's
888    /// advertised '+'/'-' fine-adjust keys must actually work.
889    #[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    /// 'p' cycles the policy preset only on the Summary tab (the only tab
915    /// that renders the policy widget) and always leaves a status message.
916    #[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    /// The three multi-mode dashboards, built from the real fixtures. The
937    /// active tab is forced post-construction (the same prefs-isolation
938    /// convention as [`diff_app`]) — in production it is a stale preference
939    /// restore from `App::base`.
940    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    /// The multi modes have no visible tabs; their `active_tab` is a stale
951    /// preference restore. `dispatch_tab_key` must never route a key into an
952    /// invisible tab handler there — neither a tab-local binding ('f' filter,
953    /// '/' search) nor a digit the hidden tab would swallow.
954    #[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    /// The export dialog is real in every multi mode (rendered + non-JSON
971    /// picks explain themselves), and the Diff-only legend can no longer
972    /// open as an invisible key-swallowing modal outside Diff.
973    #[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            // Markdown is unsupported in the multi modes: the pick must fail
983            // with an explanatory status instead of writing anything.
984            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    /// The legend's own footer says "Press any key to close" — honor it.
1003    #[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    /// End-to-end through the real dispatcher: the global digit tab-select is
1016    /// gated to Diff mode, so a digit pressed in a multi-mode dashboard leaves
1017    /// the hidden `active_tab` untouched.
1018    #[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}