Skip to main content

sbom_tools/tui/view/
app.rs

1//! `ViewApp` - Dedicated TUI for exploring a single SBOM.
2//!
3//! This provides a rich, purpose-built interface for SBOM analysis
4//! with hierarchical navigation, search, and deep inspection.
5
6use crate::model::{Component, NormalizedSbom, NormalizedSbomIndex};
7use crate::quality::{ComplianceResult, QualityReport, QualityScorer};
8use crate::tui::app_states::SourcePanelState;
9use crate::tui::state::ListNavigation;
10use crate::tui::widgets::TreeState;
11use std::collections::{HashMap, HashSet};
12
13use super::views::{StandardComplianceState, compute_compliance_results};
14
15/// Main application state for single SBOM viewing.
16pub struct ViewApp {
17    /// The SBOM being viewed
18    pub(crate) sbom: NormalizedSbom,
19
20    /// BOM profile (SBOM / CBOM) — determines tab set and mode-specific behavior
21    pub(crate) bom_profile: crate::model::BomProfile,
22
23    /// Current active view/tab
24    pub(crate) active_tab: ViewTab,
25
26    /// Tree navigation state
27    pub(crate) tree_state: TreeState,
28
29    /// Current tree grouping mode
30    pub(crate) tree_group_by: TreeGroupBy,
31
32    /// Current tree filter
33    pub(crate) tree_filter: TreeFilter,
34    /// Tab-bar window computed by the last render — shared with the mouse
35    /// hit-test so render geometry and click geometry cannot drift.
36    pub(crate) tab_window: crate::tui::shared::TabWindow,
37
38    /// Tree search query (inline filter)
39    pub(crate) tree_search_query: String,
40
41    /// Whether tree search is active
42    pub(crate) tree_search_active: bool,
43
44    /// Cached tree nodes — rebuilt only when group_by, filter, or search_query change
45    pub(crate) cached_tree_nodes: Vec<crate::tui::widgets::TreeNode>,
46    /// Cache key for tree nodes
47    tree_cache_key: Option<TreeCacheKey>,
48
49    /// Selected component ID (for detail panel)
50    pub(crate) selected_component: Option<String>,
51
52    /// Component detail sub-tab
53    pub(crate) component_tab: ComponentDetailTab,
54
55    /// Scroll offset for the component detail panel (Overview/Identifiers etc.)
56    pub(crate) component_detail_scroll: u16,
57
58    /// Vulnerability explorer state
59    pub(crate) vuln_state: VulnExplorerState,
60
61    /// License view state
62    pub(crate) license_state: LicenseViewState,
63
64    /// Dependency view state
65    pub(crate) dependency_state: DependencyViewState,
66
67    /// Global search state
68    pub(crate) search_state: SearchState,
69
70    /// Focus panel (left list vs right detail)
71    pub(crate) focus_panel: FocusPanel,
72
73    /// Show help overlay
74    pub(crate) show_help: bool,
75
76    /// Help overlay scroll offset (rows)
77    pub(crate) help_scroll: u16,
78
79    /// Help overlay scroll ceiling, measured at render time
80    pub(crate) help_max_scroll: u16,
81
82    /// Show export dialog
83    pub(crate) show_export: bool,
84
85    /// Show legend overlay
86    pub(crate) show_legend: bool,
87
88    /// Status message to display temporarily
89    pub(crate) status_message: Option<String>,
90
91    /// When true, the status message survives one extra keypress before clearing.
92    pub(crate) status_sticky: bool,
93
94    /// Navigation context for breadcrumbs
95    pub(crate) navigation_ctx: ViewNavigationContext,
96
97    /// Should quit
98    pub(crate) should_quit: bool,
99
100    /// Animation tick counter
101    pub(crate) tick: u64,
102
103    /// Cached statistics
104    pub(crate) stats: SbomStats,
105
106    /// Quality report for the SBOM
107    pub(crate) quality_report: QualityReport,
108
109    /// Quality view state
110    pub(crate) quality_state: QualityViewState,
111
112    /// Compliance validation results for all standards (lazily computed)
113    pub(crate) compliance_results: Option<Vec<ComplianceResult>>,
114
115    /// Compliance view state
116    pub(crate) compliance_state: StandardComplianceState,
117
118    /// Precomputed index for fast lookups
119    pub(crate) sbom_index: NormalizedSbomIndex,
120
121    /// Source tab state
122    pub(crate) source_state: SourcePanelState,
123
124    /// Legacy Crypto tab: selected list index (all asset types)
125    pub(crate) crypto_list_selected: usize,
126    /// CBOM Algorithms tab: selected index
127    pub(crate) algorithms_selected: usize,
128    /// CBOM Certificates tab: selected index
129    pub(crate) certificates_selected: usize,
130    /// CBOM Keys tab: selected index
131    pub(crate) keys_selected: usize,
132    /// CBOM Protocols tab: selected index
133    pub(crate) protocols_selected: usize,
134    /// CBOM PQC-Compliance tab: selected algorithm index
135    pub(crate) pqc_selected: usize,
136
137    /// CBOM Algorithms tab: sort order
138    pub(crate) algorithm_sort_by: AlgorithmSortBy,
139
140    /// AI-BOM Models tab: selected index
141    pub(crate) models_selected: usize,
142    /// AI-BOM Datasets tab: selected index
143    pub(crate) datasets_selected: usize,
144    /// AI-BOM Models tab: detail-pane scroll offset (K/J)
145    pub(crate) models_detail_scroll: u16,
146    /// AI-BOM Datasets tab: detail-pane scroll offset (K/J)
147    pub(crate) datasets_detail_scroll: u16,
148    /// AI-BOM AI-Readiness tab: shared scroll offset for checks + recommendations
149    pub(crate) ai_readiness_scroll: usize,
150
151    /// Bookmarked component canonical IDs (in-memory, no persistence)
152    pub(crate) bookmarked: HashSet<String>,
153
154    /// Optional export filename template (from `--export-template` CLI arg).
155    pub(crate) export_template: Option<String>,
156
157    /// Optional CRA sidecar metadata. When set, `compute_compliance_results`
158    /// passes it to `ComplianceChecker::with_sidecar()` so OSS-Steward,
159    /// EUCC, Article 14, and product-class checks render correctly in
160    /// the TUI compliance tab.
161    pub(crate) cra_sidecar: Option<crate::model::CraSidecarMetadata>,
162
163    /// Optional effective CRA Annex III/IV product class (resolved by the
164    /// CLI: sidecar `productClass` wins over `--cra-product-class`). Applied
165    /// by `compute_compliance_results` and TUI exports so the compliance tab
166    /// renders the same severity-calibrated verdicts as the non-TUI report
167    /// path.
168    pub(crate) cra_product_class: Option<crate::model::CraProductClass>,
169}
170
171impl ViewApp {
172    /// Create a new `ViewApp` for the given SBOM.
173    #[must_use]
174    pub fn new(
175        sbom: NormalizedSbom,
176        raw_content: &str,
177        bom_profile: crate::model::BomProfile,
178    ) -> Self {
179        let stats = SbomStats::from_sbom(&sbom);
180
181        // Calculate quality score
182        let scoring_profile = crate::tui::scoring_profile_for(bom_profile);
183        let scorer = QualityScorer::new(scoring_profile);
184        let quality_report = scorer.score(&sbom);
185        let quality_state = QualityViewState::new(quality_report.recommendations.len());
186
187        let compliance_state = StandardComplianceState::new();
188
189        // Build index for fast lookups (O(1) instead of O(n))
190        let sbom_index = sbom.build_index();
191
192        // Build source panel state from raw content
193        let source_state = SourcePanelState::new(raw_content);
194
195        // Pre-expand the first few ecosystems
196        let mut tree_state = TreeState::new();
197        for eco in stats.ecosystem_counts.keys().take(3) {
198            tree_state.expand(&format!("eco:{eco}"));
199        }
200
201        // Restore last active tab from preferences (must be valid for this profile)
202        let available_tabs = ViewTab::tabs_for_profile(bom_profile);
203        let initial_tab = crate::config::TuiPreferences::load()
204            .last_view_tab
205            .as_deref()
206            .and_then(ViewTab::from_str_opt)
207            .filter(|t| available_tabs.contains(t))
208            .unwrap_or(ViewTab::Overview);
209
210        let mut app = Self {
211            sbom,
212            bom_profile,
213            active_tab: initial_tab,
214            tree_state,
215            tree_group_by: TreeGroupBy::Ecosystem,
216            tree_filter: TreeFilter::All,
217            tab_window: crate::tui::shared::TabWindow::default(),
218            tree_search_query: String::new(),
219            tree_search_active: false,
220            cached_tree_nodes: Vec::new(),
221            tree_cache_key: None,
222            selected_component: None,
223            component_tab: ComponentDetailTab::Overview,
224            component_detail_scroll: 0,
225            vuln_state: VulnExplorerState::new(),
226            license_state: LicenseViewState::new(),
227            dependency_state: DependencyViewState::new(),
228            search_state: SearchState::new(),
229            focus_panel: FocusPanel::Left,
230            show_help: false,
231            help_scroll: 0,
232            help_max_scroll: 0,
233            show_export: false,
234            show_legend: false,
235            status_message: None,
236            status_sticky: false,
237            navigation_ctx: ViewNavigationContext::new(),
238            should_quit: false,
239            tick: 0,
240            stats,
241            quality_report,
242            quality_state,
243            compliance_results: None,
244            compliance_state,
245            sbom_index,
246            source_state,
247            crypto_list_selected: 0,
248            algorithms_selected: 0,
249            certificates_selected: 0,
250            keys_selected: 0,
251            protocols_selected: 0,
252            pqc_selected: 0,
253            algorithm_sort_by: AlgorithmSortBy::default(),
254            models_selected: 0,
255            datasets_selected: 0,
256            models_detail_scroll: 0,
257            datasets_detail_scroll: 0,
258            ai_readiness_scroll: 0,
259            bookmarked: HashSet::new(),
260            export_template: None,
261            cra_sidecar: None,
262            cra_product_class: None,
263        };
264
265        // Pre-compute vuln cache at startup to avoid freeze on first tab visit
266        let cache = super::views::build_vuln_cache(&app);
267        app.vuln_state.set_cache(cache);
268
269        app
270    }
271
272    /// Set the CRA sidecar metadata on this `ViewApp`. Stored so the
273    /// compliance tab can render OSS-Steward / EUCC / Article 14 /
274    /// product-class checks against the same sidecar the CLI uses.
275    pub fn with_cra_sidecar(mut self, sidecar: crate::model::CraSidecarMetadata) -> Self {
276        // Invalidate cached results so the sidecar takes effect on next render.
277        self.compliance_results = None;
278        self.cra_sidecar = Some(sidecar);
279        self
280    }
281
282    /// Set the effective CRA product class on this `ViewApp` (the CLI
283    /// resolves it: sidecar wins over `--cra-product-class`). Stored so the
284    /// compliance tab and TUI exports apply the same severity calibration as
285    /// the non-TUI report path instead of silently scoring `Default` class.
286    pub fn with_cra_product_class(mut self, class: crate::model::CraProductClass) -> Self {
287        // Invalidate cached results so the class takes effect on next render.
288        self.compliance_results = None;
289        self.cra_product_class = Some(class);
290        self
291    }
292
293    /// Lazily compute compliance results for all standards when first needed.
294    pub fn ensure_compliance_results(&mut self) {
295        if self.compliance_results.is_none() {
296            self.compliance_results = Some(compute_compliance_results(
297                &self.sbom,
298                self.cra_sidecar.as_ref(),
299                self.cra_product_class,
300            ));
301        }
302    }
303
304    /// Switch to the next tab (cycles within profile's tab set).
305    pub fn next_tab(&mut self) {
306        let tabs = ViewTab::tabs_for_profile(self.bom_profile);
307        let idx = tabs.iter().position(|t| *t == self.active_tab).unwrap_or(0);
308        self.active_tab = tabs[(idx + 1) % tabs.len()];
309        self.focus_panel = FocusPanel::Left;
310    }
311
312    /// Switch to the previous tab (cycles within profile's tab set).
313    pub fn prev_tab(&mut self) {
314        let tabs = ViewTab::tabs_for_profile(self.bom_profile);
315        let idx = tabs.iter().position(|t| *t == self.active_tab).unwrap_or(0);
316        self.active_tab = tabs[(idx + tabs.len() - 1) % tabs.len()];
317        self.focus_panel = FocusPanel::Left;
318    }
319
320    /// Select a specific tab, resetting focus to the left (list) panel.
321    pub fn select_tab(&mut self, tab: ViewTab) {
322        self.active_tab = tab;
323        self.focus_panel = FocusPanel::Left;
324    }
325
326    // ========================================================================
327    // CBOM per-tab selection helpers
328    // ========================================================================
329
330    /// Get the selection index for the active CBOM tab.
331    pub fn active_crypto_selected(&self) -> usize {
332        match self.active_tab {
333            ViewTab::Algorithms => self.algorithms_selected,
334            ViewTab::Certificates => self.certificates_selected,
335            ViewTab::Keys => self.keys_selected,
336            ViewTab::Protocols => self.protocols_selected,
337            ViewTab::PqcCompliance => self.pqc_selected,
338            _ => self.crypto_list_selected,
339        }
340    }
341
342    /// Get a mutable reference to the selection index for the active CBOM tab.
343    pub fn active_crypto_selected_mut(&mut self) -> &mut usize {
344        match self.active_tab {
345            ViewTab::Algorithms => &mut self.algorithms_selected,
346            ViewTab::Certificates => &mut self.certificates_selected,
347            ViewTab::Keys => &mut self.keys_selected,
348            ViewTab::Protocols => &mut self.protocols_selected,
349            ViewTab::PqcCompliance => &mut self.pqc_selected,
350            _ => &mut self.crypto_list_selected,
351        }
352    }
353
354    /// The crypto components exactly as the given CBOM tab renders them:
355    /// same asset-type filter, same sort order.
356    ///
357    /// KEEP IN LOCKSTEP with `views/{crypto,algorithms,certificates,keys,protocols}.rs`
358    /// — yank ('y') and the footer "[y] copy …" preview index THIS list with
359    /// the tab's selection index, so any drift copies the wrong asset.
360    pub fn visible_crypto_components(&self, tab: ViewTab) -> Vec<&Component> {
361        use crate::model::{ComponentType, CryptoAssetType};
362        let filter = match tab {
363            ViewTab::Algorithms => Some(CryptoAssetType::Algorithm),
364            ViewTab::Certificates => Some(CryptoAssetType::Certificate),
365            ViewTab::Keys => Some(CryptoAssetType::RelatedCryptoMaterial),
366            ViewTab::Protocols => Some(CryptoAssetType::Protocol),
367            // Unified Crypto tab: every crypto asset, document order.
368            _ => None,
369        };
370        let mut list: Vec<&Component> = self
371            .sbom
372            .components
373            .values()
374            .filter(|c| {
375                c.component_type == ComponentType::Cryptographic
376                    && filter.as_ref().is_none_or(|f| {
377                        c.crypto_properties
378                            .as_ref()
379                            .is_some_and(|cp| &cp.asset_type == f)
380                    })
381            })
382            .collect();
383        match tab {
384            // Mirrors views/algorithms.rs (sorted by the active sort mode).
385            ViewTab::Algorithms => {
386                list.sort_by(|a, b| match self.algorithm_sort_by {
387                    AlgorithmSortBy::Name => a.name.cmp(&b.name),
388                    AlgorithmSortBy::Family => {
389                        let fam = |c: &&Component| {
390                            c.crypto_properties
391                                .as_ref()
392                                .and_then(|cp| cp.algorithm_properties.as_ref())
393                                .and_then(|algo| algo.algorithm_family.clone())
394                                .unwrap_or_default()
395                        };
396                        fam(a).cmp(&fam(b))
397                    }
398                    AlgorithmSortBy::QuantumLevel => {
399                        let lvl = |c: &&Component| {
400                            c.crypto_properties
401                                .as_ref()
402                                .and_then(|cp| cp.algorithm_properties.as_ref())
403                                .and_then(|algo| algo.nist_quantum_security_level)
404                                .unwrap_or(0)
405                        };
406                        lvl(b).cmp(&lvl(a)) // descending: highest quantum level first
407                    }
408                    AlgorithmSortBy::Strength => {
409                        let strength = |c: &&Component| -> u8 {
410                            let Some(cp) = &c.crypto_properties else {
411                                return 1;
412                            };
413                            let Some(algo) = &cp.algorithm_properties else {
414                                return 1;
415                            };
416                            if algo.is_weak_by_name(&c.name) {
417                                return 0;
418                            }
419                            if algo.nist_quantum_security_level == Some(0) {
420                                return 1;
421                            }
422                            2
423                        };
424                        strength(a).cmp(&strength(b))
425                    }
426                });
427            }
428            // Mirrors views/certificates.rs (most urgent expiry first).
429            ViewTab::Certificates => {
430                let days_remaining = |c: &&Component| -> i64 {
431                    c.crypto_properties
432                        .as_ref()
433                        .and_then(|cp| cp.certificate_properties.as_ref())
434                        .and_then(|cert| cert.validity_days())
435                        .unwrap_or(i64::MAX)
436                };
437                list.sort_by_key(days_remaining);
438            }
439            // Keys / Protocols / Crypto render in document order.
440            _ => {}
441        }
442        list
443    }
444
445    /// Count crypto components for the active tab (filtered by asset type).
446    pub fn crypto_count_for_tab(&self) -> usize {
447        use crate::model::{ComponentType, CryptoAssetType};
448        let filter = match self.active_tab {
449            ViewTab::Algorithms | ViewTab::PqcCompliance => Some(CryptoAssetType::Algorithm),
450            ViewTab::Certificates => Some(CryptoAssetType::Certificate),
451            ViewTab::Keys => Some(CryptoAssetType::RelatedCryptoMaterial),
452            ViewTab::Protocols => Some(CryptoAssetType::Protocol),
453            _ => None,
454        };
455        self.sbom
456            .components
457            .values()
458            .filter(|c| {
459                c.component_type == ComponentType::Cryptographic
460                    && filter.as_ref().is_none_or(|f| {
461                        c.crypto_properties
462                            .as_ref()
463                            .is_some_and(|cp| &cp.asset_type == f)
464                    })
465            })
466            .count()
467    }
468
469    // ========================================================================
470    // AI-BOM per-tab selection helpers
471    // ========================================================================
472
473    /// Count the components the Models tab lists: `MachineLearningModel`
474    /// components plus mistyped carriers (a parsed model card under a wrong
475    /// CycloneDX `type`, which the tab surfaces badged). Keeping this count
476    /// in sync with the tab keeps j/k selection and the status bar honest.
477    #[must_use]
478    pub fn ml_model_count(&self) -> usize {
479        use crate::model::ComponentType;
480        self.sbom
481            .components
482            .values()
483            .filter(|c| {
484                c.component_type == ComponentType::MachineLearningModel || c.ml_model.is_some()
485            })
486            .count()
487    }
488
489    /// Max scroll offset for the AI-Readiness tab: one shared offset drives
490    /// both the checks table and the recommendations pane, so the bound is
491    /// the longer of the two lists.
492    #[must_use]
493    pub fn ai_readiness_max_scroll(&self) -> usize {
494        let checks = self
495            .quality_report
496            .ai_readiness_metrics
497            .as_ref()
498            .map_or(0, |m| m.checks.len());
499        checks
500            .max(self.quality_report.recommendations.len())
501            .saturating_sub(1)
502    }
503
504    /// Count `Data` components (Datasets tab).
505    #[must_use]
506    pub fn dataset_count(&self) -> usize {
507        use crate::model::ComponentType;
508        self.sbom
509            .components
510            .values()
511            .filter(|c| c.component_type == ComponentType::Data)
512            .count()
513    }
514
515    // ========================================================================
516    // Index access methods for O(1) lookups
517    // ========================================================================
518
519    /// Get the sort key for a component using the cached index.
520    ///
521    /// Returns pre-computed lowercase strings to avoid repeated allocations during sorting.
522    #[must_use]
523    pub fn get_sort_key(
524        &self,
525        id: &crate::model::CanonicalId,
526    ) -> Option<&crate::model::ComponentSortKey> {
527        self.sbom_index.sort_key(id)
528    }
529
530    /// Get dependencies of a component using the cached index (O(k) instead of O(edges)).
531    #[must_use]
532    pub fn get_dependencies(
533        &self,
534        id: &crate::model::CanonicalId,
535    ) -> Vec<&crate::model::DependencyEdge> {
536        self.sbom_index.dependencies_of(id, &self.sbom.edges)
537    }
538
539    /// Get dependents of a component using the cached index (O(k) instead of O(edges)).
540    #[must_use]
541    pub fn get_dependents(
542        &self,
543        id: &crate::model::CanonicalId,
544    ) -> Vec<&crate::model::DependencyEdge> {
545        self.sbom_index.dependents_of(id, &self.sbom.edges)
546    }
547
548    /// Search components by name using the cached index.
549    #[must_use]
550    pub fn search_components_by_name(&self, query: &str) -> Vec<&crate::model::Component> {
551        self.sbom.search_by_name_indexed(query, &self.sbom_index)
552    }
553
554    /// Toggle focus between left and right panels.
555    pub const fn toggle_focus(&mut self) {
556        self.focus_panel = match self.focus_panel {
557            FocusPanel::Left => FocusPanel::Right,
558            FocusPanel::Right => FocusPanel::Left,
559        };
560    }
561
562    /// Start search mode.
563    pub fn start_search(&mut self) {
564        self.search_state.active = true;
565        self.search_state.query.clear();
566        self.search_state.results.clear();
567        self.search_state.search_error = None;
568    }
569
570    /// Stop search mode.
571    pub const fn stop_search(&mut self) {
572        self.search_state.active = false;
573    }
574
575    /// Execute search with current query.
576    pub fn execute_search(&mut self) {
577        let query = self.search_state.query.clone();
578        if query.len() < 2 {
579            self.search_state.results = Vec::new();
580            self.search_state.selected = 0;
581            return;
582        }
583        // Shared matcher: same substring/regex semantics as diff mode.
584        match crate::tui::app_states::SearchMatcher::build(&query, self.search_state.mode) {
585            Ok(matcher) => {
586                self.search_state.search_error = None;
587                self.search_state.results = self.search(&matcher);
588            }
589            Err(e) => {
590                self.search_state.search_error = Some(e);
591                self.search_state.results = Vec::new();
592            }
593        }
594        self.search_state.selected = 0;
595    }
596
597    /// Search across the SBOM for matching items.
598    fn search(&self, matcher: &crate::tui::app_states::SearchMatcher) -> Vec<SearchResult> {
599        let mut results = Vec::new();
600
601        // Search components
602        for (id, comp) in &self.sbom.components {
603            if matcher.is_match(&comp.name) {
604                results.push(SearchResult::Component {
605                    id: id.value().to_string(),
606                    name: comp.name.clone(),
607                    version: comp.version.clone(),
608                    match_field: "name".to_string(),
609                });
610            } else if let Some(purl) = &comp.identifiers.purl
611                && matcher.is_match(purl)
612            {
613                results.push(SearchResult::Component {
614                    id: id.value().to_string(),
615                    name: comp.name.clone(),
616                    version: comp.version.clone(),
617                    match_field: "purl".to_string(),
618                });
619            }
620        }
621
622        // Search vulnerabilities
623        for (_, comp) in &self.sbom.components {
624            for vuln in &comp.vulnerabilities {
625                if matcher.is_match(&vuln.id) {
626                    results.push(SearchResult::Vulnerability {
627                        id: vuln.id.clone(),
628                        component_id: comp.canonical_id.to_string(), // Store ID for navigation
629                        component_name: comp.name.clone(),
630                        severity: vuln.severity.as_ref().map(std::string::ToString::to_string),
631                    });
632                }
633            }
634        }
635
636        // Limit results
637        results.truncate(50);
638        results
639    }
640
641    /// Get the currently selected component.
642    #[must_use]
643    pub fn get_selected_component(&self) -> Option<&Component> {
644        self.selected_component.as_ref().and_then(|selected_id| {
645            self.sbom
646                .components
647                .iter()
648                .find(|(id, _)| id.value() == selected_id)
649                .map(|(_, comp)| comp)
650        })
651    }
652
653    /// Jump tree selection to a component, expanding its group if needed.
654    pub fn jump_to_component_in_tree(&mut self, component_id: &str) -> bool {
655        let group_id = {
656            let Some(comp) = self
657                .sbom
658                .components
659                .iter()
660                .find(|(id, _)| id.value() == component_id)
661                .map(|(_, comp)| comp)
662            else {
663                return false;
664            };
665            self.tree_group_id_for_component(comp)
666        };
667        if let Some(ref group_id) = group_id {
668            self.tree_state.expand(group_id);
669        }
670
671        self.ensure_tree_cache();
672        let mut flat_items = Vec::new();
673        flatten_tree_for_selection(&self.cached_tree_nodes, &self.tree_state, &mut flat_items);
674
675        if let Some(index) = flat_items
676            .iter()
677            .position(|item| matches!(item, SelectedTreeNode::Component(id) if id == component_id))
678        {
679            self.tree_state.selected = index;
680            return true;
681        }
682
683        if let Some(group_id) = group_id
684            && let Some(index) = flat_items
685                .iter()
686                .position(|item| matches!(item, SelectedTreeNode::Group(id) if id == &group_id))
687        {
688            self.tree_state.selected = index;
689        }
690
691        false
692    }
693
694    /// Jump to a vulnerability by its ID (e.g., "CVE-2024-1234") in the Vulnerabilities tab.
695    ///
696    /// Returns `true` if the vulnerability was found and selected.
697    pub fn jump_to_vuln_by_id(&mut self, vuln_id: &str) -> bool {
698        // Ensure cache is built
699        if self.vuln_state.cached_data.is_none() {
700            let cache = super::views::build_vuln_cache(self);
701            self.vuln_state.cached_data = Some(std::sync::Arc::new(cache));
702        }
703        let Some(cache) = &self.vuln_state.cached_data else {
704            return false;
705        };
706        // Find the vuln in the cache by ID
707        if let Some(vuln_idx) = cache.vulns.iter().position(|v| v.vuln_id == vuln_id) {
708            // In flat mode, the display item index matches the vuln index
709            // In grouped mode, we need to find the VulnDisplayItem that wraps this vuln
710            let display_idx = self
711                .vuln_state
712                .cached_display_items
713                .iter()
714                .position(|item| {
715                    matches!(item, super::views::VulnDisplayItem::Vuln { idx, .. } if *idx == vuln_idx)
716                })
717                .unwrap_or(vuln_idx);
718            self.vuln_state.selected = display_idx;
719            return true;
720        }
721        false
722    }
723
724    /// Find a source tree item whose value matches a given reference string (bom-ref, CVE ID, etc.)
725    /// and return its index in `cached_flat_items`.
726    pub fn find_source_item_for_ref(&mut self, ref_value: &str) -> Option<usize> {
727        self.source_state.ensure_flat_cache();
728        let quoted = format!("\"{ref_value}\"");
729        self.source_state
730            .cached_flat_items
731            .iter()
732            .position(|item| item.value_preview == quoted || item.value_preview == ref_value)
733    }
734
735    /// Get the currently selected tree node info (Group label + children component IDs,
736    /// or None if a component is selected or nothing is selected).
737    #[must_use]
738    pub fn get_selected_group_info(&self) -> Option<(String, Vec<String>)> {
739        let nodes = self.build_tree_nodes();
740        let mut flat_items = Vec::new();
741        flatten_tree_for_selection(nodes, &self.tree_state, &mut flat_items);
742
743        let selected = flat_items.get(self.tree_state.selected)?;
744        match selected {
745            SelectedTreeNode::Group(group_id) => {
746                // Find the group in tree nodes and collect child component IDs
747                fn find_group_children(
748                    nodes: &[crate::tui::widgets::TreeNode],
749                    target_id: &str,
750                ) -> Option<(String, Vec<String>)> {
751                    for node in nodes {
752                        if let crate::tui::widgets::TreeNode::Group {
753                            id,
754                            label,
755                            children,
756                            ..
757                        } = node
758                        {
759                            if id == target_id {
760                                let child_ids: Vec<String> = children
761                                    .iter()
762                                    .filter_map(|c| match c {
763                                        crate::tui::widgets::TreeNode::Component { id, .. } => {
764                                            Some(id.clone())
765                                        }
766                                        crate::tui::widgets::TreeNode::Group { .. } => None,
767                                    })
768                                    .collect();
769                                return Some((label.clone(), child_ids));
770                            }
771                            // Recurse into subgroups
772                            if let Some(result) = find_group_children(children, target_id) {
773                                return Some(result);
774                            }
775                        }
776                    }
777                    None
778                }
779                find_group_children(nodes, group_id)
780            }
781            SelectedTreeNode::Component(_) => None,
782        }
783    }
784
785    /// Toggle bookmark on the currently selected component.
786    pub fn toggle_bookmark(&mut self) {
787        if let Some(ref comp_id) = self.selected_component {
788            if self.bookmarked.contains(comp_id) {
789                self.bookmarked.remove(comp_id);
790            } else {
791                self.bookmarked.insert(comp_id.clone());
792            }
793        } else if let Some(node) = self.get_selected_tree_node() {
794            match node {
795                SelectedTreeNode::Component(id) => {
796                    if self.bookmarked.contains(&id) {
797                        self.bookmarked.remove(&id);
798                    } else {
799                        self.bookmarked.insert(id);
800                    }
801                }
802                SelectedTreeNode::Group(_) => {}
803            }
804        }
805    }
806
807    /// Toggle tree grouping mode.
808    pub fn toggle_tree_grouping(&mut self) {
809        self.tree_group_by = match self.tree_group_by {
810            TreeGroupBy::Ecosystem => TreeGroupBy::License,
811            TreeGroupBy::License => TreeGroupBy::VulnStatus,
812            TreeGroupBy::VulnStatus => TreeGroupBy::ComponentType,
813            TreeGroupBy::ComponentType => TreeGroupBy::Flat,
814            TreeGroupBy::Flat => TreeGroupBy::Ecosystem,
815        };
816        self.tree_state = TreeState::new(); // Reset tree state on grouping change
817    }
818
819    /// Toggle tree filter.
820    pub fn toggle_tree_filter(&mut self) {
821        self.tree_filter = match self.tree_filter {
822            TreeFilter::All => TreeFilter::HasVulnerabilities,
823            TreeFilter::HasVulnerabilities => TreeFilter::Critical,
824            TreeFilter::Critical => TreeFilter::Bookmarked,
825            TreeFilter::Bookmarked => TreeFilter::All,
826        };
827        self.tree_state = TreeState::new();
828    }
829
830    /// Start tree search mode.
831    pub fn start_tree_search(&mut self) {
832        self.tree_search_active = true;
833        self.tree_search_query.clear();
834    }
835
836    /// Stop tree search mode.
837    pub const fn stop_tree_search(&mut self) {
838        self.tree_search_active = false;
839    }
840
841    /// Clear tree search and exit search mode.
842    pub fn clear_tree_search(&mut self) {
843        self.tree_search_query.clear();
844        self.tree_search_active = false;
845        self.tree_state = TreeState::new();
846    }
847
848    /// Add character to tree search query.
849    pub fn tree_search_push_char(&mut self, c: char) {
850        self.tree_search_query.push(c);
851        self.tree_state = TreeState::new();
852    }
853
854    /// Remove character from tree search query.
855    pub fn tree_search_pop_char(&mut self) {
856        self.tree_search_query.pop();
857        self.tree_state = TreeState::new();
858    }
859
860    /// Cycle to next component detail tab.
861    pub const fn next_component_tab(&mut self) {
862        self.component_tab = match self.component_tab {
863            ComponentDetailTab::Overview => ComponentDetailTab::Identifiers,
864            ComponentDetailTab::Identifiers => ComponentDetailTab::Vulnerabilities,
865            ComponentDetailTab::Vulnerabilities => ComponentDetailTab::Dependencies,
866            ComponentDetailTab::Dependencies => ComponentDetailTab::Overview,
867        };
868        self.component_detail_scroll = 0;
869    }
870
871    /// Cycle to previous component detail tab.
872    pub const fn prev_component_tab(&mut self) {
873        self.component_tab = match self.component_tab {
874            ComponentDetailTab::Overview => ComponentDetailTab::Dependencies,
875            ComponentDetailTab::Identifiers => ComponentDetailTab::Overview,
876            ComponentDetailTab::Vulnerabilities => ComponentDetailTab::Identifiers,
877            ComponentDetailTab::Dependencies => ComponentDetailTab::Vulnerabilities,
878        };
879        self.component_detail_scroll = 0;
880    }
881
882    /// Select a specific component detail tab.
883    pub(crate) const fn select_component_tab(&mut self, tab: ComponentDetailTab) {
884        self.component_tab = tab;
885        self.component_detail_scroll = 0;
886    }
887
888    /// Toggle help overlay.
889    pub const fn toggle_help(&mut self) {
890        self.show_help = !self.show_help;
891        if self.show_help {
892            self.show_export = false;
893            self.show_legend = false;
894            self.help_scroll = 0;
895            self.help_max_scroll = 0;
896        }
897    }
898
899    /// Toggle export dialog.
900    pub const fn toggle_export(&mut self) {
901        self.show_export = !self.show_export;
902        if self.show_export {
903            self.show_help = false;
904            self.show_legend = false;
905        }
906    }
907
908    /// Toggle legend overlay.
909    pub const fn toggle_legend(&mut self) {
910        self.show_legend = !self.show_legend;
911        if self.show_legend {
912            self.show_help = false;
913            self.show_export = false;
914        }
915    }
916
917    /// Close all overlays.
918    pub const fn close_overlays(&mut self) {
919        self.show_help = false;
920        self.show_export = false;
921        self.show_legend = false;
922        self.search_state.active = false;
923        self.compliance_state.show_detail = false;
924    }
925
926    /// Check if any overlay is open.
927    #[must_use]
928    pub const fn has_overlay(&self) -> bool {
929        self.show_help
930            || self.show_export
931            || self.show_legend
932            || self.search_state.active
933            || self.compliance_state.show_detail
934    }
935
936    /// Set a temporary status message.
937    pub fn set_status_message(&mut self, msg: impl Into<String>) {
938        self.status_message = Some(msg.into());
939    }
940
941    /// Clear the status message.
942    ///
943    /// If `status_sticky` is set the message is kept for one extra keypress,
944    /// then cleared on the subsequent call.
945    pub fn clear_status_message(&mut self) {
946        if self.status_sticky {
947            self.status_sticky = false;
948        } else {
949            self.status_message = None;
950        }
951    }
952
953    /// Export the current SBOM to a file.
954    ///
955    /// The export is scoped to the active tab: e.g. if the user is on the
956    /// Vulnerabilities tab only vulnerability data is included.
957    pub fn export(&mut self, format: crate::tui::export::ExportFormat) {
958        use crate::reports::ReportConfig;
959        use crate::tui::export::{export_view, view_tab_to_report_type};
960
961        let report_type = view_tab_to_report_type(self.active_tab);
962        // Pre-compute CRA Phase 2 with this view's sidecar and product class
963        // so exported reports carry the same verdicts as the compliance tab
964        // instead of falling back to a bare (sidecar-less, Default-class)
965        // checker.
966        let mut cra_checker =
967            crate::quality::ComplianceChecker::new(crate::quality::ComplianceLevel::CraPhase2);
968        if let Some(sidecar) = &self.cra_sidecar {
969            cra_checker = cra_checker.with_sidecar(sidecar.clone());
970        }
971        if let Some(class) = self.cra_product_class {
972            cra_checker = cra_checker.with_product_class(class);
973        }
974        let config = ReportConfig {
975            view_cra_compliance: Some(cra_checker.check(&self.sbom)),
976            ..ReportConfig::with_types(vec![report_type])
977        };
978        let result = export_view(
979            format,
980            &self.sbom,
981            None,
982            &config,
983            self.export_template.as_deref(),
984        );
985
986        if result.success {
987            self.set_status_message(result.message);
988            self.status_sticky = true;
989        } else {
990            self.set_status_message(format!("Export failed: {}", result.message));
991        }
992    }
993
994    /// Export compliance results from the compliance tab
995    pub fn export_compliance(&mut self, format: crate::tui::export::ExportFormat) {
996        use crate::tui::export::export_compliance;
997
998        self.ensure_compliance_results();
999        let results = match self.compliance_results.as_ref() {
1000            Some(r) if !r.is_empty() => r,
1001            _ => {
1002                self.set_status_message("No compliance results to export");
1003                return;
1004            }
1005        };
1006
1007        let result = export_compliance(
1008            format,
1009            results,
1010            self.compliance_state.selected_standard,
1011            None,
1012            self.export_template.as_deref(),
1013        );
1014        if result.success {
1015            self.set_status_message(result.message);
1016            self.status_sticky = true;
1017        } else {
1018            self.set_status_message(format!("Export failed: {}", result.message));
1019        }
1020    }
1021
1022    /// Navigate back using breadcrumb history.
1023    pub fn go_back(&mut self) -> bool {
1024        if let Some(breadcrumb) = self.navigation_ctx.pop_breadcrumb() {
1025            self.active_tab = breadcrumb.tab;
1026            // Restore selection index based on tab
1027            match breadcrumb.tab {
1028                ViewTab::Vulnerabilities => {
1029                    self.vuln_state.selected = breadcrumb.selection_index;
1030                }
1031                ViewTab::Licenses => {
1032                    self.license_state.selected = breadcrumb.selection_index;
1033                }
1034                ViewTab::Dependencies => {
1035                    self.dependency_state.selected = breadcrumb.selection_index;
1036                }
1037                ViewTab::Tree => {
1038                    self.tree_state.selected = breadcrumb.selection_index;
1039                }
1040                ViewTab::Source => {
1041                    self.source_state.selected = breadcrumb.selection_index;
1042                }
1043                _ => {}
1044            }
1045            self.focus_panel = FocusPanel::Left;
1046            true
1047        } else {
1048            false
1049        }
1050    }
1051
1052    /// Handle navigation in current view.
1053    pub fn navigate_up(&mut self) {
1054        match self.active_tab {
1055            ViewTab::Tree => self.tree_state.select_prev(),
1056            ViewTab::Vulnerabilities => self.vuln_state.select_prev(),
1057            ViewTab::Licenses => self.license_state.select_prev(),
1058            ViewTab::Dependencies => self.dependency_state.select_prev(),
1059            ViewTab::Quality => self.quality_state.select_prev(),
1060            ViewTab::Compliance => self.compliance_state.select_prev(),
1061            ViewTab::Source => self.source_state.select_prev(),
1062            ViewTab::Crypto
1063            | ViewTab::Algorithms
1064            | ViewTab::Certificates
1065            | ViewTab::Keys
1066            | ViewTab::Protocols
1067            | ViewTab::PqcCompliance => {
1068                let sel = self.active_crypto_selected_mut();
1069                *sel = sel.saturating_sub(1);
1070            }
1071            ViewTab::Models => {
1072                self.models_selected = self.models_selected.saturating_sub(1);
1073                self.models_detail_scroll = 0;
1074            }
1075            ViewTab::Datasets => {
1076                self.datasets_selected = self.datasets_selected.saturating_sub(1);
1077                self.datasets_detail_scroll = 0;
1078            }
1079            ViewTab::AiReadiness => {
1080                self.ai_readiness_scroll = self.ai_readiness_scroll.saturating_sub(1);
1081            }
1082            ViewTab::Overview => {}
1083        }
1084    }
1085
1086    /// Handle navigation in current view.
1087    pub fn navigate_down(&mut self) {
1088        match self.active_tab {
1089            ViewTab::Tree => self.tree_state.select_next(),
1090            ViewTab::Vulnerabilities => self.vuln_state.select_next(),
1091            ViewTab::Licenses => self.license_state.select_next(),
1092            ViewTab::Dependencies => self.dependency_state.select_next(),
1093            ViewTab::Quality => self.quality_state.select_next(),
1094            ViewTab::Compliance => {
1095                self.ensure_compliance_results();
1096                let max = self.filtered_compliance_violation_count();
1097                self.compliance_state.select_next(max);
1098            }
1099            ViewTab::Source => self.source_state.select_next(),
1100            ViewTab::Crypto
1101            | ViewTab::Algorithms
1102            | ViewTab::Certificates
1103            | ViewTab::Keys
1104            | ViewTab::Protocols
1105            | ViewTab::PqcCompliance => {
1106                let max = self.crypto_count_for_tab().saturating_sub(1);
1107                let sel = self.active_crypto_selected_mut();
1108                *sel = sel.saturating_add(1).min(max);
1109            }
1110            ViewTab::Models => {
1111                let max = self.ml_model_count().saturating_sub(1);
1112                self.models_selected = self.models_selected.saturating_add(1).min(max);
1113                self.models_detail_scroll = 0;
1114            }
1115            ViewTab::Datasets => {
1116                let max = self.dataset_count().saturating_sub(1);
1117                self.datasets_selected = self.datasets_selected.saturating_add(1).min(max);
1118                self.datasets_detail_scroll = 0;
1119            }
1120            ViewTab::AiReadiness => {
1121                self.ai_readiness_scroll = self
1122                    .ai_readiness_scroll
1123                    .saturating_add(1)
1124                    .min(self.ai_readiness_max_scroll());
1125            }
1126            ViewTab::Overview => {}
1127        }
1128    }
1129
1130    /// Count compliance violations that pass the current severity filter.
1131    pub(crate) fn filtered_compliance_violation_count(&self) -> usize {
1132        self.compliance_results
1133            .as_ref()
1134            .and_then(|r| r.get(self.compliance_state.selected_standard))
1135            .map_or(0, |r| {
1136                if self.compliance_state.grouped {
1137                    // In grouped mode, count is the number of groups
1138                    super::views::build_groups(r, self.compliance_state.severity_filter).len()
1139                } else {
1140                    r.violations
1141                        .iter()
1142                        .filter(|v| self.compliance_state.severity_filter.matches(v.severity))
1143                        .count()
1144                }
1145            })
1146    }
1147
1148    /// Page up - move up by page size.
1149    pub fn page_up(&mut self) {
1150        use crate::tui::constants::PAGE_SIZE;
1151        if self.active_tab == ViewTab::Source {
1152            self.source_state.page_up();
1153        } else {
1154            for _ in 0..PAGE_SIZE {
1155                self.navigate_up();
1156            }
1157        }
1158    }
1159
1160    /// Page down - move down by page size.
1161    pub fn page_down(&mut self) {
1162        use crate::tui::constants::PAGE_SIZE;
1163        if self.active_tab == ViewTab::Source {
1164            self.source_state.page_down();
1165        } else {
1166            for _ in 0..PAGE_SIZE {
1167                self.navigate_down();
1168            }
1169        }
1170    }
1171
1172    /// Go to first item in current view.
1173    pub fn go_first(&mut self) {
1174        match self.active_tab {
1175            ViewTab::Tree => self.tree_state.select_first(),
1176            ViewTab::Vulnerabilities => self.vuln_state.selected = 0,
1177            ViewTab::Licenses => self.license_state.selected = 0,
1178            ViewTab::Dependencies => self.dependency_state.selected = 0,
1179            ViewTab::Quality => self.quality_state.scroll_offset = 0,
1180            ViewTab::Compliance => self.compliance_state.selected_violation = 0,
1181            ViewTab::Source => self.source_state.select_first(),
1182            ViewTab::Crypto
1183            | ViewTab::Algorithms
1184            | ViewTab::Certificates
1185            | ViewTab::Keys
1186            | ViewTab::Protocols
1187            | ViewTab::PqcCompliance => *self.active_crypto_selected_mut() = 0,
1188            ViewTab::Models => self.models_selected = 0,
1189            ViewTab::Datasets => self.datasets_selected = 0,
1190            ViewTab::AiReadiness => self.ai_readiness_scroll = 0,
1191            ViewTab::Overview => {}
1192        }
1193    }
1194
1195    /// Go to last item in current view.
1196    pub fn go_last(&mut self) {
1197        match self.active_tab {
1198            ViewTab::Tree => self.tree_state.select_last(),
1199            ViewTab::Vulnerabilities => {
1200                self.vuln_state.selected = self.vuln_state.total.saturating_sub(1);
1201            }
1202            ViewTab::Licenses => {
1203                self.license_state.selected = self.license_state.total.saturating_sub(1);
1204            }
1205            ViewTab::Dependencies => {
1206                self.dependency_state.selected = self.dependency_state.total.saturating_sub(1);
1207            }
1208            ViewTab::Quality => {
1209                self.quality_state.scroll_offset =
1210                    self.quality_state.total_recommendations.saturating_sub(1);
1211            }
1212            ViewTab::Compliance => {
1213                self.ensure_compliance_results();
1214                let max = self.filtered_compliance_violation_count();
1215                self.compliance_state.selected_violation = max.saturating_sub(1);
1216            }
1217            ViewTab::Source => self.source_state.select_last(),
1218            ViewTab::Crypto
1219            | ViewTab::Algorithms
1220            | ViewTab::Certificates
1221            | ViewTab::Keys
1222            | ViewTab::Protocols
1223            | ViewTab::PqcCompliance => {
1224                let max = self.crypto_count_for_tab();
1225                *self.active_crypto_selected_mut() = max.saturating_sub(1);
1226            }
1227            ViewTab::Models => self.models_selected = self.ml_model_count().saturating_sub(1),
1228            ViewTab::Datasets => self.datasets_selected = self.dataset_count().saturating_sub(1),
1229            ViewTab::AiReadiness => self.ai_readiness_scroll = self.ai_readiness_max_scroll(),
1230            ViewTab::Overview => {}
1231        }
1232    }
1233
1234    /// Handle enter/select action.
1235    pub fn handle_enter(&mut self) {
1236        match self.active_tab {
1237            ViewTab::Tree => {
1238                // Toggle expand or select component
1239                if let Some(node) = self.get_selected_tree_node() {
1240                    match node {
1241                        SelectedTreeNode::Group(id) => {
1242                            self.tree_state.toggle_expand(&id);
1243                        }
1244                        SelectedTreeNode::Component(id) => {
1245                            self.selected_component = Some(id);
1246                            self.focus_panel = FocusPanel::Right;
1247                            self.component_tab = ComponentDetailTab::Overview;
1248                        }
1249                    }
1250                }
1251            }
1252            ViewTab::Vulnerabilities => {
1253                // In grouped mode, check if we're on a group header
1254                if self.vuln_state.group_by != VulnGroupBy::Flat
1255                    && let Some(item) = self
1256                        .vuln_state
1257                        .cached_display_items
1258                        .get(self.vuln_state.selected)
1259                {
1260                    match item {
1261                        super::views::VulnDisplayItem::GroupHeader { label, .. } => {
1262                            let label = label.clone();
1263                            self.vuln_state.toggle_vuln_group(&label);
1264                            return;
1265                        }
1266                        super::views::VulnDisplayItem::SubGroupHeader {
1267                            parent_label,
1268                            label,
1269                            ..
1270                        } => {
1271                            let key = format!("{parent_label}::{label}");
1272                            self.vuln_state.toggle_vuln_group(&key);
1273                            return;
1274                        }
1275                        super::views::VulnDisplayItem::Vuln { .. } => {
1276                            // Fall through to normal navigation
1277                        }
1278                    }
1279                }
1280                // Navigate to component in Tree tab with proper targeting
1281                if let Some(cache) = &self.vuln_state.cached_data.clone()
1282                    && let Some((comp_id, vuln_id)) = self.vuln_state.get_nav_component_id(cache)
1283                {
1284                    // Push breadcrumb so Backspace returns here
1285                    self.navigation_ctx.push_breadcrumb(
1286                        ViewTab::Vulnerabilities,
1287                        vuln_id.clone(),
1288                        self.vuln_state.selected,
1289                    );
1290                    self.selected_component = Some(comp_id.clone());
1291                    self.component_tab = ComponentDetailTab::Overview;
1292                    self.active_tab = ViewTab::Tree;
1293                    self.focus_panel = FocusPanel::Right;
1294                    self.jump_to_component_in_tree(&comp_id);
1295                    self.set_status_message(format!("→ {vuln_id} (Backspace to return)"));
1296                }
1297            }
1298            ViewTab::Licenses => {
1299                // Navigate to the first component with this license in the Tree tab
1300                let license_data = super::views::build_license_data_from_app(self);
1301                let selected_idx = self
1302                    .license_state
1303                    .selected
1304                    .min(license_data.len().saturating_sub(1));
1305                if let Some((license, _, _)) = license_data.get(selected_idx)
1306                    && let Some(comp_id) =
1307                        super::views::get_first_component_id_for_license(self, license)
1308                {
1309                    self.navigation_ctx.push_breadcrumb(
1310                        ViewTab::Licenses,
1311                        license.clone(),
1312                        self.license_state.selected,
1313                    );
1314                    self.selected_component = Some(comp_id.clone());
1315                    self.component_tab = ComponentDetailTab::Overview;
1316                    self.active_tab = ViewTab::Tree;
1317                    self.focus_panel = FocusPanel::Right;
1318                    self.jump_to_component_in_tree(&comp_id);
1319                    self.set_status_message(format!("→ {license} (Backspace to return)"));
1320                }
1321            }
1322            ViewTab::Dependencies => {
1323                if let Some(node_id) = self.get_selected_dependency_node_id() {
1324                    // If node has children, toggle expand; if leaf, navigate to Tree tab
1325                    let is_leaf = self
1326                        .dependency_state
1327                        .cached_flat_nodes
1328                        .get(self.dependency_state.selected)
1329                        .is_some_and(|n| !n.has_children);
1330                    if is_leaf {
1331                        // Cross-tab navigation: jump to component in Tree view
1332                        let display_name = self
1333                            .dependency_state
1334                            .cached_flat_nodes
1335                            .get(self.dependency_state.selected)
1336                            .map(|n| n.name.clone())
1337                            .unwrap_or_default();
1338                        self.navigation_ctx.push_breadcrumb(
1339                            ViewTab::Dependencies,
1340                            display_name.clone(),
1341                            self.dependency_state.selected,
1342                        );
1343                        self.selected_component = Some(node_id.clone());
1344                        self.component_tab = ComponentDetailTab::Overview;
1345                        self.active_tab = ViewTab::Tree;
1346                        self.focus_panel = FocusPanel::Right;
1347                        self.jump_to_component_in_tree(&node_id);
1348                        self.set_status_message(format!("→ {display_name} (Backspace to return)"));
1349                    } else {
1350                        self.dependency_state.toggle_expand(&node_id);
1351                    }
1352                }
1353            }
1354            ViewTab::Compliance => {
1355                // Toggle violation detail overlay
1356                self.ensure_compliance_results();
1357                let idx = self.compliance_state.selected_standard;
1358                let has_violations = self
1359                    .compliance_results
1360                    .as_ref()
1361                    .and_then(|r| r.get(idx))
1362                    .is_some_and(|r| !r.violations.is_empty());
1363                if has_violations {
1364                    self.compliance_state.show_detail = !self.compliance_state.show_detail;
1365                }
1366            }
1367            ViewTab::Source => {
1368                // Toggle expand/collapse in tree mode
1369                if self.source_state.view_mode == crate::tui::app_states::SourceViewMode::Tree
1370                    && let Some(ref tree) = self.source_state.json_tree
1371                {
1372                    let mut items = Vec::new();
1373                    crate::tui::shared::source::flatten_json_tree(
1374                        tree,
1375                        "",
1376                        0,
1377                        &self.source_state.expanded,
1378                        &mut items,
1379                        true,
1380                        &[],
1381                        self.source_state.sort_mode,
1382                        "",
1383                    );
1384                    if let Some(item) = items.get(self.source_state.selected)
1385                        && item.is_expandable
1386                    {
1387                        let node_id = item.node_id.clone();
1388                        self.source_state.toggle_expand(&node_id);
1389                    }
1390                }
1391            }
1392            ViewTab::Quality => {
1393                if self.quality_state.view_mode == QualityViewMode::Summary {
1394                    // Jump to Recommendations view preserving selection
1395                    self.quality_state.view_mode = QualityViewMode::Recommendations;
1396                }
1397            }
1398            ViewTab::Overview
1399            | ViewTab::Crypto
1400            | ViewTab::Algorithms
1401            | ViewTab::Certificates
1402            | ViewTab::Keys
1403            | ViewTab::Protocols
1404            | ViewTab::PqcCompliance
1405            | ViewTab::Models
1406            | ViewTab::Datasets
1407            | ViewTab::AiReadiness => {}
1408        }
1409    }
1410
1411    /// Jump the source panel to the section selected in the map.
1412    pub fn handle_source_map_enter(&mut self) {
1413        // Build sections from JSON tree root children
1414        let Some(tree) = &self.source_state.json_tree else {
1415            return;
1416        };
1417        let Some(children) = tree.children() else {
1418            return;
1419        };
1420
1421        // Find the Nth expandable section
1422        let expandable: Vec<_> = children.iter().filter(|c| c.is_expandable()).collect();
1423
1424        let target = match expandable.get(self.source_state.map_selected) {
1425            Some(t) => *t,
1426            None => return,
1427        };
1428
1429        let target_id = target.node_id("root");
1430
1431        match self.source_state.view_mode {
1432            crate::tui::app_states::SourceViewMode::Tree => {
1433                // Ensure section is expanded
1434                if !self.source_state.expanded.contains(&target_id) {
1435                    self.source_state.expanded.insert(target_id.clone());
1436                }
1437                // Flatten and find the target node's index
1438                let mut items = Vec::new();
1439                crate::tui::shared::source::flatten_json_tree(
1440                    tree,
1441                    "",
1442                    0,
1443                    &self.source_state.expanded,
1444                    &mut items,
1445                    true,
1446                    &[],
1447                    self.source_state.sort_mode,
1448                    "",
1449                );
1450                if let Some(idx) = items.iter().position(|item| item.node_id == target_id) {
1451                    self.source_state.selected = idx;
1452                    self.source_state.scroll_offset = idx.saturating_sub(2);
1453                }
1454            }
1455            crate::tui::app_states::SourceViewMode::Raw => {
1456                // Find the line that starts this section
1457                let key = match target {
1458                    crate::tui::app_states::source::JsonTreeNode::Object { key, .. }
1459                    | crate::tui::app_states::source::JsonTreeNode::Array { key, .. }
1460                    | crate::tui::app_states::source::JsonTreeNode::Leaf { key, .. } => key.clone(),
1461                };
1462                // Search raw_lines for the top-level key
1463                for (i, line) in self.source_state.raw_lines.iter().enumerate() {
1464                    let search = format!("\"{key}\":");
1465                    if line.contains(&search) && line.starts_with("  ") && !line.starts_with("    ")
1466                    {
1467                        self.source_state.selected = i;
1468                        self.source_state.scroll_offset = i.saturating_sub(2);
1469                        break;
1470                    }
1471                }
1472            }
1473        }
1474
1475        // Switch focus back to source panel after jumping
1476        self.focus_panel = FocusPanel::Left;
1477    }
1478
1479    /// Get the component ID currently shown in the source map context footer.
1480    /// Returns the canonical ID value string if inside the "components" section.
1481    #[must_use]
1482    pub fn get_map_context_component_id(&self) -> Option<String> {
1483        let tree = self.source_state.json_tree.as_ref()?;
1484        let mut items = Vec::new();
1485        crate::tui::shared::source::flatten_json_tree(
1486            tree,
1487            "",
1488            0,
1489            &self.source_state.expanded,
1490            &mut items,
1491            true,
1492            &[],
1493            self.source_state.sort_mode,
1494            "",
1495        );
1496        let item = items.get(self.source_state.selected)?;
1497        let parts: Vec<&str> = item.node_id.split('.').collect();
1498        if parts.len() < 3 || parts[1] != "components" {
1499            return None;
1500        }
1501        let idx_part = parts[2];
1502        if idx_part.starts_with('[') && idx_part.ends_with(']') {
1503            let idx: usize = idx_part[1..idx_part.len() - 1].parse().ok()?;
1504            let (canon_id, _) = self.sbom.components.iter().nth(idx)?;
1505            Some(canon_id.value().to_string())
1506        } else {
1507            None
1508        }
1509    }
1510
1511    /// Get the currently selected dependency node ID (if any).
1512    #[must_use]
1513    pub fn get_selected_dependency_node_id(&self) -> Option<String> {
1514        // Use cached flat nodes if available (much faster than rebuilding tree)
1515        if !self.dependency_state.cached_flat_nodes.is_empty() {
1516            return self
1517                .dependency_state
1518                .cached_flat_nodes
1519                .get(self.dependency_state.selected)
1520                .map(|n| n.id.clone());
1521        }
1522        // Fallback: build the flattened list (only before first render)
1523        let mut visible_nodes = Vec::new();
1524        self.collect_visible_dependency_nodes(&mut visible_nodes);
1525        visible_nodes.get(self.dependency_state.selected).cloned()
1526    }
1527
1528    /// Collect visible dependency nodes in tree order.
1529    fn collect_visible_dependency_nodes(&self, nodes: &mut Vec<String>) {
1530        // Build edges map from sbom.edges
1531        let mut edges: std::collections::HashMap<String, Vec<String>> =
1532            std::collections::HashMap::new();
1533        let mut has_parent: std::collections::HashSet<String> = std::collections::HashSet::new();
1534        let mut all_nodes: std::collections::HashSet<String> = std::collections::HashSet::new();
1535
1536        for (id, _) in &self.sbom.components {
1537            all_nodes.insert(id.value().to_string());
1538        }
1539
1540        for edge in &self.sbom.edges {
1541            let from = edge.from.value().to_string();
1542            let to = edge.to.value().to_string();
1543            if all_nodes.contains(&from) && all_nodes.contains(&to) {
1544                edges.entry(from).or_default().push(to.clone());
1545                has_parent.insert(to);
1546            }
1547        }
1548
1549        // Find roots, sorted for stable ordering matching render traversal
1550        let mut roots: Vec<_> = all_nodes
1551            .iter()
1552            .filter(|id| !has_parent.contains(*id))
1553            .cloned()
1554            .collect();
1555        roots.sort();
1556
1557        // Traverse and collect visible nodes
1558        for root in roots {
1559            self.collect_dep_nodes_recursive(
1560                &root,
1561                &edges,
1562                nodes,
1563                &mut std::collections::HashSet::new(),
1564            );
1565        }
1566    }
1567
1568    fn collect_dep_nodes_recursive(
1569        &self,
1570        node_id: &str,
1571        edges: &std::collections::HashMap<String, Vec<String>>,
1572        nodes: &mut Vec<String>,
1573        visited: &mut std::collections::HashSet<String>,
1574    ) {
1575        if visited.contains(node_id) {
1576            return;
1577        }
1578        visited.insert(node_id.to_string());
1579        nodes.push(node_id.to_string());
1580
1581        if self.dependency_state.is_expanded(node_id)
1582            && let Some(children) = edges.get(node_id)
1583        {
1584            for child in children {
1585                self.collect_dep_nodes_recursive(child, edges, nodes, visited);
1586            }
1587        }
1588    }
1589
1590    /// Get the currently selected tree node.
1591    pub(crate) fn get_selected_tree_node(&self) -> Option<SelectedTreeNode> {
1592        let nodes = self.build_tree_nodes();
1593        let mut flat_items = Vec::new();
1594        flatten_tree_for_selection(nodes, &self.tree_state, &mut flat_items);
1595
1596        flat_items.get(self.tree_state.selected).cloned()
1597    }
1598
1599    /// Ensure tree node cache is valid, rebuilding if needed.
1600    pub fn ensure_tree_cache(&mut self) {
1601        let current_key = TreeCacheKey {
1602            group_by: self.tree_group_by,
1603            filter: self.tree_filter,
1604            search_query: self.tree_search_query.clone(),
1605        };
1606        if self.tree_cache_key.as_ref() != Some(&current_key) {
1607            self.cached_tree_nodes = match self.tree_group_by {
1608                TreeGroupBy::Ecosystem => self.build_ecosystem_tree(),
1609                TreeGroupBy::License => self.build_license_tree(),
1610                TreeGroupBy::VulnStatus => self.build_vuln_status_tree(),
1611                TreeGroupBy::ComponentType => self.build_type_tree(),
1612                TreeGroupBy::Flat => self.build_flat_tree(),
1613            };
1614            self.tree_cache_key = Some(current_key);
1615        }
1616    }
1617
1618    /// Get tree nodes from cache (returns empty slice if cache not yet built).
1619    /// For render paths, call `ensure_tree_cache()` first.
1620    /// For event handlers that only need to read, the cache is always warm after first render.
1621    pub fn build_tree_nodes(&self) -> &[crate::tui::widgets::TreeNode] {
1622        &self.cached_tree_nodes
1623    }
1624
1625    fn build_ecosystem_tree(&self) -> Vec<crate::tui::widgets::TreeNode> {
1626        use crate::tui::widgets::TreeNode;
1627
1628        let mut ecosystem_map: HashMap<String, Vec<&Component>> = HashMap::new();
1629
1630        for comp in self.sbom.components.values() {
1631            if !self.matches_filter(comp) {
1632                continue;
1633            }
1634            let eco = comp
1635                .ecosystem
1636                .as_ref()
1637                .map_or_else(|| "Unknown".to_string(), std::string::ToString::to_string);
1638            ecosystem_map.entry(eco).or_default().push(comp);
1639        }
1640
1641        let mut groups: Vec<TreeNode> = ecosystem_map
1642            .into_iter()
1643            .map(|(eco, mut components)| {
1644                let vuln_count: usize = components.iter().map(|c| c.vulnerabilities.len()).sum();
1645                components.sort_by(|a, b| a.name.cmp(&b.name));
1646                let children: Vec<TreeNode> = components
1647                    .into_iter()
1648                    .map(|c| TreeNode::Component {
1649                        id: c.canonical_id.value().to_string(),
1650                        name: c.name.clone(),
1651                        version: c.version.clone(),
1652                        vuln_count: c.vulnerabilities.len(),
1653                        max_severity: get_max_severity(c),
1654                        component_type: Some(
1655                            crate::tui::widgets::detect_component_type(&c.name).to_string(),
1656                        ),
1657                        ecosystem: c.ecosystem.as_ref().map(std::string::ToString::to_string),
1658                        is_bookmarked: self.bookmarked.contains(c.canonical_id.value()),
1659                    })
1660                    .collect();
1661                let count = children.len();
1662                TreeNode::Group {
1663                    id: format!("eco:{eco}"),
1664                    label: eco,
1665                    children,
1666                    item_count: count,
1667                    vuln_count,
1668                }
1669            })
1670            .collect();
1671
1672        groups.sort_by(|a, b| match (a, b) {
1673            (
1674                TreeNode::Group {
1675                    item_count: ac,
1676                    label: al,
1677                    ..
1678                },
1679                TreeNode::Group {
1680                    item_count: bc,
1681                    label: bl,
1682                    ..
1683                },
1684            ) => bc.cmp(ac).then_with(|| al.cmp(bl)),
1685            _ => std::cmp::Ordering::Equal,
1686        });
1687
1688        groups
1689    }
1690
1691    fn build_license_tree(&self) -> Vec<crate::tui::widgets::TreeNode> {
1692        use crate::tui::widgets::TreeNode;
1693
1694        let mut license_map: HashMap<String, Vec<&Component>> = HashMap::new();
1695
1696        for comp in self.sbom.components.values() {
1697            if !self.matches_filter(comp) {
1698                continue;
1699            }
1700            let license = if comp.licenses.declared.is_empty() {
1701                "Unknown".to_string()
1702            } else {
1703                comp.licenses.declared[0].expression.clone()
1704            };
1705            license_map.entry(license).or_default().push(comp);
1706        }
1707
1708        let mut groups: Vec<TreeNode> = license_map
1709            .into_iter()
1710            .map(|(license, mut components)| {
1711                let vuln_count: usize = components.iter().map(|c| c.vulnerabilities.len()).sum();
1712                components.sort_by(|a, b| a.name.cmp(&b.name));
1713                let children: Vec<TreeNode> = components
1714                    .into_iter()
1715                    .map(|c| TreeNode::Component {
1716                        id: c.canonical_id.value().to_string(),
1717                        name: c.name.clone(),
1718                        version: c.version.clone(),
1719                        vuln_count: c.vulnerabilities.len(),
1720                        max_severity: get_max_severity(c),
1721                        component_type: Some(
1722                            crate::tui::widgets::detect_component_type(&c.name).to_string(),
1723                        ),
1724                        ecosystem: c.ecosystem.as_ref().map(std::string::ToString::to_string),
1725                        is_bookmarked: self.bookmarked.contains(c.canonical_id.value()),
1726                    })
1727                    .collect();
1728                let count = children.len();
1729                TreeNode::Group {
1730                    id: format!("lic:{license}"),
1731                    label: license,
1732                    children,
1733                    item_count: count,
1734                    vuln_count,
1735                }
1736            })
1737            .collect();
1738
1739        groups.sort_by(|a, b| match (a, b) {
1740            (
1741                TreeNode::Group {
1742                    item_count: ac,
1743                    label: al,
1744                    ..
1745                },
1746                TreeNode::Group {
1747                    item_count: bc,
1748                    label: bl,
1749                    ..
1750                },
1751            ) => bc.cmp(ac).then_with(|| al.cmp(bl)),
1752            _ => std::cmp::Ordering::Equal,
1753        });
1754
1755        groups
1756    }
1757
1758    fn build_vuln_status_tree(&self) -> Vec<crate::tui::widgets::TreeNode> {
1759        use super::severity::severity_category;
1760        use crate::tui::widgets::TreeNode;
1761
1762        let mut critical_comps = Vec::new();
1763        let mut high_comps = Vec::new();
1764        let mut other_vuln_comps = Vec::new();
1765        let mut clean_comps = Vec::new();
1766
1767        for comp in self.sbom.components.values() {
1768            if !self.matches_filter(comp) {
1769                continue;
1770            }
1771
1772            match severity_category(&comp.vulnerabilities) {
1773                "critical" => critical_comps.push(comp),
1774                "high" => high_comps.push(comp),
1775                "clean" => clean_comps.push(comp),
1776                _ => other_vuln_comps.push(comp),
1777            }
1778        }
1779
1780        let build_group = |label: &str,
1781                           id: &str,
1782                           comps: Vec<&Component>,
1783                           bookmarked: &HashSet<String>|
1784         -> TreeNode {
1785            let vuln_count: usize = comps.iter().map(|c| c.vulnerabilities.len()).sum();
1786            let children: Vec<TreeNode> = comps
1787                .into_iter()
1788                .map(|c| TreeNode::Component {
1789                    id: c.canonical_id.value().to_string(),
1790                    name: c.name.clone(),
1791                    version: c.version.clone(),
1792                    vuln_count: c.vulnerabilities.len(),
1793                    max_severity: get_max_severity(c),
1794                    component_type: Some(
1795                        crate::tui::widgets::detect_component_type(&c.name).to_string(),
1796                    ),
1797                    ecosystem: c.ecosystem.as_ref().map(std::string::ToString::to_string),
1798                    is_bookmarked: bookmarked.contains(c.canonical_id.value()),
1799                })
1800                .collect();
1801            let count = children.len();
1802            TreeNode::Group {
1803                id: id.to_string(),
1804                label: label.to_string(),
1805                children,
1806                item_count: count,
1807                vuln_count,
1808            }
1809        };
1810
1811        let mut groups = Vec::new();
1812        if !critical_comps.is_empty() {
1813            groups.push(build_group(
1814                "Critical",
1815                "vuln:critical",
1816                critical_comps,
1817                &self.bookmarked,
1818            ));
1819        }
1820        if !high_comps.is_empty() {
1821            groups.push(build_group(
1822                "High",
1823                "vuln:high",
1824                high_comps,
1825                &self.bookmarked,
1826            ));
1827        }
1828        if !other_vuln_comps.is_empty() {
1829            groups.push(build_group(
1830                "Other Vulnerabilities",
1831                "vuln:other",
1832                other_vuln_comps,
1833                &self.bookmarked,
1834            ));
1835        }
1836        if !clean_comps.is_empty() {
1837            groups.push(build_group(
1838                "No Vulnerabilities",
1839                "vuln:clean",
1840                clean_comps,
1841                &self.bookmarked,
1842            ));
1843        }
1844
1845        groups
1846    }
1847
1848    fn build_type_tree(&self) -> Vec<crate::tui::widgets::TreeNode> {
1849        use crate::tui::widgets::TreeNode;
1850
1851        let mut type_map: HashMap<&'static str, Vec<&Component>> = HashMap::new();
1852
1853        for comp in self.sbom.components.values() {
1854            if !self.matches_filter(comp) {
1855                continue;
1856            }
1857            let comp_type = crate::tui::widgets::detect_component_type(&comp.name);
1858            type_map.entry(comp_type).or_default().push(comp);
1859        }
1860
1861        // Define type order and labels
1862        let type_order = vec![
1863            ("lib", "Libraries"),
1864            ("bin", "Binaries"),
1865            ("cert", "Certificates"),
1866            ("fs", "Filesystems"),
1867            ("file", "Other Files"),
1868        ];
1869
1870        let mut groups = Vec::new();
1871        for (type_key, type_label) in type_order {
1872            if let Some(mut components) = type_map.remove(type_key) {
1873                if components.is_empty() {
1874                    continue;
1875                }
1876                let vuln_count: usize = components.iter().map(|c| c.vulnerabilities.len()).sum();
1877                components.sort_by(|a, b| a.name.cmp(&b.name));
1878                let children: Vec<TreeNode> = components
1879                    .into_iter()
1880                    .map(|c| TreeNode::Component {
1881                        id: c.canonical_id.value().to_string(),
1882                        name: c.name.clone(),
1883                        version: c.version.clone(),
1884                        vuln_count: c.vulnerabilities.len(),
1885                        max_severity: get_max_severity(c),
1886                        component_type: Some(type_key.to_string()),
1887                        ecosystem: c.ecosystem.as_ref().map(std::string::ToString::to_string),
1888                        is_bookmarked: self.bookmarked.contains(c.canonical_id.value()),
1889                    })
1890                    .collect();
1891                let count = children.len();
1892                groups.push(TreeNode::Group {
1893                    id: format!("type:{type_key}"),
1894                    label: type_label.to_string(),
1895                    children,
1896                    item_count: count,
1897                    vuln_count,
1898                });
1899            }
1900        }
1901
1902        groups
1903    }
1904
1905    fn build_flat_tree(&self) -> Vec<crate::tui::widgets::TreeNode> {
1906        use crate::tui::widgets::TreeNode;
1907
1908        self.sbom
1909            .components
1910            .values()
1911            .filter(|c| self.matches_filter(c))
1912            .map(|c| TreeNode::Component {
1913                id: c.canonical_id.value().to_string(),
1914                name: c.name.clone(),
1915                version: c.version.clone(),
1916                vuln_count: c.vulnerabilities.len(),
1917                max_severity: get_max_severity(c),
1918                component_type: Some(
1919                    crate::tui::widgets::detect_component_type(&c.name).to_string(),
1920                ),
1921                ecosystem: c.ecosystem.as_ref().map(std::string::ToString::to_string),
1922                is_bookmarked: self.bookmarked.contains(c.canonical_id.value()),
1923            })
1924            .collect()
1925    }
1926
1927    fn matches_filter(&self, comp: &Component) -> bool {
1928        use super::severity::severity_matches;
1929
1930        // Check tree filter first
1931        let passes_filter = match self.tree_filter {
1932            TreeFilter::All => true,
1933            TreeFilter::HasVulnerabilities => !comp.vulnerabilities.is_empty(),
1934            TreeFilter::Critical => comp
1935                .vulnerabilities
1936                .iter()
1937                .any(|v| severity_matches(v.severity.as_ref(), "critical")),
1938            TreeFilter::Bookmarked => self.bookmarked.contains(comp.canonical_id.value()),
1939        };
1940
1941        if !passes_filter {
1942            return false;
1943        }
1944
1945        // Check search query
1946        if self.tree_search_query.is_empty() {
1947            return true;
1948        }
1949
1950        let query_lower = self.tree_search_query.to_lowercase();
1951        let name_lower = comp.name.to_lowercase();
1952
1953        // Match against name
1954        if name_lower.contains(&query_lower) {
1955            return true;
1956        }
1957
1958        // Match against version
1959        if let Some(ref version) = comp.version
1960            && version.to_lowercase().contains(&query_lower)
1961        {
1962            return true;
1963        }
1964
1965        // Match against ecosystem
1966        if let Some(ref eco) = comp.ecosystem
1967            && eco.to_string().to_lowercase().contains(&query_lower)
1968        {
1969            return true;
1970        }
1971
1972        false
1973    }
1974
1975    fn tree_group_id_for_component(&self, comp: &Component) -> Option<String> {
1976        match self.tree_group_by {
1977            TreeGroupBy::Ecosystem => {
1978                let eco = comp
1979                    .ecosystem
1980                    .as_ref()
1981                    .map_or_else(|| "Unknown".to_string(), std::string::ToString::to_string);
1982                Some(format!("eco:{eco}"))
1983            }
1984            TreeGroupBy::License => {
1985                let license = if comp.licenses.declared.is_empty() {
1986                    "Unknown".to_string()
1987                } else {
1988                    comp.licenses.declared[0].expression.clone()
1989                };
1990                Some(format!("lic:{license}"))
1991            }
1992            TreeGroupBy::VulnStatus => {
1993                use super::severity::severity_category;
1994                let group = match severity_category(&comp.vulnerabilities) {
1995                    "critical" => "vuln:critical",
1996                    "high" => "vuln:high",
1997                    "clean" => "vuln:clean",
1998                    _ => "vuln:other",
1999                };
2000                Some(group.to_string())
2001            }
2002            TreeGroupBy::ComponentType => {
2003                let comp_type = crate::tui::widgets::detect_component_type(&comp.name);
2004                Some(format!("type:{comp_type}"))
2005            }
2006            TreeGroupBy::Flat => None,
2007        }
2008    }
2009}
2010
2011/// Get the maximum severity level from a component's vulnerabilities
2012fn get_max_severity(comp: &Component) -> Option<String> {
2013    super::severity::max_severity_from_vulns(&comp.vulnerabilities)
2014}
2015
2016/// Selected tree node for navigation.
2017#[derive(Debug, Clone)]
2018pub(crate) enum SelectedTreeNode {
2019    Group(String),
2020    Component(String),
2021}
2022
2023fn flatten_tree_for_selection(
2024    nodes: &[crate::tui::widgets::TreeNode],
2025    state: &TreeState,
2026    items: &mut Vec<SelectedTreeNode>,
2027) {
2028    use crate::tui::widgets::TreeNode;
2029
2030    for node in nodes {
2031        match node {
2032            TreeNode::Group { id, children, .. } => {
2033                items.push(SelectedTreeNode::Group(id.clone()));
2034                if state.is_expanded(id) {
2035                    flatten_tree_for_selection(children, state, items);
2036                }
2037            }
2038            TreeNode::Component { id, .. } => {
2039                items.push(SelectedTreeNode::Component(id.clone()));
2040            }
2041        }
2042    }
2043}
2044
2045/// View tabs for the single SBOM viewer.
2046#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2047pub enum ViewTab {
2048    // ── Shared across profiles ──
2049    /// Overview: SBOM stats or CBOM quantum dashboard (adapts per profile)
2050    Overview,
2051    /// Quality score view (metrics adapt per profile)
2052    Quality,
2053    /// Original SBOM source viewer
2054    Source,
2055
2056    // ── SBOM-specific ──
2057    /// Hierarchical component tree
2058    Tree,
2059    /// Vulnerability explorer
2060    Vulnerabilities,
2061    /// License analysis view
2062    Licenses,
2063    /// Dependency graph view
2064    Dependencies,
2065    /// Compliance validation view (NTIA/CRA/FDA/SSDF/EO14028)
2066    Compliance,
2067
2068    // ── CBOM-specific ──
2069    /// Algorithm inventory with quantum readiness indicators
2070    Algorithms,
2071    /// Certificate validity tracking and expiry timeline
2072    Certificates,
2073    /// Key material state monitoring
2074    Keys,
2075    /// Protocol and cipher suite analysis
2076    Protocols,
2077    /// PQC compliance (CNSA 2.0 + NIST PQC dedicated view)
2078    PqcCompliance,
2079
2080    // ── AI-BOM-specific ──
2081    /// Machine-learning model inventory with model-card metadata
2082    Models,
2083    /// Training/evaluation dataset inventory with governance metadata
2084    Datasets,
2085    /// AI-readiness scoring dashboard (model-card completeness)
2086    AiReadiness,
2087
2088    // ── Legacy ──
2089    /// Single crypto tab (kept for preference migration)
2090    Crypto,
2091}
2092
2093impl ViewTab {
2094    /// Tab display title.
2095    #[must_use]
2096    pub const fn title(&self) -> &'static str {
2097        match self {
2098            Self::Overview => "Overview",
2099            Self::Quality => "Quality",
2100            Self::Source => "Source",
2101            // Full titles everywhere — the tab bar windows with «/» markers,
2102            // so overflow is handled without lossy abbreviations.
2103            Self::Tree => "Components",
2104            Self::Vulnerabilities => "Vulnerabilities",
2105            Self::Licenses => "Licenses",
2106            Self::Dependencies => "Dependencies",
2107            Self::Compliance => "Compliance",
2108            Self::Algorithms => "Algorithms",
2109            Self::Certificates => "Certificates",
2110            Self::Keys => "Keys",
2111            Self::Protocols => "Protocols",
2112            Self::PqcCompliance => "PQC Compliance",
2113            Self::Models => "Models",
2114            Self::Datasets => "Datasets",
2115            Self::AiReadiness => "AI-Readiness",
2116            Self::Crypto => "Crypto",
2117        }
2118    }
2119
2120    /// Positional shortcut key based on tab position in the profile's tab set.
2121    #[must_use]
2122    pub fn shortcut_for_profile(&self, profile: crate::model::BomProfile) -> Option<usize> {
2123        Self::tabs_for_profile(profile)
2124            .iter()
2125            .position(|t| t == self)
2126            .map(|i| i + 1)
2127    }
2128
2129    /// Stable string identifier for persistence.
2130    #[must_use]
2131    pub const fn as_str(&self) -> &'static str {
2132        match self {
2133            Self::Overview => "overview",
2134            Self::Quality => "quality",
2135            Self::Source => "source",
2136            Self::Tree => "tree",
2137            Self::Vulnerabilities => "vulnerabilities",
2138            Self::Licenses => "licenses",
2139            Self::Dependencies => "dependencies",
2140            Self::Compliance => "compliance",
2141            Self::Algorithms => "algorithms",
2142            Self::Certificates => "certificates",
2143            Self::Keys => "keys",
2144            Self::Protocols => "protocols",
2145            Self::PqcCompliance => "pqc-compliance",
2146            Self::Models => "models",
2147            Self::Datasets => "datasets",
2148            Self::AiReadiness => "ai-readiness",
2149            Self::Crypto => "crypto",
2150        }
2151    }
2152
2153    /// Get the tab set for a given BOM profile.
2154    ///
2155    /// Each profile defines its own ordered set of tabs.
2156    /// Number keys 1-8 map positionally to this slice.
2157    #[must_use]
2158    pub const fn tabs_for_profile(profile: crate::model::BomProfile) -> &'static [ViewTab] {
2159        match profile {
2160            // Ordinals for the tabs shared with the Diff app (Dependencies=3,
2161            // Licenses=4, Vulnerabilities=5) deliberately match the Diff
2162            // app's tab bar so digit-key muscle memory transfers between the
2163            // two apps.
2164            crate::model::BomProfile::Sbom => &[
2165                Self::Overview,
2166                Self::Tree,
2167                Self::Dependencies,
2168                Self::Licenses,
2169                Self::Vulnerabilities,
2170                Self::Quality,
2171                Self::Compliance,
2172                Self::Source,
2173            ],
2174            crate::model::BomProfile::Cbom => &[
2175                Self::Overview,
2176                Self::Algorithms,
2177                Self::Certificates,
2178                Self::Keys,
2179                Self::Protocols,
2180                Self::Quality,
2181                Self::PqcCompliance,
2182                Self::Source,
2183            ],
2184            crate::model::BomProfile::AiBom => &[
2185                Self::Overview,
2186                Self::Models,
2187                Self::Datasets,
2188                Self::AiReadiness,
2189                Self::Compliance,
2190                Self::Source,
2191            ],
2192        }
2193    }
2194
2195    /// Parse from a persisted string identifier.
2196    #[must_use]
2197    pub fn from_str_opt(s: &str) -> Option<Self> {
2198        match s {
2199            "overview" => Some(Self::Overview),
2200            "quality" => Some(Self::Quality),
2201            "source" => Some(Self::Source),
2202            "tree" => Some(Self::Tree),
2203            "vulnerabilities" => Some(Self::Vulnerabilities),
2204            "licenses" => Some(Self::Licenses),
2205            "dependencies" => Some(Self::Dependencies),
2206            "compliance" => Some(Self::Compliance),
2207            "algorithms" => Some(Self::Algorithms),
2208            "certificates" => Some(Self::Certificates),
2209            "keys" => Some(Self::Keys),
2210            "protocols" => Some(Self::Protocols),
2211            "pqc-compliance" => Some(Self::PqcCompliance),
2212            "models" => Some(Self::Models),
2213            "datasets" => Some(Self::Datasets),
2214            "ai-readiness" => Some(Self::AiReadiness),
2215            "crypto" => Some(Self::Crypto),
2216            _ => None,
2217        }
2218    }
2219}
2220
2221/// Tree grouping modes.
2222#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2223pub enum TreeGroupBy {
2224    Ecosystem,
2225    License,
2226    VulnStatus,
2227    ComponentType,
2228    Flat,
2229}
2230
2231impl TreeGroupBy {
2232    #[must_use]
2233    pub const fn label(&self) -> &'static str {
2234        match self {
2235            Self::Ecosystem => "Ecosystem",
2236            Self::License => "License",
2237            Self::VulnStatus => "Vuln Status",
2238            Self::ComponentType => "Type",
2239            Self::Flat => "Flat List",
2240        }
2241    }
2242}
2243
2244/// Tree filter options.
2245#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2246pub enum TreeFilter {
2247    All,
2248    HasVulnerabilities,
2249    Critical,
2250    Bookmarked,
2251}
2252
2253impl TreeFilter {
2254    #[must_use]
2255    pub const fn label(&self) -> &'static str {
2256        match self {
2257            Self::All => "All",
2258            Self::HasVulnerabilities => "Has Vulns",
2259            Self::Critical => "Critical",
2260            Self::Bookmarked => "Bookmarked",
2261        }
2262    }
2263}
2264
2265/// Component detail sub-tabs.
2266#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
2267pub(crate) enum ComponentDetailTab {
2268    #[default]
2269    Overview,
2270    Identifiers,
2271    Vulnerabilities,
2272    Dependencies,
2273}
2274
2275impl ComponentDetailTab {
2276    pub const fn title(self) -> &'static str {
2277        match self {
2278            Self::Overview => "Overview",
2279            Self::Identifiers => "Identifiers",
2280            Self::Vulnerabilities => "Vulnerabilities",
2281            Self::Dependencies => "Dependencies",
2282        }
2283    }
2284
2285    pub const fn shortcut(self) -> &'static str {
2286        match self {
2287            Self::Overview => "1",
2288            Self::Identifiers => "2",
2289            Self::Vulnerabilities => "3",
2290            Self::Dependencies => "4",
2291        }
2292    }
2293
2294    pub const fn all() -> [Self; 4] {
2295        [
2296            Self::Overview,
2297            Self::Identifiers,
2298            Self::Vulnerabilities,
2299            Self::Dependencies,
2300        ]
2301    }
2302}
2303
2304/// Focus panel (for split views).
2305#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2306pub(crate) enum FocusPanel {
2307    Left,
2308    Right,
2309}
2310
2311/// State for vulnerability explorer.
2312#[derive(Debug, Clone)]
2313pub(crate) struct VulnExplorerState {
2314    pub selected: usize,
2315    pub total: usize,
2316    pub scroll_offset: usize,
2317    pub group_by: VulnGroupBy,
2318    pub sort_by: VulnSortBy,
2319    pub filter_severity: Option<String>,
2320    /// When true, isolate KEV-flagged vulnerabilities (mirrors diff-mode `VulnFilter::Kev`)
2321    pub filter_kev: bool,
2322    /// When true, deduplicate vulnerabilities by CVE ID and show affected component count
2323    pub deduplicate: bool,
2324    /// Local search/filter query for vulnerability list
2325    pub search_query: String,
2326    /// Whether search input mode is active
2327    pub search_active: bool,
2328    /// Scroll offset for the detail panel (right side)
2329    pub detail_scroll: u16,
2330    /// Expanded group IDs for grouped view (severity labels or component names)
2331    pub expanded_groups: HashSet<String>,
2332    /// Cache key to detect when we need to rebuild the vulnerability list
2333    cache_key: Option<VulnCacheKey>,
2334    /// Cached vulnerability list for performance (Arc-wrapped for zero-cost cloning)
2335    pub cached_data: Option<super::views::VulnCacheRef>,
2336    /// Cached display items (group headers + vuln indices) — rebuilt only when
2337    /// cache or expanded_groups change, NOT every frame
2338    pub cached_display_items: Vec<super::views::VulnDisplayItem>,
2339    /// Snapshot of expanded_groups when display items were last built
2340    display_items_expanded_snapshot: HashSet<String>,
2341    /// Snapshot of group_by when display items were last built
2342    display_items_group_by: VulnGroupBy,
2343    /// Index into affected_component_ids for cycling with [n]/[p] after inspect
2344    pub inspect_component_idx: usize,
2345}
2346
2347/// Cache key for vulnerability list - rebuild when any of these change
2348/// Cache key for tree node list
2349#[derive(Debug, Clone, PartialEq, Eq)]
2350struct TreeCacheKey {
2351    group_by: TreeGroupBy,
2352    filter: TreeFilter,
2353    search_query: String,
2354}
2355
2356#[derive(Debug, Clone, PartialEq, Eq)]
2357struct VulnCacheKey {
2358    filter_severity: Option<String>,
2359    filter_kev: bool,
2360    deduplicate: bool,
2361    sort_by: VulnSortBy,
2362    search_query: String,
2363}
2364
2365impl VulnExplorerState {
2366    pub fn new() -> Self {
2367        Self {
2368            selected: 0,
2369            total: 0,
2370            scroll_offset: 0,
2371            group_by: VulnGroupBy::Component,
2372            sort_by: VulnSortBy::Severity,
2373            filter_severity: None,
2374            filter_kev: false,
2375            deduplicate: true,
2376            search_query: String::new(),
2377            search_active: false,
2378            detail_scroll: 0,
2379            expanded_groups: HashSet::new(),
2380            cache_key: None,
2381            cached_data: None,
2382            cached_display_items: Vec::new(),
2383            display_items_expanded_snapshot: HashSet::new(),
2384            display_items_group_by: VulnGroupBy::Component,
2385            inspect_component_idx: 0,
2386        }
2387    }
2388
2389    /// Get current cache key based on filter settings
2390    fn current_cache_key(&self) -> VulnCacheKey {
2391        VulnCacheKey {
2392            filter_severity: self.filter_severity.clone(),
2393            filter_kev: self.filter_kev,
2394            deduplicate: self.deduplicate,
2395            sort_by: self.sort_by,
2396            search_query: self.search_query.clone(),
2397        }
2398    }
2399
2400    /// Check if cache is valid (allocation-free comparison)
2401    pub fn is_cache_valid(&self) -> bool {
2402        if let Some(key) = &self.cache_key {
2403            self.cached_data.is_some()
2404                && key.filter_severity == self.filter_severity
2405                && key.filter_kev == self.filter_kev
2406                && key.deduplicate == self.deduplicate
2407                && key.sort_by == self.sort_by
2408                && key.search_query == self.search_query
2409        } else {
2410            false
2411        }
2412    }
2413
2414    /// Store cache with current settings (wraps in Arc for cheap cloning)
2415    pub fn set_cache(&mut self, cache: super::views::VulnCache) {
2416        self.cache_key = Some(self.current_cache_key());
2417        self.cached_data = Some(std::sync::Arc::new(cache));
2418    }
2419
2420    /// Invalidate the cache
2421    pub fn invalidate_cache(&mut self) {
2422        self.cache_key = None;
2423        self.cached_data = None;
2424        self.cached_display_items.clear();
2425    }
2426
2427    /// Check if display items need rebuilding (expanded_groups or group_by changed)
2428    pub fn are_display_items_valid(&self) -> bool {
2429        !self.cached_display_items.is_empty()
2430            && self.display_items_expanded_snapshot == self.expanded_groups
2431            && self.display_items_group_by == self.group_by
2432    }
2433
2434    /// Rebuild and cache display items
2435    pub fn rebuild_display_items(&mut self) {
2436        if let Some(cache) = &self.cached_data {
2437            self.cached_display_items = super::views::build_display_items(
2438                &cache.vulns,
2439                &self.group_by,
2440                &self.expanded_groups,
2441            );
2442            self.display_items_expanded_snapshot = self.expanded_groups.clone();
2443            self.display_items_group_by = self.group_by;
2444        }
2445    }
2446
2447    pub const fn select_next(&mut self) {
2448        if self.total > 0 && self.selected < self.total.saturating_sub(1) {
2449            self.selected += 1;
2450            self.detail_scroll = 0;
2451            self.inspect_component_idx = 0;
2452        }
2453    }
2454
2455    pub const fn select_prev(&mut self) {
2456        if self.selected > 0 {
2457            self.selected -= 1;
2458            self.detail_scroll = 0;
2459            self.inspect_component_idx = 0;
2460        }
2461    }
2462
2463    /// Scroll detail panel down
2464    pub const fn detail_scroll_down(&mut self) {
2465        self.detail_scroll = self.detail_scroll.saturating_add(1);
2466    }
2467
2468    /// Scroll detail panel up
2469    pub const fn detail_scroll_up(&mut self) {
2470        self.detail_scroll = self.detail_scroll.saturating_sub(1);
2471    }
2472
2473    /// Ensure selected index is within bounds
2474    pub const fn clamp_selection(&mut self) {
2475        if self.total == 0 {
2476            self.selected = 0;
2477        } else if self.selected >= self.total {
2478            self.selected = self.total.saturating_sub(1);
2479        }
2480    }
2481
2482    /// Get the selected VulnRow from the cached display items.
2483    /// Returns the vuln row and its index into `VulnCache.vulns`.
2484    pub fn get_selected_vuln_row<'a>(
2485        &self,
2486        cache: &'a super::views::VulnCache,
2487    ) -> Option<&'a super::views::VulnRow> {
2488        let item = self.cached_display_items.get(self.selected)?;
2489        match item {
2490            super::views::VulnDisplayItem::Vuln { idx, .. } => cache.vulns.get(*idx),
2491            _ => None,
2492        }
2493    }
2494
2495    /// Get the component ID to navigate to for the selected vuln.
2496    /// Uses `inspect_component_idx` to cycle through multi-affected components.
2497    pub fn get_nav_component_id(
2498        &self,
2499        cache: &super::views::VulnCache,
2500    ) -> Option<(String, String)> {
2501        let vuln = self.get_selected_vuln_row(cache)?;
2502        let idx = self
2503            .inspect_component_idx
2504            .min(vuln.affected_component_ids.len().saturating_sub(1));
2505        let comp_id = vuln.affected_component_ids.get(idx)?;
2506        Some((comp_id.clone(), vuln.vuln_id.clone()))
2507    }
2508
2509    pub fn toggle_group(&mut self) {
2510        self.group_by = match self.group_by {
2511            VulnGroupBy::Severity => VulnGroupBy::Component,
2512            VulnGroupBy::Component => VulnGroupBy::Flat,
2513            VulnGroupBy::Flat => VulnGroupBy::Severity,
2514        };
2515        self.selected = 0;
2516        self.expanded_groups.clear();
2517        self.invalidate_cache();
2518    }
2519
2520    /// Toggle expansion of a vulnerability group header.
2521    pub fn toggle_vuln_group(&mut self, group_id: &str) {
2522        if self.expanded_groups.contains(group_id) {
2523            self.expanded_groups.remove(group_id);
2524        } else {
2525            self.expanded_groups.insert(group_id.to_string());
2526        }
2527    }
2528
2529    /// Expand all groups.
2530    pub fn expand_all_groups(&mut self, labels: &[String]) {
2531        for label in labels {
2532            self.expanded_groups.insert(label.clone());
2533        }
2534    }
2535
2536    /// Collapse all groups.
2537    pub fn collapse_all_groups(&mut self) {
2538        self.expanded_groups.clear();
2539    }
2540
2541    /// Jump to next group header using cached display items.
2542    pub fn jump_next_group_cached(&mut self) {
2543        for (i, item) in self
2544            .cached_display_items
2545            .iter()
2546            .enumerate()
2547            .skip(self.selected + 1)
2548        {
2549            if matches!(item, super::views::VulnDisplayItem::GroupHeader { .. }) {
2550                self.selected = i;
2551                self.detail_scroll = 0;
2552                return;
2553            }
2554        }
2555        // Wrap to first group
2556        for (i, item) in self.cached_display_items.iter().enumerate() {
2557            if matches!(item, super::views::VulnDisplayItem::GroupHeader { .. }) {
2558                self.selected = i;
2559                self.detail_scroll = 0;
2560                return;
2561            }
2562        }
2563    }
2564
2565    /// Jump to previous group header using cached display items.
2566    pub fn jump_prev_group_cached(&mut self) {
2567        for (i, item) in self
2568            .cached_display_items
2569            .iter()
2570            .enumerate()
2571            .take(self.selected)
2572            .rev()
2573        {
2574            if matches!(item, super::views::VulnDisplayItem::GroupHeader { .. }) {
2575                self.selected = i;
2576                self.detail_scroll = 0;
2577                return;
2578            }
2579        }
2580        // Wrap to last group
2581        for (i, item) in self.cached_display_items.iter().enumerate().rev() {
2582            if matches!(item, super::views::VulnDisplayItem::GroupHeader { .. }) {
2583                self.selected = i;
2584                self.detail_scroll = 0;
2585                return;
2586            }
2587        }
2588    }
2589
2590    pub fn toggle_filter(&mut self) {
2591        self.filter_severity = match &self.filter_severity {
2592            None => Some("critical".to_string()),
2593            Some(s) if s == "critical" => Some("high".to_string()),
2594            Some(s) if s == "high" => Some("medium".to_string()),
2595            Some(s) if s == "medium" => Some("low".to_string()),
2596            Some(s) if s == "low" => Some("unknown".to_string()),
2597            Some(s) if s == "unknown" => None,
2598            _ => None,
2599        };
2600        self.selected = 0;
2601        self.invalidate_cache();
2602    }
2603
2604    /// Toggle the KEV-only filter (isolates KEV-flagged vulns), mirroring
2605    /// diff-mode `VulnFilter::Kev`.
2606    pub fn toggle_kev_filter(&mut self) {
2607        self.filter_kev = !self.filter_kev;
2608        // Only reset the cursor when narrowing to KEV; toggling OFF restores
2609        // the full list, where keeping the position is less disruptive
2610        // (clamp_selection still bounds it on the next render).
2611        if self.filter_kev {
2612            self.selected = 0;
2613        }
2614        self.invalidate_cache();
2615    }
2616
2617    /// Clear the severity filter entirely (the no-results state's
2618    /// "[Esc] to clear" recovery path).
2619    pub fn clear_severity_filter(&mut self) {
2620        self.filter_severity = None;
2621        self.selected = 0;
2622        self.invalidate_cache();
2623    }
2624
2625    pub fn toggle_sort(&mut self) {
2626        self.sort_by = self.sort_by.next();
2627        self.selected = 0;
2628        self.invalidate_cache();
2629    }
2630
2631    pub fn toggle_deduplicate(&mut self) {
2632        self.deduplicate = !self.deduplicate;
2633        self.selected = 0;
2634        self.invalidate_cache();
2635    }
2636
2637    /// Start local search mode for vulnerability list
2638    pub fn start_vuln_search(&mut self) {
2639        self.search_active = true;
2640        self.search_query.clear();
2641    }
2642
2643    /// Stop search mode (keep query for filtering)
2644    pub const fn stop_vuln_search(&mut self) {
2645        self.search_active = false;
2646    }
2647
2648    /// Clear search completely
2649    pub fn clear_vuln_search(&mut self) {
2650        self.search_active = false;
2651        self.search_query.clear();
2652        self.selected = 0;
2653        self.invalidate_cache();
2654    }
2655
2656    /// Push a character to search query
2657    pub fn search_push(&mut self, c: char) {
2658        self.search_query.push(c);
2659        self.selected = 0;
2660        self.invalidate_cache();
2661    }
2662
2663    /// Pop a character from search query
2664    pub fn search_pop(&mut self) {
2665        self.search_query.pop();
2666        self.selected = 0;
2667        self.invalidate_cache();
2668    }
2669
2670    // NOTE: the old `get_selected` raw-component-walk helper was deleted on
2671    // purpose: it ignored the grouped/sorted/deduped display list and made
2672    // yank copy the wrong CVE. Resolve selections through
2673    // `cached_display_items` / `get_selected_vuln_row` instead.
2674}
2675
2676impl Default for VulnExplorerState {
2677    fn default() -> Self {
2678        Self::new()
2679    }
2680}
2681
2682impl ListNavigation for VulnExplorerState {
2683    fn selected(&self) -> usize {
2684        self.selected
2685    }
2686
2687    fn set_selected(&mut self, idx: usize) {
2688        self.selected = idx;
2689    }
2690
2691    fn total(&self) -> usize {
2692        self.total
2693    }
2694
2695    fn set_total(&mut self, total: usize) {
2696        self.total = total;
2697    }
2698}
2699
2700/// View mode for quality panel
2701#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
2702pub(crate) enum QualityViewMode {
2703    #[default]
2704    Summary,
2705    Breakdown,
2706    Metrics,
2707    Recommendations,
2708}
2709
2710/// Quality view state
2711pub(crate) struct QualityViewState {
2712    pub view_mode: QualityViewMode,
2713    pub selected_recommendation: usize,
2714    pub total_recommendations: usize,
2715    pub scroll_offset: usize,
2716}
2717
2718impl QualityViewState {
2719    pub const fn new(total_recommendations: usize) -> Self {
2720        Self {
2721            view_mode: QualityViewMode::Summary,
2722            selected_recommendation: 0,
2723            total_recommendations,
2724            scroll_offset: 0,
2725        }
2726    }
2727
2728    pub const fn toggle_view(&mut self) {
2729        self.view_mode = match self.view_mode {
2730            QualityViewMode::Summary => QualityViewMode::Breakdown,
2731            QualityViewMode::Breakdown => QualityViewMode::Metrics,
2732            QualityViewMode::Metrics => QualityViewMode::Recommendations,
2733            QualityViewMode::Recommendations => QualityViewMode::Summary,
2734        };
2735        self.selected_recommendation = 0;
2736        self.scroll_offset = 0;
2737    }
2738}
2739
2740impl ListNavigation for QualityViewState {
2741    fn selected(&self) -> usize {
2742        self.selected_recommendation
2743    }
2744
2745    fn set_selected(&mut self, idx: usize) {
2746        self.selected_recommendation = idx;
2747    }
2748
2749    fn total(&self) -> usize {
2750        self.total_recommendations
2751    }
2752
2753    fn set_total(&mut self, total: usize) {
2754        self.total_recommendations = total;
2755    }
2756}
2757
2758impl Default for QualityViewState {
2759    fn default() -> Self {
2760        Self::new(0)
2761    }
2762}
2763
2764/// Vulnerability grouping modes.
2765#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2766pub(crate) enum VulnGroupBy {
2767    Severity,
2768    Component,
2769    Flat,
2770}
2771
2772/// Vulnerability sorting modes.
2773#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2774pub(crate) enum VulnSortBy {
2775    Severity,
2776    Cvss,
2777    CveId,
2778    Component,
2779}
2780
2781impl VulnSortBy {
2782    pub const fn next(self) -> Self {
2783        match self {
2784            Self::Severity => Self::Cvss,
2785            Self::Cvss => Self::CveId,
2786            Self::CveId => Self::Component,
2787            Self::Component => Self::Severity,
2788        }
2789    }
2790
2791    pub const fn label(self) -> &'static str {
2792        match self {
2793            Self::Severity => "Severity",
2794            Self::Cvss => "CVSS",
2795            Self::CveId => "CVE ID",
2796            Self::Component => "Component",
2797        }
2798    }
2799}
2800
2801/// Sort order for the Algorithms tab.
2802#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
2803pub(crate) enum AlgorithmSortBy {
2804    #[default]
2805    Name,
2806    Family,
2807    QuantumLevel,
2808    Strength,
2809}
2810
2811impl AlgorithmSortBy {
2812    pub const fn next(self) -> Self {
2813        match self {
2814            Self::Name => Self::Family,
2815            Self::Family => Self::QuantumLevel,
2816            Self::QuantumLevel => Self::Strength,
2817            Self::Strength => Self::Name,
2818        }
2819    }
2820
2821    pub const fn label(self) -> &'static str {
2822        match self {
2823            Self::Name => "Name",
2824            Self::Family => "Family",
2825            Self::QuantumLevel => "Quantum",
2826            Self::Strength => "Strength",
2827        }
2828    }
2829}
2830
2831/// State for license view.
2832#[derive(Debug, Clone)]
2833pub(crate) struct LicenseViewState {
2834    pub selected: usize,
2835    pub total: usize,
2836    pub scroll_offset: usize,
2837    pub group_by: LicenseGroupBy,
2838    /// Scroll position within component list in details panel
2839    pub component_scroll: usize,
2840    /// Total components for the selected license
2841    pub component_total: usize,
2842    /// Cached pairwise SPDX compatibility report. The SBOM is immutable in
2843    /// view mode, so this is computed once (lazily, on first Licenses render)
2844    /// instead of running the O(unique_licenses squared) check per frame.
2845    pub compat_report:
2846        Option<std::sync::Arc<crate::tui::license_utils::LicenseCompatibilityReport>>,
2847}
2848
2849impl LicenseViewState {
2850    pub const fn new() -> Self {
2851        Self {
2852            selected: 0,
2853            total: 0,
2854            scroll_offset: 0,
2855            group_by: LicenseGroupBy::License,
2856            component_scroll: 0,
2857            component_total: 0,
2858            compat_report: None,
2859        }
2860    }
2861
2862    /// Scroll component list up
2863    pub const fn scroll_components_up(&mut self) {
2864        if self.component_scroll > 0 {
2865            self.component_scroll -= 1;
2866        }
2867    }
2868
2869    /// Scroll component list down
2870    pub const fn scroll_components_down(&mut self, visible_count: usize) {
2871        if self.component_total > visible_count
2872            && self.component_scroll < self.component_total - visible_count
2873        {
2874            self.component_scroll += 1;
2875        }
2876    }
2877
2878    /// Reset component scroll when license selection changes
2879    pub const fn reset_component_scroll(&mut self) {
2880        self.component_scroll = 0;
2881    }
2882
2883    pub const fn select_next(&mut self) {
2884        if self.total > 0 && self.selected < self.total.saturating_sub(1) {
2885            self.selected += 1;
2886            self.reset_component_scroll();
2887        }
2888    }
2889
2890    pub const fn select_prev(&mut self) {
2891        if self.selected > 0 {
2892            self.selected -= 1;
2893            self.reset_component_scroll();
2894        }
2895    }
2896
2897    /// Ensure selected index is within bounds
2898    pub const fn clamp_selection(&mut self) {
2899        if self.total == 0 {
2900            self.selected = 0;
2901        } else if self.selected >= self.total {
2902            self.selected = self.total.saturating_sub(1);
2903        }
2904    }
2905
2906    pub const fn toggle_group(&mut self) {
2907        self.group_by = match self.group_by {
2908            LicenseGroupBy::License => LicenseGroupBy::Category,
2909            LicenseGroupBy::Category => LicenseGroupBy::License,
2910        };
2911        self.selected = 0;
2912        self.reset_component_scroll();
2913    }
2914}
2915
2916impl Default for LicenseViewState {
2917    fn default() -> Self {
2918        Self::new()
2919    }
2920}
2921
2922impl ListNavigation for LicenseViewState {
2923    fn selected(&self) -> usize {
2924        self.selected
2925    }
2926
2927    fn set_selected(&mut self, idx: usize) {
2928        self.selected = idx;
2929    }
2930
2931    fn total(&self) -> usize {
2932        self.total
2933    }
2934
2935    fn set_total(&mut self, total: usize) {
2936        self.total = total;
2937    }
2938}
2939
2940/// License grouping modes.
2941#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2942pub(crate) enum LicenseGroupBy {
2943    License,
2944    Category,
2945}
2946
2947/// Dependency view state.
2948#[derive(Debug, Clone)]
2949pub(crate) struct DependencyViewState {
2950    /// Currently selected node in the dependency tree
2951    pub selected: usize,
2952    /// Total number of visible nodes
2953    pub total: usize,
2954    /// Set of expanded node IDs
2955    pub expanded: HashSet<String>,
2956    /// Scroll offset for the tree view
2957    pub scroll_offset: usize,
2958    /// Search query for dependency tree filtering
2959    pub search_query: String,
2960    /// Whether search input is active
2961    pub search_active: bool,
2962    /// Scroll offset for the detail/stats panel
2963    pub detail_scroll: u16,
2964    /// Whether roots have been auto-expanded on first visit
2965    pub roots_initialized: bool,
2966    /// Snapshot of expanded set when flat nodes were last built
2967    expanded_snapshot: HashSet<String>,
2968    /// Cached flattened tree nodes
2969    pub cached_flat_nodes: Vec<super::views::FlatDepNode>,
2970    /// Cached search match count (query, count)
2971    cached_search_match: (String, Option<usize>),
2972    /// Lazily-built dependency graph (built once; `sbom` is immutable in view mode)
2973    pub(crate) cached_graph: Option<super::views::DependencyGraph>,
2974}
2975
2976impl DependencyViewState {
2977    pub fn new() -> Self {
2978        Self {
2979            selected: 0,
2980            total: 0,
2981            expanded: HashSet::new(),
2982            scroll_offset: 0,
2983            search_query: String::new(),
2984            search_active: false,
2985            detail_scroll: 0,
2986            roots_initialized: false,
2987            expanded_snapshot: HashSet::new(),
2988            cached_flat_nodes: Vec::new(),
2989            cached_search_match: (String::new(), None),
2990            cached_graph: None,
2991        }
2992    }
2993
2994    pub fn toggle_expand(&mut self, node_id: &str) {
2995        if self.expanded.contains(node_id) {
2996            self.expanded.remove(node_id);
2997        } else {
2998            self.expanded.insert(node_id.to_string());
2999        }
3000    }
3001
3002    pub fn is_expanded(&self, node_id: &str) -> bool {
3003        self.expanded.contains(node_id)
3004    }
3005
3006    pub fn expand_all(&mut self, all_node_ids: &[String]) {
3007        self.expanded.extend(all_node_ids.iter().cloned());
3008    }
3009
3010    pub fn collapse_all(&mut self) {
3011        self.expanded.clear();
3012    }
3013
3014    pub fn start_search(&mut self) {
3015        self.search_active = true;
3016    }
3017
3018    pub fn stop_search(&mut self) {
3019        self.search_active = false;
3020    }
3021
3022    pub fn clear_search(&mut self) {
3023        self.search_query.clear();
3024        self.search_active = false;
3025    }
3026
3027    pub fn search_push(&mut self, c: char) {
3028        self.search_query.push(c);
3029    }
3030
3031    pub fn search_pop(&mut self) {
3032        self.search_query.pop();
3033    }
3034
3035    /// Check if cached flat nodes are still valid
3036    pub fn are_flat_nodes_valid(&self) -> bool {
3037        !self.cached_flat_nodes.is_empty() && self.expanded_snapshot == self.expanded
3038    }
3039
3040    /// Cache flat nodes and snapshot expanded state
3041    pub fn set_cached_flat_nodes(&mut self, nodes: Vec<super::views::FlatDepNode>) {
3042        self.cached_flat_nodes = nodes;
3043        self.expanded_snapshot = self.expanded.clone();
3044    }
3045
3046    /// Get cached search match count, recomputing only when query changed
3047    pub fn get_search_match_count(&mut self) -> Option<usize> {
3048        if self.search_query.is_empty() {
3049            return None;
3050        }
3051        if self.cached_search_match.0 == self.search_query {
3052            return self.cached_search_match.1;
3053        }
3054        let q = self.search_query.to_lowercase();
3055        let count = self
3056            .cached_flat_nodes
3057            .iter()
3058            .filter(|n| n.name.to_lowercase().contains(&q))
3059            .count();
3060        self.cached_search_match = (self.search_query.clone(), Some(count));
3061        Some(count)
3062    }
3063}
3064
3065impl Default for DependencyViewState {
3066    fn default() -> Self {
3067        Self::new()
3068    }
3069}
3070
3071impl ListNavigation for DependencyViewState {
3072    fn selected(&self) -> usize {
3073        self.selected
3074    }
3075
3076    fn set_selected(&mut self, idx: usize) {
3077        self.selected = idx;
3078    }
3079
3080    fn total(&self) -> usize {
3081        self.total
3082    }
3083
3084    fn set_total(&mut self, total: usize) {
3085        self.total = total;
3086    }
3087}
3088
3089/// Global search state.
3090#[derive(Debug, Clone)]
3091pub(crate) struct SearchState {
3092    pub active: bool,
3093    pub query: String,
3094    pub results: Vec<SearchResult>,
3095    pub selected: usize,
3096    /// Substring or regex (Ctrl+R toggles, same contract as diff mode)
3097    pub mode: crate::tui::app_states::SearchMode,
3098    /// Error from an invalid regex pattern
3099    pub search_error: Option<String>,
3100}
3101
3102impl SearchState {
3103    pub const fn new() -> Self {
3104        Self {
3105            active: false,
3106            query: String::new(),
3107            results: Vec::new(),
3108            selected: 0,
3109            mode: crate::tui::app_states::SearchMode::Substring,
3110            search_error: None,
3111        }
3112    }
3113
3114    pub fn push_char(&mut self, c: char) {
3115        self.query.push(c);
3116    }
3117
3118    pub fn pop_char(&mut self) {
3119        self.query.pop();
3120    }
3121
3122    pub fn select_next(&mut self) {
3123        if !self.results.is_empty() && self.selected < self.results.len() - 1 {
3124            self.selected += 1;
3125        }
3126    }
3127
3128    pub const fn select_prev(&mut self) {
3129        if self.selected > 0 {
3130            self.selected -= 1;
3131        }
3132    }
3133}
3134
3135impl Default for SearchState {
3136    fn default() -> Self {
3137        Self::new()
3138    }
3139}
3140
3141/// Search result types.
3142#[derive(Debug, Clone)]
3143pub(crate) enum SearchResult {
3144    Component {
3145        id: String,
3146        name: String,
3147        version: Option<String>,
3148        match_field: String,
3149    },
3150    Vulnerability {
3151        id: String,
3152        /// Component canonical ID for navigation
3153        component_id: String,
3154        /// Component name for display
3155        component_name: String,
3156        severity: Option<String>,
3157    },
3158}
3159
3160/// Cached SBOM statistics.
3161#[derive(Debug, Clone)]
3162pub struct SbomStats {
3163    pub component_count: usize,
3164    pub vuln_count: usize,
3165    pub license_count: usize,
3166    pub ecosystem_counts: HashMap<String, usize>,
3167    pub vuln_by_severity: HashMap<String, usize>,
3168    pub license_counts: HashMap<String, usize>,
3169    pub critical_count: usize,
3170    pub high_count: usize,
3171    pub medium_count: usize,
3172    pub low_count: usize,
3173    pub unknown_count: usize,
3174    pub eol_count: usize,
3175    pub eol_approaching_count: usize,
3176    pub eol_supported_count: usize,
3177    pub eol_security_only_count: usize,
3178    pub eol_enriched: bool,
3179}
3180
3181impl SbomStats {
3182    pub fn from_sbom(sbom: &NormalizedSbom) -> Self {
3183        let mut ecosystem_counts: HashMap<String, usize> = HashMap::new();
3184        let mut vuln_by_severity: HashMap<String, usize> = HashMap::new();
3185        let mut license_counts: HashMap<String, usize> = HashMap::new();
3186        let mut vuln_count = 0;
3187        let mut critical_count = 0;
3188        let mut high_count = 0;
3189        let mut medium_count = 0;
3190        let mut low_count = 0;
3191        let mut unknown_count = 0;
3192        let mut eol_count = 0;
3193        let mut eol_approaching_count = 0;
3194        let mut eol_supported_count = 0;
3195        let mut eol_security_only_count = 0;
3196        let mut eol_enriched = false;
3197
3198        for comp in sbom.components.values() {
3199            // Count ecosystems
3200            let eco = comp
3201                .ecosystem
3202                .as_ref()
3203                .map_or_else(|| "Unknown".to_string(), std::string::ToString::to_string);
3204            *ecosystem_counts.entry(eco).or_insert(0) += 1;
3205
3206            // Count licenses
3207            for lic in &comp.licenses.declared {
3208                *license_counts.entry(lic.expression.clone()).or_insert(0) += 1;
3209            }
3210            if comp.licenses.declared.is_empty() {
3211                *license_counts.entry("Unknown".to_string()).or_insert(0) += 1;
3212            }
3213
3214            // Count vulnerabilities
3215            for vuln in &comp.vulnerabilities {
3216                vuln_count += 1;
3217                let sev = vuln
3218                    .severity
3219                    .as_ref()
3220                    .map_or_else(|| "Unknown".to_string(), std::string::ToString::to_string);
3221                *vuln_by_severity.entry(sev.clone()).or_insert(0) += 1;
3222
3223                match sev.to_lowercase().as_str() {
3224                    "critical" => critical_count += 1,
3225                    "high" => high_count += 1,
3226                    "medium" => medium_count += 1,
3227                    "low" => low_count += 1,
3228                    _ => unknown_count += 1,
3229                }
3230            }
3231
3232            // Count EOL statuses
3233            if let Some(eol) = &comp.eol {
3234                use crate::model::EolStatus;
3235                eol_enriched = true;
3236                match eol.status {
3237                    EolStatus::EndOfLife => eol_count += 1,
3238                    EolStatus::ApproachingEol => eol_approaching_count += 1,
3239                    EolStatus::Supported => eol_supported_count += 1,
3240                    EolStatus::SecurityOnly => eol_security_only_count += 1,
3241                    _ => {}
3242                }
3243            }
3244        }
3245
3246        Self {
3247            component_count: sbom.components.len(),
3248            vuln_count,
3249            // Real licenses only — the synthetic "Unknown" bucket (components
3250            // with no license at all) must not count as a unique license.
3251            license_count: license_counts
3252                .keys()
3253                .filter(|k| k.as_str() != "Unknown")
3254                .count(),
3255            ecosystem_counts,
3256            vuln_by_severity,
3257            license_counts,
3258            critical_count,
3259            high_count,
3260            medium_count,
3261            low_count,
3262            unknown_count,
3263            eol_count,
3264            eol_approaching_count,
3265            eol_supported_count,
3266            eol_security_only_count,
3267            eol_enriched,
3268        }
3269    }
3270}
3271
3272/// Breadcrumb entry for navigation history in view mode.
3273#[derive(Debug, Clone)]
3274pub struct ViewBreadcrumb {
3275    /// Tab we came from
3276    pub tab: ViewTab,
3277    /// Description of what was selected (e.g., "CVE-2024-1234", "lodash")
3278    pub label: String,
3279    /// Selection index to restore when going back
3280    pub selection_index: usize,
3281}
3282
3283/// Navigation context for cross-view navigation and breadcrumbs in view mode.
3284#[derive(Debug, Clone, Default)]
3285pub struct ViewNavigationContext {
3286    /// Breadcrumb trail for back navigation
3287    pub breadcrumbs: Vec<ViewBreadcrumb>,
3288    /// Target component name to navigate to (for vuln → component navigation)
3289    pub target_component: Option<String>,
3290    /// Target vulnerability ID to navigate to (for component → vuln navigation)
3291    pub target_vulnerability: Option<String>,
3292}
3293
3294impl ViewNavigationContext {
3295    #[must_use]
3296    pub const fn new() -> Self {
3297        Self {
3298            breadcrumbs: Vec::new(),
3299            target_component: None,
3300            target_vulnerability: None,
3301        }
3302    }
3303
3304    /// Push a new breadcrumb onto the trail
3305    pub fn push_breadcrumb(&mut self, tab: ViewTab, label: String, selection_index: usize) {
3306        self.breadcrumbs.push(ViewBreadcrumb {
3307            tab,
3308            label,
3309            selection_index,
3310        });
3311    }
3312
3313    /// Pop the last breadcrumb and return it (for back navigation)
3314    pub fn pop_breadcrumb(&mut self) -> Option<ViewBreadcrumb> {
3315        self.breadcrumbs.pop()
3316    }
3317
3318    /// Clear all breadcrumbs (on explicit tab switch)
3319    pub fn clear_breadcrumbs(&mut self) {
3320        self.breadcrumbs.clear();
3321    }
3322
3323    /// Check if we have navigation history
3324    #[must_use]
3325    pub fn has_history(&self) -> bool {
3326        !self.breadcrumbs.is_empty()
3327    }
3328
3329    /// Get the current breadcrumb trail as a string
3330    #[must_use]
3331    pub fn breadcrumb_trail(&self) -> String {
3332        self.breadcrumbs
3333            .iter()
3334            .map(|b| format!("{}: {}", b.tab.title(), b.label))
3335            .collect::<Vec<_>>()
3336            .join(" > ")
3337    }
3338
3339    /// Clear navigation targets
3340    pub fn clear_targets(&mut self) {
3341        self.target_component = None;
3342        self.target_vulnerability = None;
3343    }
3344}
3345
3346#[cfg(test)]
3347mod tests {
3348    use super::*;
3349    use crate::model::NormalizedSbom;
3350
3351    #[test]
3352    fn test_view_app_creation() {
3353        let sbom = NormalizedSbom::default();
3354        let mut app = ViewApp::new(sbom, "", crate::model::BomProfile::Sbom);
3355        // Reset to known state (preferences may override default)
3356        app.active_tab = ViewTab::Overview;
3357        assert_eq!(app.active_tab, ViewTab::Overview);
3358        assert!(!app.should_quit);
3359    }
3360
3361    #[test]
3362    fn with_cra_product_class_reaches_the_compliance_results() {
3363        // Regression: the view TUI used to silently drop the CLI-resolved
3364        // product class while the non-TUI report path applied it, so the same
3365        // invocation showed a milder verdict on a TTY. The class must reach
3366        // the compliance checkers (Critical escalates CRA severities).
3367        use crate::quality::ComplianceLevel;
3368        fn cra_error_count(app: &mut ViewApp) -> usize {
3369            app.ensure_compliance_results();
3370            app.compliance_results
3371                .as_ref()
3372                .unwrap()
3373                .iter()
3374                .find(|r| r.level == ComplianceLevel::CraPhase2)
3375                .expect("CraPhase2 result present")
3376                .error_count
3377        }
3378        let mut plain = ViewApp::new(
3379            NormalizedSbom::default(),
3380            "",
3381            crate::model::BomProfile::Sbom,
3382        );
3383        let mut critical = ViewApp::new(
3384            NormalizedSbom::default(),
3385            "",
3386            crate::model::BomProfile::Sbom,
3387        )
3388        .with_cra_product_class(crate::model::CraProductClass::Critical);
3389        let plain_errors = cra_error_count(&mut plain);
3390        let critical_errors = cra_error_count(&mut critical);
3391        assert!(
3392            critical_errors > plain_errors,
3393            "Critical class must escalate CRA severities in the TUI results \
3394             (plain={plain_errors} critical={critical_errors})"
3395        );
3396    }
3397
3398    #[test]
3399    fn test_tab_navigation() {
3400        let sbom = NormalizedSbom::default();
3401        let mut app = ViewApp::new(sbom, "", crate::model::BomProfile::Sbom);
3402        // Start from known state regardless of saved preferences
3403        app.active_tab = ViewTab::Overview;
3404
3405        app.next_tab();
3406        assert_eq!(app.active_tab, ViewTab::Tree);
3407
3408        app.next_tab();
3409        assert_eq!(app.active_tab, ViewTab::Dependencies);
3410
3411        app.prev_tab();
3412        assert_eq!(app.active_tab, ViewTab::Tree);
3413    }
3414
3415    #[test]
3416    fn test_vuln_state_navigation_with_zero_total() {
3417        // This was causing a crash due to underflow: total - 1 when total = 0
3418        let mut state = VulnExplorerState::new();
3419        assert_eq!(state.total, 0);
3420        assert_eq!(state.selected, 0);
3421
3422        // This should not panic or change selection
3423        state.select_next();
3424        assert_eq!(state.selected, 0);
3425
3426        state.select_prev();
3427        assert_eq!(state.selected, 0);
3428    }
3429
3430    #[test]
3431    fn test_vuln_state_clamp_selection() {
3432        let mut state = VulnExplorerState::new();
3433        state.total = 5;
3434        state.selected = 10; // Out of bounds
3435
3436        state.clamp_selection();
3437        assert_eq!(state.selected, 4); // Should be clamped to last valid index
3438
3439        state.total = 0;
3440        state.clamp_selection();
3441        assert_eq!(state.selected, 0); // Should be 0 when empty
3442    }
3443
3444    #[test]
3445    fn test_license_state_navigation_with_zero_total() {
3446        let mut state = LicenseViewState::new();
3447        assert_eq!(state.total, 0);
3448        assert_eq!(state.selected, 0);
3449
3450        // This should not panic or change selection
3451        state.select_next();
3452        assert_eq!(state.selected, 0);
3453
3454        state.select_prev();
3455        assert_eq!(state.selected, 0);
3456    }
3457
3458    #[test]
3459    fn test_license_state_clamp_selection() {
3460        let mut state = LicenseViewState::new();
3461        state.total = 3;
3462        state.selected = 5; // Out of bounds
3463
3464        state.clamp_selection();
3465        assert_eq!(state.selected, 2); // Should be clamped to last valid index
3466    }
3467
3468    #[test]
3469    fn test_dependency_state_navigation() {
3470        let mut state = DependencyViewState::new();
3471        assert_eq!(state.total, 0);
3472        assert_eq!(state.selected, 0);
3473
3474        // Test with zero total - should not change
3475        state.select_next();
3476        assert_eq!(state.selected, 0);
3477
3478        // Test with items
3479        state.total = 5;
3480        state.select_next();
3481        assert_eq!(state.selected, 1);
3482
3483        state.select_next();
3484        state.select_next();
3485        state.select_next();
3486        assert_eq!(state.selected, 4); // At end
3487
3488        state.select_next();
3489        assert_eq!(state.selected, 4); // Should not go past end
3490
3491        state.select_prev();
3492        assert_eq!(state.selected, 3);
3493    }
3494
3495    #[test]
3496    fn test_dependency_state_expand_collapse() {
3497        let mut state = DependencyViewState::new();
3498
3499        assert!(!state.is_expanded("node1"));
3500
3501        state.toggle_expand("node1");
3502        assert!(state.is_expanded("node1"));
3503
3504        state.toggle_expand("node1");
3505        assert!(!state.is_expanded("node1"));
3506    }
3507
3508    #[test]
3509    fn test_tabs_for_profile_sbom() {
3510        let tabs = ViewTab::tabs_for_profile(crate::model::BomProfile::Sbom);
3511        assert_eq!(tabs.len(), 8);
3512        assert_eq!(tabs[0], ViewTab::Overview);
3513        assert_eq!(tabs[1], ViewTab::Tree);
3514        // Shared-tab ordinals are pinned to the Diff app's tab bar so
3515        // digit-key jumps behave identically across the two apps.
3516        assert_eq!(tabs[2], ViewTab::Dependencies);
3517        assert_eq!(tabs[3], ViewTab::Licenses);
3518        assert_eq!(tabs[4], ViewTab::Vulnerabilities);
3519        assert_eq!(tabs[7], ViewTab::Source);
3520        assert!(!tabs.contains(&ViewTab::Algorithms));
3521    }
3522
3523    #[test]
3524    fn test_tabs_for_profile_cbom() {
3525        let tabs = ViewTab::tabs_for_profile(crate::model::BomProfile::Cbom);
3526        assert_eq!(tabs.len(), 8);
3527        assert_eq!(tabs[0], ViewTab::Overview);
3528        assert_eq!(tabs[1], ViewTab::Algorithms);
3529        assert_eq!(tabs[2], ViewTab::Certificates);
3530        assert_eq!(tabs[3], ViewTab::Keys);
3531        assert_eq!(tabs[4], ViewTab::Protocols);
3532        assert_eq!(tabs[5], ViewTab::Quality);
3533        assert_eq!(tabs[6], ViewTab::PqcCompliance);
3534        assert_eq!(tabs[7], ViewTab::Source);
3535        assert!(!tabs.contains(&ViewTab::Tree));
3536    }
3537
3538    #[test]
3539    fn test_cbom_tab_navigation_cycles() {
3540        let sbom = NormalizedSbom::default();
3541        let mut app = ViewApp::new(sbom, "", crate::model::BomProfile::Cbom);
3542        app.active_tab = ViewTab::Overview;
3543
3544        app.next_tab();
3545        assert_eq!(app.active_tab, ViewTab::Algorithms);
3546
3547        app.next_tab();
3548        assert_eq!(app.active_tab, ViewTab::Certificates);
3549
3550        // Cycle back from Source → Overview
3551        app.active_tab = ViewTab::Source;
3552        app.next_tab();
3553        assert_eq!(app.active_tab, ViewTab::Overview);
3554
3555        // Prev from Overview → Source
3556        app.prev_tab();
3557        assert_eq!(app.active_tab, ViewTab::Source);
3558    }
3559
3560    #[test]
3561    fn test_per_tab_selection_independent() {
3562        let mut sbom = NormalizedSbom::default();
3563        // Add crypto components for navigation
3564        for i in 0..5 {
3565            let mut c = crate::model::Component::new(format!("algo-{i}"), format!("algo-{i}@1.0"));
3566            c.component_type = crate::model::ComponentType::Cryptographic;
3567            c.crypto_properties = Some(crate::model::CryptoProperties::new(
3568                crate::model::CryptoAssetType::Algorithm,
3569            ));
3570            sbom.add_component(c);
3571        }
3572        for i in 0..2 {
3573            let mut c = crate::model::Component::new(format!("cert-{i}"), format!("cert-{i}@1.0"));
3574            c.component_type = crate::model::ComponentType::Cryptographic;
3575            c.crypto_properties = Some(crate::model::CryptoProperties::new(
3576                crate::model::CryptoAssetType::Certificate,
3577            ));
3578            sbom.add_component(c);
3579        }
3580
3581        let mut app = ViewApp::new(sbom, "", crate::model::BomProfile::Cbom);
3582
3583        // Navigate on Algorithms tab
3584        app.active_tab = ViewTab::Algorithms;
3585        app.navigate_down();
3586        app.navigate_down();
3587        assert_eq!(app.algorithms_selected, 2);
3588
3589        // Switch to Certificates — selection should be independent
3590        app.active_tab = ViewTab::Certificates;
3591        assert_eq!(app.certificates_selected, 0);
3592
3593        // Navigate on Certificates
3594        app.navigate_down();
3595        assert_eq!(app.certificates_selected, 1);
3596
3597        // Switch back to Algorithms — preserved at 2
3598        app.active_tab = ViewTab::Algorithms;
3599        assert_eq!(app.algorithms_selected, 2);
3600    }
3601
3602    #[test]
3603    fn test_crypto_count_for_tab() {
3604        let mut sbom = NormalizedSbom::default();
3605        for i in 0..3 {
3606            let mut c = crate::model::Component::new(format!("algo-{i}"), format!("algo-{i}@1.0"));
3607            c.component_type = crate::model::ComponentType::Cryptographic;
3608            c.crypto_properties = Some(crate::model::CryptoProperties::new(
3609                crate::model::CryptoAssetType::Algorithm,
3610            ));
3611            sbom.add_component(c);
3612        }
3613        let mut c = crate::model::Component::new("cert-0".to_string(), "cert-0@1.0".to_string());
3614        c.component_type = crate::model::ComponentType::Cryptographic;
3615        c.crypto_properties = Some(crate::model::CryptoProperties::new(
3616            crate::model::CryptoAssetType::Certificate,
3617        ));
3618        sbom.add_component(c);
3619
3620        let mut app = ViewApp::new(sbom, "", crate::model::BomProfile::Cbom);
3621
3622        app.active_tab = ViewTab::Algorithms;
3623        assert_eq!(app.crypto_count_for_tab(), 3);
3624
3625        app.active_tab = ViewTab::Certificates;
3626        assert_eq!(app.crypto_count_for_tab(), 1);
3627
3628        app.active_tab = ViewTab::Keys;
3629        assert_eq!(app.crypto_count_for_tab(), 0);
3630
3631        // Legacy Crypto tab counts all
3632        app.active_tab = ViewTab::Crypto;
3633        assert_eq!(app.crypto_count_for_tab(), 4);
3634    }
3635
3636    /// Regression: PQC-Compliance previously fell through to the navigation
3637    /// no-op arm while the footer advertised working arrow keys.
3638    #[test]
3639    fn pqc_compliance_navigation_clamps_selection() {
3640        let mut sbom = NormalizedSbom::default();
3641        for i in 0..5 {
3642            let mut c = crate::model::Component::new(format!("algo-{i}"), format!("algo-{i}@1.0"));
3643            c.component_type = crate::model::ComponentType::Cryptographic;
3644            c.crypto_properties = Some(crate::model::CryptoProperties::new(
3645                crate::model::CryptoAssetType::Algorithm,
3646            ));
3647            sbom.add_component(c);
3648        }
3649        let mut app = ViewApp::new(sbom, "", crate::model::BomProfile::Cbom);
3650        app.active_tab = ViewTab::PqcCompliance;
3651
3652        app.navigate_down();
3653        app.navigate_down();
3654        assert_eq!(app.pqc_selected, 2);
3655        app.go_last();
3656        assert_eq!(app.pqc_selected, 4);
3657        app.navigate_down();
3658        assert_eq!(app.pqc_selected, 4, "selection must clamp at the last row");
3659        app.go_first();
3660        assert_eq!(app.pqc_selected, 0);
3661    }
3662
3663    /// Regression: AI-Readiness previously fell through to the navigation
3664    /// no-op arm; the scroll must move and clamp to the longer pane.
3665    #[test]
3666    fn ai_readiness_navigation_scrolls_and_clamps() {
3667        let (sbom, profile) = crate::tui::test_support::aibom_single();
3668        let mut app = ViewApp::new(sbom, "", profile);
3669        app.active_tab = ViewTab::AiReadiness;
3670
3671        let max = app.ai_readiness_max_scroll();
3672        assert!(max > 0, "AIBOM fixture must produce scrollable content");
3673        for _ in 0..(max + 5) {
3674            app.navigate_down();
3675        }
3676        assert_eq!(app.ai_readiness_scroll, max, "scroll must clamp at max");
3677        app.go_first();
3678        assert_eq!(app.ai_readiness_scroll, 0);
3679        app.go_last();
3680        assert_eq!(app.ai_readiness_scroll, max);
3681        app.navigate_up();
3682        assert_eq!(app.ai_readiness_scroll, max - 1);
3683    }
3684}