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