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