Skip to main content

sbom_tools/tui/
app.rs

1//! Application state for the TUI.
2
3use crate::diff::{DiffResult, MatrixResult, MultiDiffResult, TimelineResult};
4#[cfg(feature = "enrichment")]
5use crate::enrichment::EnrichmentStats;
6use crate::model::{NormalizedSbom, NormalizedSbomIndex};
7use crate::quality::{ComplianceResult, QualityReport};
8use crate::tui::views::ThresholdTuningState;
9
10// Re-export state types from app_states module for backwards compatibility
11#[allow(unused_imports)]
12pub use super::app_states::{
13    // Side-by-side states
14    AlignmentMode,
15    // Navigation states
16    Breadcrumb,
17    // Search states
18    ChangeType,
19    ChangeTypeFilter,
20    // Component deep dive states
21    ComponentDeepDiveData,
22    ComponentDeepDiveState,
23    // Component states
24    ComponentFilter,
25    ComponentSimilarityInfo,
26    ComponentSort,
27    ComponentTargetPresence,
28    ComponentVersionEntry,
29    ComponentVulnInfo,
30    ComponentsState,
31    // Dependencies state
32    DependenciesState,
33    DiffSearchResult,
34    DiffSearchState,
35    // Vulnerability states
36    DiffVulnItem,
37    DiffVulnStatus,
38    // Graph changes state
39    GraphChangesState,
40    // License states
41    LicenseGroupBy,
42    LicenseRiskFilter,
43    LicenseSort,
44    LicensesState,
45    // Matrix states
46    MatrixSortBy,
47    MatrixState,
48    // Multi-view states
49    MultiDiffState,
50    MultiViewFilterPreset,
51    MultiViewSearchState,
52    MultiViewSortBy,
53    // View switcher states
54    MultiViewType,
55    NavigationContext,
56    // Quality states
57    QualityState,
58    QualityViewMode,
59    ScrollSyncMode,
60    // Shortcuts overlay states
61    ShortcutsContext,
62    ShortcutsOverlayState,
63    SideBySideState,
64    SimilarityThreshold,
65    SortDirection,
66    // Timeline states
67    TimelineComponentFilter,
68    TimelineSortBy,
69    TimelineState,
70    ViewSwitcherState,
71    VulnChangeType,
72    VulnFilter,
73    VulnSort,
74    VulnerabilitiesState,
75    sort_component_changes,
76    sort_components,
77};
78
79/// Per-tab UI state container.
80///
81/// Groups all tab-specific state structs that were previously
82/// flat fields on `App`. Access via `app.tabs.components`, etc.
83pub struct TabStates {
84    pub(crate) components: ComponentsState,
85    pub(crate) dependencies: DependenciesState,
86    pub(crate) licenses: LicensesState,
87    pub(crate) vulnerabilities: VulnerabilitiesState,
88    pub(crate) quality: QualityState,
89    pub(crate) graph_changes: GraphChangesState,
90    pub(crate) side_by_side: SideBySideState,
91    pub(crate) diff_compliance: crate::tui::app_states::DiffComplianceState,
92    pub(crate) multi_diff: MultiDiffState,
93    pub(crate) timeline: TimelineState,
94    pub(crate) matrix: MatrixState,
95    pub(crate) source: crate::tui::app_states::SourceDiffState,
96}
97
98/// Overlay UI state container.
99///
100/// Groups all overlay visibility flags and complex overlay states.
101pub struct AppOverlays {
102    pub(crate) show_help: bool,
103    pub(crate) show_export: bool,
104    pub(crate) show_legend: bool,
105    pub(crate) search: DiffSearchState,
106    pub(crate) threshold_tuning: ThresholdTuningState,
107    pub(crate) view_switcher: ViewSwitcherState,
108    pub(crate) shortcuts: ShortcutsOverlayState,
109    pub(crate) component_deep_dive: ComponentDeepDiveState,
110}
111
112impl AppOverlays {
113    pub fn new() -> Self {
114        Self {
115            show_help: false,
116            show_export: false,
117            show_legend: false,
118            search: DiffSearchState::new(),
119            threshold_tuning: ThresholdTuningState::default(),
120            view_switcher: ViewSwitcherState::new(),
121            shortcuts: ShortcutsOverlayState::new(),
122            component_deep_dive: ComponentDeepDiveState::new(),
123        }
124    }
125
126    pub const fn toggle_help(&mut self) {
127        self.show_help = !self.show_help;
128        if self.show_help {
129            self.show_export = false;
130            self.show_legend = false;
131        }
132    }
133
134    pub const fn toggle_export(&mut self) {
135        self.show_export = !self.show_export;
136        if self.show_export {
137            self.show_help = false;
138            self.show_legend = false;
139        }
140    }
141
142    pub const fn toggle_legend(&mut self) {
143        self.show_legend = !self.show_legend;
144        if self.show_legend {
145            self.show_help = false;
146            self.show_export = false;
147        }
148    }
149
150    pub const fn close_all(&mut self) {
151        self.show_help = false;
152        self.show_export = false;
153        self.show_legend = false;
154        self.search.active = false;
155        self.threshold_tuning.visible = false;
156    }
157
158    pub const fn has_active(&self) -> bool {
159        self.show_help
160            || self.show_export
161            || self.show_legend
162            || self.search.active
163            || self.threshold_tuning.visible
164    }
165}
166
167/// Data context: SBOM data, diff results, indexes, quality, and compliance.
168///
169/// Groups all immutable-after-construction data that tabs read from.
170pub struct DataContext {
171    pub(crate) diff_result: Option<DiffResult>,
172    pub(crate) old_sbom: Option<NormalizedSbom>,
173    pub(crate) new_sbom: Option<NormalizedSbom>,
174    pub(crate) sbom: Option<NormalizedSbom>,
175    pub(crate) multi_diff_result: Option<MultiDiffResult>,
176    pub(crate) timeline_result: Option<TimelineResult>,
177    pub(crate) matrix_result: Option<MatrixResult>,
178    pub(crate) old_sbom_index: Option<NormalizedSbomIndex>,
179    pub(crate) new_sbom_index: Option<NormalizedSbomIndex>,
180    pub(crate) sbom_index: Option<NormalizedSbomIndex>,
181    pub(crate) old_quality: Option<QualityReport>,
182    pub(crate) new_quality: Option<QualityReport>,
183    pub(crate) quality_report: Option<QualityReport>,
184    pub(crate) old_cra_compliance: Option<ComplianceResult>,
185    pub(crate) new_cra_compliance: Option<ComplianceResult>,
186    pub(crate) old_compliance_results: Option<Vec<ComplianceResult>>,
187    pub(crate) new_compliance_results: Option<Vec<ComplianceResult>>,
188    pub(crate) matching_threshold: f64,
189    #[cfg(feature = "enrichment")]
190    pub(crate) enrichment_stats_old: Option<EnrichmentStats>,
191    #[cfg(feature = "enrichment")]
192    pub(crate) enrichment_stats_new: Option<EnrichmentStats>,
193}
194
195/// Main application state
196pub struct App {
197    /// Current mode (diff or view)
198    pub(crate) mode: AppMode,
199    /// Active tab
200    pub(crate) active_tab: TabKind,
201    /// SBOM data, diff results, indexes, quality, and compliance
202    pub(crate) data: DataContext,
203    /// Per-tab UI state
204    pub(crate) tabs: TabStates,
205    /// Overlay UI state
206    pub(crate) overlays: AppOverlays,
207    /// Should quit
208    pub(crate) should_quit: bool,
209    /// Status message to display temporarily
210    pub(crate) status_message: Option<String>,
211    /// When true, the status message survives one extra keypress before clearing.
212    pub(crate) status_sticky: bool,
213    /// Animation tick counter
214    pub(crate) tick: u64,
215    /// Last exported file path
216    pub(crate) last_export_path: Option<String>,
217    /// Navigation context for cross-view navigation
218    pub(crate) navigation_ctx: NavigationContext,
219    /// Security analysis cache for blast radius, risk indicators, and flagged items
220    pub(crate) security_cache: crate::tui::security::SecurityAnalysisCache,
221    /// Compliance/policy checking state
222    pub(crate) compliance_state: crate::tui::app_states::PolicyComplianceState,
223    /// Optional export filename template (from `--export-template` CLI arg).
224    pub(crate) export_template: Option<String>,
225    /// Quality tab `ViewState` implementation (proof of concept).
226    ///
227    /// When present, quality tab key events are dispatched through this
228    /// `ViewState` instead of the direct handler. State is synced back to
229    /// `tabs.quality` after each event for rendering compatibility.
230    pub(crate) quality_view: Option<crate::tui::view_states::QualityView>,
231}
232
233impl App {
234    /// Lazily compute compliance results for all standards when first needed.
235    pub fn ensure_compliance_results(&mut self) {
236        if self.data.old_compliance_results.is_none()
237            && let Some(old_sbom) = &self.data.old_sbom
238        {
239            self.data.old_compliance_results = Some(
240                crate::quality::ComplianceLevel::all()
241                    .iter()
242                    .map(|level| crate::quality::ComplianceChecker::new(*level).check(old_sbom))
243                    .collect(),
244            );
245        }
246        if self.data.new_compliance_results.is_none()
247            && let Some(new_sbom) = &self.data.new_sbom
248        {
249            self.data.new_compliance_results = Some(
250                crate::quality::ComplianceLevel::all()
251                    .iter()
252                    .map(|level| crate::quality::ComplianceChecker::new(*level).check(new_sbom))
253                    .collect(),
254            );
255        }
256    }
257
258    /// Toggle help overlay
259    pub const fn toggle_help(&mut self) {
260        self.overlays.toggle_help();
261    }
262
263    /// Toggle export dialog
264    pub const fn toggle_export(&mut self) {
265        self.overlays.toggle_export();
266    }
267
268    /// Toggle legend overlay
269    pub const fn toggle_legend(&mut self) {
270        self.overlays.toggle_legend();
271    }
272
273    /// Close all overlays
274    pub const fn close_overlays(&mut self) {
275        self.overlays.close_all();
276    }
277
278    /// Check if any overlay is open
279    #[must_use]
280    pub const fn has_overlay(&self) -> bool {
281        self.overlays.has_active()
282    }
283
284    /// Toggle threshold tuning overlay
285    pub fn toggle_threshold_tuning(&mut self) {
286        if self.overlays.threshold_tuning.visible {
287            self.overlays.threshold_tuning.visible = false;
288        } else {
289            self.show_threshold_tuning();
290        }
291    }
292
293    /// Show threshold tuning overlay and compute initial estimated matches
294    pub fn show_threshold_tuning(&mut self) {
295        // Close other overlays
296        self.overlays.close_all();
297
298        // Get total components count
299        let total = match self.mode {
300            AppMode::Diff => {
301                self.data
302                    .old_sbom
303                    .as_ref()
304                    .map_or(0, crate::model::NormalizedSbom::component_count)
305                    + self
306                        .data
307                        .new_sbom
308                        .as_ref()
309                        .map_or(0, crate::model::NormalizedSbom::component_count)
310            }
311            AppMode::View => self
312                .data
313                .sbom
314                .as_ref()
315                .map_or(0, crate::model::NormalizedSbom::component_count),
316            _ => 0,
317        };
318
319        // Initialize threshold tuning state
320        self.overlays.threshold_tuning =
321            ThresholdTuningState::new(self.data.matching_threshold, total);
322        self.update_threshold_preview();
323    }
324
325    /// Update the estimated matches preview based on current threshold
326    pub fn update_threshold_preview(&mut self) {
327        if !self.overlays.threshold_tuning.visible {
328            return;
329        }
330
331        // Estimate matches at current threshold
332        // For now, use a simple heuristic based on the diff result
333        let estimated = if let Some(ref result) = self.data.diff_result {
334            // Count modified components (matches) and estimate how threshold changes would affect
335            let current_matches = result.components.modified.len();
336            let threshold = self.overlays.threshold_tuning.threshold;
337            let base_threshold = self.data.matching_threshold;
338
339            // Simple estimation: lower threshold = more matches, higher = fewer
340            let ratio = if threshold < base_threshold {
341                (base_threshold - threshold).mul_add(2.0, 1.0)
342            } else {
343                (threshold - base_threshold).mul_add(-1.5, 1.0)
344            };
345            ((current_matches as f64 * ratio).max(0.0)) as usize
346        } else {
347            0
348        };
349
350        self.overlays
351            .threshold_tuning
352            .set_estimated_matches(estimated);
353    }
354
355    /// Apply the tuned threshold and potentially re-diff
356    pub fn apply_threshold(&mut self) {
357        self.data.matching_threshold = self.overlays.threshold_tuning.threshold;
358        self.overlays.threshold_tuning.visible = false;
359        self.set_status_message(format!(
360            "Threshold set to {:.0}% - Re-run diff to apply",
361            self.data.matching_threshold * 100.0
362        ));
363    }
364
365    /// Set a temporary status message
366    pub fn set_status_message(&mut self, msg: impl Into<String>) {
367        self.status_message = Some(msg.into());
368    }
369
370    /// Clear the status message.
371    ///
372    /// If `status_sticky` is set the message is kept for one extra keypress,
373    /// then cleared on the subsequent call.
374    pub fn clear_status_message(&mut self) {
375        if self.status_sticky {
376            self.status_sticky = false;
377        } else {
378            self.status_message = None;
379        }
380    }
381
382    /// Export the current diff to a file.
383    ///
384    /// The export is scoped to the active tab: e.g. if the user is on the
385    /// Vulnerabilities tab only vulnerability data is included.
386    pub fn export(&mut self, format: super::export::ExportFormat) {
387        use super::export::{export_diff, export_view, tab_to_report_type};
388        use crate::reports::ReportConfig;
389
390        let report_type = tab_to_report_type(self.active_tab);
391        let config = ReportConfig::with_types(vec![report_type]);
392
393        let result = match self.mode {
394            AppMode::Diff => {
395                if let (Some(diff_result), Some(old_sbom), Some(new_sbom)) = (
396                    &self.data.diff_result,
397                    &self.data.old_sbom,
398                    &self.data.new_sbom,
399                ) {
400                    export_diff(
401                        format,
402                        diff_result,
403                        old_sbom,
404                        new_sbom,
405                        None,
406                        &config,
407                        self.export_template.as_deref(),
408                    )
409                } else {
410                    self.set_status_message("No diff data to export");
411                    return;
412                }
413            }
414            AppMode::View => {
415                if let Some(ref sbom) = self.data.sbom {
416                    export_view(format, sbom, None, &config, self.export_template.as_deref())
417                } else {
418                    self.set_status_message("No SBOM data to export");
419                    return;
420                }
421            }
422            _ => {
423                self.set_status_message("Export not supported for this mode");
424                return;
425            }
426        };
427
428        if result.success {
429            self.last_export_path = Some(result.path.display().to_string());
430            self.set_status_message(result.message);
431            self.status_sticky = true;
432        } else {
433            self.set_status_message(format!("Export failed: {}", result.message));
434        }
435    }
436
437    /// Export compliance results from the active compliance tab
438    pub fn export_compliance(&mut self, format: super::export::ExportFormat) {
439        use super::export::export_compliance;
440
441        self.ensure_compliance_results();
442
443        // Determine which compliance results and selected standard to use
444        let (results, selected) = if let Some(ref results) = self.data.new_compliance_results {
445            if !results.is_empty() {
446                (results, self.tabs.diff_compliance.selected_standard)
447            } else if let Some(ref old_results) = self.data.old_compliance_results {
448                if old_results.is_empty() {
449                    self.set_status_message("No compliance results to export");
450                    return;
451                }
452                (old_results, self.tabs.diff_compliance.selected_standard)
453            } else {
454                self.set_status_message("No compliance results to export");
455                return;
456            }
457        } else if let Some(ref old_results) = self.data.old_compliance_results {
458            if old_results.is_empty() {
459                self.set_status_message("No compliance results to export");
460                return;
461            }
462            (old_results, self.tabs.diff_compliance.selected_standard)
463        } else {
464            self.set_status_message("No compliance results to export");
465            return;
466        };
467
468        let result = export_compliance(
469            format,
470            results,
471            selected,
472            None,
473            self.export_template.as_deref(),
474        );
475        if result.success {
476            self.last_export_path = Some(result.path.display().to_string());
477            self.set_status_message(result.message);
478            self.status_sticky = true;
479        } else {
480            self.set_status_message(format!("Export failed: {}", result.message));
481        }
482    }
483
484    /// Export matrix results to a file
485    pub fn export_matrix(&mut self, format: super::export::ExportFormat) {
486        use super::export::export_matrix;
487
488        let Some(ref matrix_result) = self.data.matrix_result else {
489            self.set_status_message("No matrix data to export");
490            return;
491        };
492
493        let result = export_matrix(format, matrix_result, self.export_template.as_deref());
494        if result.success {
495            self.last_export_path = Some(result.path.display().to_string());
496            self.set_status_message(result.message);
497            self.status_sticky = true;
498        } else {
499            self.set_status_message(format!("Export failed: {}", result.message));
500        }
501    }
502
503    // ========================================================================
504    // Compliance / Policy Checking
505    // ========================================================================
506
507    /// Run compliance check against the current policy
508    pub fn run_compliance_check(&mut self) {
509        use crate::tui::security::{SecurityPolicy, check_compliance};
510
511        let preset = self.compliance_state.policy_preset;
512
513        // Standards-based presets delegate to the quality::ComplianceChecker
514        if preset.is_standards_based() {
515            self.run_standards_compliance_check(preset);
516            return;
517        }
518
519        let policy = match preset {
520            super::app_states::PolicyPreset::Enterprise => SecurityPolicy::enterprise_default(),
521            super::app_states::PolicyPreset::Strict => SecurityPolicy::strict(),
522            super::app_states::PolicyPreset::Permissive => SecurityPolicy::permissive(),
523            // Standards-based presets handled above
524            _ => unreachable!(),
525        };
526
527        // Collect component data for compliance checking
528        let components = self.collect_compliance_data();
529
530        if components.is_empty() {
531            self.set_status_message("No components to check");
532            return;
533        }
534
535        let result = check_compliance(&policy, &components);
536        let passes = result.passes;
537        let score = result.score;
538        let violation_count = result.violations.len();
539
540        self.compliance_state.result = Some(result);
541        self.compliance_state.checked = true;
542        self.compliance_state.selected_violation = 0;
543
544        if passes {
545            self.set_status_message(format!("Policy: {} - PASS (score: {})", policy.name, score));
546        } else {
547            self.set_status_message(format!(
548                "Policy: {} - FAIL ({} violations, score: {})",
549                policy.name, violation_count, score
550            ));
551        }
552    }
553
554    /// Run a standards-based compliance check (CRA, NTIA, FDA) and convert
555    /// the result into a PolicyViolation-based `ComplianceResult` for unified display.
556    fn run_standards_compliance_check(&mut self, preset: super::app_states::PolicyPreset) {
557        use crate::quality::{ComplianceChecker, ViolationSeverity};
558        use crate::tui::security::{
559            ComplianceResult as PolicyResult, PolicySeverity, PolicyViolation,
560        };
561
562        let Some(level) = preset.compliance_level() else {
563            return;
564        };
565
566        // Find the SBOM to check (prefer new_sbom in diff mode, sbom in view mode)
567        let sbom = match self.mode {
568            AppMode::Diff => self.data.new_sbom.as_ref(),
569            _ => self.data.sbom.as_ref(),
570        };
571        let Some(sbom) = sbom else {
572            self.set_status_message("No SBOM loaded to check");
573            return;
574        };
575
576        let checker = ComplianceChecker::new(level);
577        let std_result = checker.check(sbom);
578
579        // Convert quality::Violation → PolicyViolation
580        let violations: Vec<PolicyViolation> = std_result
581            .violations
582            .iter()
583            .map(|v| {
584                let severity = match v.severity {
585                    ViolationSeverity::Error => PolicySeverity::High,
586                    ViolationSeverity::Warning => PolicySeverity::Medium,
587                    ViolationSeverity::Info => PolicySeverity::Low,
588                };
589                PolicyViolation {
590                    rule_name: v.requirement.clone(),
591                    severity,
592                    component: v.element.clone(),
593                    description: v.message.clone(),
594                    remediation: v.remediation_guidance().to_string(),
595                }
596            })
597            .collect();
598
599        // Calculate score: errors weigh 10pts, warnings 5pts, info 1pt
600        let penalty: u32 = violations
601            .iter()
602            .map(|v| match v.severity {
603                PolicySeverity::High | PolicySeverity::Critical => 10,
604                PolicySeverity::Medium => 5,
605                PolicySeverity::Low => 1,
606            })
607            .sum();
608        let score = 100u8.saturating_sub(penalty.min(100) as u8);
609
610        let passes = std_result.is_compliant;
611        let policy_name = format!("{} Compliance", preset.label());
612        let violation_count = violations.len();
613
614        let result = PolicyResult {
615            policy_name: policy_name.clone(),
616            components_checked: sbom.components.len(),
617            violations,
618            score,
619            passes,
620        };
621
622        self.compliance_state.result = Some(result);
623        self.compliance_state.checked = true;
624        self.compliance_state.selected_violation = 0;
625
626        if passes {
627            self.set_status_message(format!("{policy_name} - COMPLIANT (score: {score})"));
628        } else {
629            self.set_status_message(format!(
630                "{policy_name} - NON-COMPLIANT ({violation_count} violations, score: {score})"
631            ));
632        }
633    }
634
635    /// Collect component data for compliance checking
636    fn collect_compliance_data(&self) -> Vec<crate::tui::security::ComplianceComponentData> {
637        let mut components = Vec::new();
638
639        match self.mode {
640            AppMode::Diff => {
641                if let Some(sbom) = &self.data.new_sbom {
642                    for comp in sbom.components.values() {
643                        let licenses: Vec<String> = comp
644                            .licenses
645                            .declared
646                            .iter()
647                            .map(std::string::ToString::to_string)
648                            .collect();
649                        let vulns: Vec<(String, String)> = comp
650                            .vulnerabilities
651                            .iter()
652                            .map(|v| {
653                                let severity = v.severity.as_ref().map_or_else(
654                                    || "Unknown".to_string(),
655                                    std::string::ToString::to_string,
656                                );
657                                (v.id.clone(), severity)
658                            })
659                            .collect();
660                        components.push((comp.name.clone(), comp.version.clone(), licenses, vulns));
661                    }
662                }
663            }
664            AppMode::View => {
665                if let Some(sbom) = &self.data.sbom {
666                    for comp in sbom.components.values() {
667                        let licenses: Vec<String> = comp
668                            .licenses
669                            .declared
670                            .iter()
671                            .map(std::string::ToString::to_string)
672                            .collect();
673                        let vulns: Vec<(String, String)> = comp
674                            .vulnerabilities
675                            .iter()
676                            .map(|v| {
677                                let severity = v.severity.as_ref().map_or_else(
678                                    || "Unknown".to_string(),
679                                    std::string::ToString::to_string,
680                                );
681                                (v.id.clone(), severity)
682                            })
683                            .collect();
684                        components.push((comp.name.clone(), comp.version.clone(), licenses, vulns));
685                    }
686                }
687            }
688            _ => {}
689        }
690
691        components
692    }
693
694    /// Toggle compliance view details
695    pub const fn toggle_compliance_details(&mut self) {
696        self.compliance_state.toggle_details();
697    }
698
699    /// Cycle to next policy preset
700    pub fn next_policy(&mut self) {
701        self.compliance_state.toggle_policy();
702        // Re-run check with new policy if already checked
703        if self.compliance_state.checked {
704            self.run_compliance_check();
705        }
706    }
707
708    // ========================================================================
709    // ViewState trait integration methods
710    // ========================================================================
711
712    /// Get the current view mode for `ViewContext`
713    #[must_use]
714    pub const fn view_mode(&self) -> super::traits::ViewMode {
715        super::traits::ViewMode::from_app_mode(self.mode)
716    }
717
718    /// Handle an `EventResult` from a view state
719    ///
720    /// This method processes the result of a view's event handling,
721    /// performing navigation, showing overlays, or setting status messages.
722    pub fn handle_event_result(&mut self, result: super::traits::EventResult) {
723        use super::traits::EventResult;
724
725        match result {
726            EventResult::Consumed | EventResult::Ignored => {
727                // Event was handled, or not handled -- nothing else to do
728            }
729            EventResult::NavigateTo(target) => {
730                self.navigate_to_target(target);
731            }
732            EventResult::Exit => {
733                self.should_quit = true;
734            }
735            EventResult::ShowOverlay(kind) => {
736                self.show_overlay_kind(&kind);
737            }
738            EventResult::StatusMessage(msg) => {
739                self.set_status_message(msg);
740            }
741        }
742    }
743
744    /// Show an overlay based on the kind
745    fn show_overlay_kind(&mut self, kind: &super::traits::OverlayKind) {
746        use super::traits::OverlayKind;
747
748        // Close any existing overlays first
749        self.overlays.close_all();
750
751        match kind {
752            OverlayKind::Help => self.overlays.show_help = true,
753            OverlayKind::Export => self.overlays.show_export = true,
754            OverlayKind::Legend => self.overlays.show_legend = true,
755            OverlayKind::Search => {
756                self.overlays.search.active = true;
757                self.overlays.search.query.clear();
758            }
759            OverlayKind::Shortcuts => self.overlays.shortcuts.visible = true,
760        }
761    }
762
763    /// Get the current tab as a `TabTarget`
764    #[must_use]
765    pub const fn current_tab_target(&self) -> super::traits::TabTarget {
766        super::traits::TabTarget::from_tab_kind(self.active_tab)
767    }
768
769    /// Get keyboard shortcuts for the current view
770    #[must_use]
771    pub fn current_shortcuts(&self) -> Vec<super::traits::Shortcut> {
772        use super::traits::Shortcut;
773
774        let mut shortcuts = vec![
775            Shortcut::primary("?", "Help"),
776            Shortcut::primary("q", "Quit"),
777            Shortcut::primary("Tab", "Next tab"),
778            Shortcut::primary("/", "Search"),
779        ];
780
781        // Add view-specific shortcuts
782        match self.active_tab {
783            TabKind::Components => {
784                shortcuts.push(Shortcut::new("f", "Filter"));
785                shortcuts.push(Shortcut::new("s", "Sort"));
786                shortcuts.push(Shortcut::new("m", "Multi-select"));
787            }
788            TabKind::Dependencies => {
789                shortcuts.push(Shortcut::new("t", "Transitive"));
790                shortcuts.push(Shortcut::new("+/-", "Depth"));
791            }
792            TabKind::Vulnerabilities => {
793                shortcuts.push(Shortcut::new("f", "Filter"));
794                shortcuts.push(Shortcut::new("s", "Sort"));
795            }
796            TabKind::Quality => {
797                shortcuts.push(Shortcut::new("v", "View mode"));
798            }
799            _ => {}
800        }
801
802        shortcuts
803    }
804}
805
806/// Application mode
807#[derive(Debug, Clone, Copy, PartialEq, Eq)]
808pub enum AppMode {
809    /// Comparing two SBOMs
810    Diff,
811    /// Viewing a single SBOM
812    View,
813    /// 1:N multi-diff comparison
814    MultiDiff,
815    /// Timeline analysis
816    Timeline,
817    /// N×N matrix comparison
818    Matrix,
819}
820
821/// Tab kinds
822#[derive(Debug, Clone, Copy, PartialEq, Eq)]
823pub enum TabKind {
824    Summary,
825    Components,
826    Dependencies,
827    Licenses,
828    Vulnerabilities,
829    Quality,
830    Compliance,
831    SideBySide,
832    GraphChanges,
833    Source,
834}
835
836impl TabKind {
837    #[must_use]
838    pub const fn title(&self) -> &'static str {
839        match self {
840            Self::Summary => "Summary",
841            Self::Components => "Components",
842            Self::Dependencies => "Dependencies",
843            Self::Licenses => "Licenses",
844            Self::Vulnerabilities => "Vulnerabilities",
845            Self::Quality => "Quality",
846            Self::Compliance => "Compliance",
847            Self::SideBySide => "Side-by-Side",
848            Self::GraphChanges => "Graph",
849            Self::Source => "Source",
850        }
851    }
852
853    /// Stable string identifier for persistence.
854    #[must_use]
855    pub const fn as_str(&self) -> &'static str {
856        match self {
857            Self::Summary => "summary",
858            Self::Components => "components",
859            Self::Dependencies => "dependencies",
860            Self::Licenses => "licenses",
861            Self::Vulnerabilities => "vulnerabilities",
862            Self::Quality => "quality",
863            Self::Compliance => "compliance",
864            Self::SideBySide => "side-by-side",
865            Self::GraphChanges => "graph",
866            Self::Source => "source",
867        }
868    }
869
870    /// Parse from a persisted string identifier.
871    #[must_use]
872    pub fn from_str_opt(s: &str) -> Option<Self> {
873        match s {
874            "summary" => Some(Self::Summary),
875            "components" => Some(Self::Components),
876            "dependencies" => Some(Self::Dependencies),
877            "licenses" => Some(Self::Licenses),
878            "vulnerabilities" => Some(Self::Vulnerabilities),
879            "quality" => Some(Self::Quality),
880            "compliance" => Some(Self::Compliance),
881            "side-by-side" => Some(Self::SideBySide),
882            "graph" => Some(Self::GraphChanges),
883            "source" => Some(Self::Source),
884            _ => None,
885        }
886    }
887}