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, ScoringProfile};
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    /// Current active view/tab
21    pub(crate) active_tab: ViewTab,
22
23    /// Tree navigation state
24    pub(crate) tree_state: TreeState,
25
26    /// Current tree grouping mode
27    pub(crate) tree_group_by: TreeGroupBy,
28
29    /// Current tree filter
30    pub(crate) tree_filter: TreeFilter,
31
32    /// Tree search query (inline filter)
33    pub(crate) tree_search_query: String,
34
35    /// Whether tree search is active
36    pub(crate) tree_search_active: bool,
37
38    /// Selected component ID (for detail panel)
39    pub(crate) selected_component: Option<String>,
40
41    /// Component detail sub-tab
42    pub(crate) component_tab: ComponentDetailTab,
43
44    /// Vulnerability explorer state
45    pub(crate) vuln_state: VulnExplorerState,
46
47    /// License view state
48    pub(crate) license_state: LicenseViewState,
49
50    /// Dependency view state
51    pub(crate) dependency_state: DependencyViewState,
52
53    /// Global search state
54    pub(crate) search_state: SearchState,
55
56    /// Focus panel (left list vs right detail)
57    pub(crate) focus_panel: FocusPanel,
58
59    /// Show help overlay
60    pub(crate) show_help: bool,
61
62    /// Show export dialog
63    pub(crate) show_export: bool,
64
65    /// Show legend overlay
66    pub(crate) show_legend: bool,
67
68    /// Status message to display temporarily
69    pub(crate) status_message: Option<String>,
70
71    /// When true, the status message survives one extra keypress before clearing.
72    pub(crate) status_sticky: bool,
73
74    /// Navigation context for breadcrumbs
75    pub(crate) navigation_ctx: ViewNavigationContext,
76
77    /// Should quit
78    pub(crate) should_quit: bool,
79
80    /// Animation tick counter
81    pub(crate) tick: u64,
82
83    /// Cached statistics
84    pub(crate) stats: SbomStats,
85
86    /// Quality report for the SBOM
87    pub(crate) quality_report: QualityReport,
88
89    /// Quality view state
90    pub(crate) quality_state: QualityViewState,
91
92    /// Compliance validation results for all standards (lazily computed)
93    pub(crate) compliance_results: Option<Vec<ComplianceResult>>,
94
95    /// Compliance view state
96    pub(crate) compliance_state: StandardComplianceState,
97
98    /// Precomputed index for fast lookups
99    pub(crate) sbom_index: NormalizedSbomIndex,
100
101    /// Source tab state
102    pub(crate) source_state: SourcePanelState,
103
104    /// Bookmarked component canonical IDs (in-memory, no persistence)
105    pub(crate) bookmarked: HashSet<String>,
106
107    /// Optional export filename template (from `--export-template` CLI arg).
108    pub(crate) export_template: Option<String>,
109}
110
111impl ViewApp {
112    /// Create a new `ViewApp` for the given SBOM.
113    #[must_use]
114    pub fn new(sbom: NormalizedSbom, raw_content: &str) -> Self {
115        let stats = SbomStats::from_sbom(&sbom);
116
117        // Calculate quality score
118        let scorer = QualityScorer::new(ScoringProfile::Standard);
119        let quality_report = scorer.score(&sbom);
120        let quality_state = QualityViewState::new(quality_report.recommendations.len());
121
122        let compliance_state = StandardComplianceState::new();
123
124        // Build index for fast lookups (O(1) instead of O(n))
125        let sbom_index = sbom.build_index();
126
127        // Build source panel state from raw content
128        let source_state = SourcePanelState::new(raw_content);
129
130        // Pre-expand the first few ecosystems
131        let mut tree_state = TreeState::new();
132        for eco in stats.ecosystem_counts.keys().take(3) {
133            tree_state.expand(&format!("eco:{eco}"));
134        }
135
136        // Restore last active tab from preferences
137        let initial_tab = crate::config::TuiPreferences::load()
138            .last_view_tab
139            .as_deref()
140            .and_then(ViewTab::from_str_opt)
141            .unwrap_or(ViewTab::Overview);
142
143        let mut app = Self {
144            sbom,
145            active_tab: initial_tab,
146            tree_state,
147            tree_group_by: TreeGroupBy::Ecosystem,
148            tree_filter: TreeFilter::All,
149            tree_search_query: String::new(),
150            tree_search_active: false,
151            selected_component: None,
152            component_tab: ComponentDetailTab::Overview,
153            vuln_state: VulnExplorerState::new(),
154            license_state: LicenseViewState::new(),
155            dependency_state: DependencyViewState::new(),
156            search_state: SearchState::new(),
157            focus_panel: FocusPanel::Left,
158            show_help: false,
159            show_export: false,
160            show_legend: false,
161            status_message: None,
162            status_sticky: false,
163            navigation_ctx: ViewNavigationContext::new(),
164            should_quit: false,
165            tick: 0,
166            stats,
167            quality_report,
168            quality_state,
169            compliance_results: None,
170            compliance_state,
171            sbom_index,
172            source_state,
173            bookmarked: HashSet::new(),
174            export_template: None,
175        };
176
177        // Pre-compute vuln cache at startup to avoid freeze on first tab visit
178        let cache = super::views::build_vuln_cache(&app);
179        app.vuln_state.set_cache(cache);
180
181        app
182    }
183
184    /// Lazily compute compliance results for all standards when first needed.
185    pub fn ensure_compliance_results(&mut self) {
186        if self.compliance_results.is_none() {
187            self.compliance_results = Some(compute_compliance_results(&self.sbom));
188        }
189    }
190
191    /// Switch to the next tab.
192    pub const fn next_tab(&mut self) {
193        self.active_tab = match self.active_tab {
194            ViewTab::Overview => ViewTab::Tree,
195            ViewTab::Tree => ViewTab::Vulnerabilities,
196            ViewTab::Vulnerabilities => ViewTab::Licenses,
197            ViewTab::Licenses => ViewTab::Dependencies,
198            ViewTab::Dependencies => ViewTab::Quality,
199            ViewTab::Quality => ViewTab::Compliance,
200            ViewTab::Compliance => ViewTab::Source,
201            ViewTab::Source => ViewTab::Overview,
202        };
203    }
204
205    /// Switch to the previous tab.
206    pub const fn prev_tab(&mut self) {
207        self.active_tab = match self.active_tab {
208            ViewTab::Overview => ViewTab::Source,
209            ViewTab::Tree => ViewTab::Overview,
210            ViewTab::Vulnerabilities => ViewTab::Tree,
211            ViewTab::Licenses => ViewTab::Vulnerabilities,
212            ViewTab::Dependencies => ViewTab::Licenses,
213            ViewTab::Quality => ViewTab::Dependencies,
214            ViewTab::Compliance => ViewTab::Quality,
215            ViewTab::Source => ViewTab::Compliance,
216        };
217    }
218
219    /// Select a specific tab.
220    pub const fn select_tab(&mut self, tab: ViewTab) {
221        self.active_tab = tab;
222    }
223
224    // ========================================================================
225    // Index access methods for O(1) lookups
226    // ========================================================================
227
228    /// Get the sort key for a component using the cached index.
229    ///
230    /// Returns pre-computed lowercase strings to avoid repeated allocations during sorting.
231    #[must_use]
232    pub fn get_sort_key(
233        &self,
234        id: &crate::model::CanonicalId,
235    ) -> Option<&crate::model::ComponentSortKey> {
236        self.sbom_index.sort_key(id)
237    }
238
239    /// Get dependencies of a component using the cached index (O(k) instead of O(edges)).
240    #[must_use]
241    pub fn get_dependencies(
242        &self,
243        id: &crate::model::CanonicalId,
244    ) -> Vec<&crate::model::DependencyEdge> {
245        self.sbom_index.dependencies_of(id, &self.sbom.edges)
246    }
247
248    /// Get dependents of a component using the cached index (O(k) instead of O(edges)).
249    #[must_use]
250    pub fn get_dependents(
251        &self,
252        id: &crate::model::CanonicalId,
253    ) -> Vec<&crate::model::DependencyEdge> {
254        self.sbom_index.dependents_of(id, &self.sbom.edges)
255    }
256
257    /// Search components by name using the cached index.
258    #[must_use]
259    pub fn search_components_by_name(&self, query: &str) -> Vec<&crate::model::Component> {
260        self.sbom.search_by_name_indexed(query, &self.sbom_index)
261    }
262
263    /// Toggle focus between left and right panels.
264    pub const fn toggle_focus(&mut self) {
265        self.focus_panel = match self.focus_panel {
266            FocusPanel::Left => FocusPanel::Right,
267            FocusPanel::Right => FocusPanel::Left,
268        };
269    }
270
271    /// Start search mode.
272    pub fn start_search(&mut self) {
273        self.search_state.active = true;
274        self.search_state.query.clear();
275        self.search_state.results.clear();
276    }
277
278    /// Stop search mode.
279    pub const fn stop_search(&mut self) {
280        self.search_state.active = false;
281    }
282
283    /// Execute search with current query.
284    pub fn execute_search(&mut self) {
285        self.search_state.results = self.search(&self.search_state.query.clone());
286        self.search_state.selected = 0;
287    }
288
289    /// Search across the SBOM for matching items.
290    fn search(&self, query: &str) -> Vec<SearchResult> {
291        if query.len() < 2 {
292            return Vec::new();
293        }
294
295        let query_lower = query.to_lowercase();
296        let mut results = Vec::new();
297
298        // Search components
299        for (id, comp) in &self.sbom.components {
300            if comp.name.to_lowercase().contains(&query_lower) {
301                results.push(SearchResult::Component {
302                    id: id.value().to_string(),
303                    name: comp.name.clone(),
304                    version: comp.version.clone(),
305                    match_field: "name".to_string(),
306                });
307            } else if let Some(purl) = &comp.identifiers.purl
308                && purl.to_lowercase().contains(&query_lower)
309            {
310                results.push(SearchResult::Component {
311                    id: id.value().to_string(),
312                    name: comp.name.clone(),
313                    version: comp.version.clone(),
314                    match_field: "purl".to_string(),
315                });
316            }
317        }
318
319        // Search vulnerabilities
320        for (_, comp) in &self.sbom.components {
321            for vuln in &comp.vulnerabilities {
322                if vuln.id.to_lowercase().contains(&query_lower) {
323                    results.push(SearchResult::Vulnerability {
324                        id: vuln.id.clone(),
325                        component_id: comp.canonical_id.to_string(), // Store ID for navigation
326                        component_name: comp.name.clone(),
327                        severity: vuln.severity.as_ref().map(std::string::ToString::to_string),
328                    });
329                }
330            }
331        }
332
333        // Limit results
334        results.truncate(50);
335        results
336    }
337
338    /// Get the currently selected component.
339    #[must_use]
340    pub fn get_selected_component(&self) -> Option<&Component> {
341        self.selected_component.as_ref().and_then(|selected_id| {
342            self.sbom
343                .components
344                .iter()
345                .find(|(id, _)| id.value() == selected_id)
346                .map(|(_, comp)| comp)
347        })
348    }
349
350    /// Jump tree selection to a component, expanding its group if needed.
351    pub fn jump_to_component_in_tree(&mut self, component_id: &str) -> bool {
352        let group_id = {
353            let Some(comp) = self
354                .sbom
355                .components
356                .iter()
357                .find(|(id, _)| id.value() == component_id)
358                .map(|(_, comp)| comp)
359            else {
360                return false;
361            };
362            self.tree_group_id_for_component(comp)
363        };
364        if let Some(ref group_id) = group_id {
365            self.tree_state.expand(group_id);
366        }
367
368        let nodes = self.build_tree_nodes();
369        let mut flat_items = Vec::new();
370        flatten_tree_for_selection(&nodes, &self.tree_state, &mut flat_items);
371
372        if let Some(index) = flat_items
373            .iter()
374            .position(|item| matches!(item, SelectedTreeNode::Component(id) if id == component_id))
375        {
376            self.tree_state.selected = index;
377            return true;
378        }
379
380        if let Some(group_id) = group_id
381            && let Some(index) = flat_items
382                .iter()
383                .position(|item| matches!(item, SelectedTreeNode::Group(id) if id == &group_id))
384        {
385            self.tree_state.selected = index;
386        }
387
388        false
389    }
390
391    /// Get the currently selected tree node info (Group label + children component IDs,
392    /// or None if a component is selected or nothing is selected).
393    #[must_use]
394    pub fn get_selected_group_info(&self) -> Option<(String, Vec<String>)> {
395        let nodes = self.build_tree_nodes();
396        let mut flat_items = Vec::new();
397        flatten_tree_for_selection(&nodes, &self.tree_state, &mut flat_items);
398
399        let selected = flat_items.get(self.tree_state.selected)?;
400        match selected {
401            SelectedTreeNode::Group(group_id) => {
402                // Find the group in tree nodes and collect child component IDs
403                fn find_group_children(
404                    nodes: &[crate::tui::widgets::TreeNode],
405                    target_id: &str,
406                ) -> Option<(String, Vec<String>)> {
407                    for node in nodes {
408                        if let crate::tui::widgets::TreeNode::Group {
409                            id,
410                            label,
411                            children,
412                            ..
413                        } = node
414                        {
415                            if id == target_id {
416                                let child_ids: Vec<String> = children
417                                    .iter()
418                                    .filter_map(|c| match c {
419                                        crate::tui::widgets::TreeNode::Component { id, .. } => {
420                                            Some(id.clone())
421                                        }
422                                        crate::tui::widgets::TreeNode::Group { .. } => None,
423                                    })
424                                    .collect();
425                                return Some((label.clone(), child_ids));
426                            }
427                            // Recurse into subgroups
428                            if let Some(result) = find_group_children(children, target_id) {
429                                return Some(result);
430                            }
431                        }
432                    }
433                    None
434                }
435                find_group_children(&nodes, group_id)
436            }
437            SelectedTreeNode::Component(_) => None,
438        }
439    }
440
441    /// Toggle bookmark on the currently selected component.
442    pub fn toggle_bookmark(&mut self) {
443        if let Some(ref comp_id) = self.selected_component {
444            if self.bookmarked.contains(comp_id) {
445                self.bookmarked.remove(comp_id);
446            } else {
447                self.bookmarked.insert(comp_id.clone());
448            }
449        } else if let Some(node) = self.get_selected_tree_node() {
450            match node {
451                SelectedTreeNode::Component(id) => {
452                    if self.bookmarked.contains(&id) {
453                        self.bookmarked.remove(&id);
454                    } else {
455                        self.bookmarked.insert(id);
456                    }
457                }
458                SelectedTreeNode::Group(_) => {}
459            }
460        }
461    }
462
463    /// Toggle tree grouping mode.
464    pub fn toggle_tree_grouping(&mut self) {
465        self.tree_group_by = match self.tree_group_by {
466            TreeGroupBy::Ecosystem => TreeGroupBy::License,
467            TreeGroupBy::License => TreeGroupBy::VulnStatus,
468            TreeGroupBy::VulnStatus => TreeGroupBy::ComponentType,
469            TreeGroupBy::ComponentType => TreeGroupBy::Flat,
470            TreeGroupBy::Flat => TreeGroupBy::Ecosystem,
471        };
472        self.tree_state = TreeState::new(); // Reset tree state on grouping change
473    }
474
475    /// Toggle tree filter.
476    pub fn toggle_tree_filter(&mut self) {
477        self.tree_filter = match self.tree_filter {
478            TreeFilter::All => TreeFilter::HasVulnerabilities,
479            TreeFilter::HasVulnerabilities => TreeFilter::Critical,
480            TreeFilter::Critical => TreeFilter::Bookmarked,
481            TreeFilter::Bookmarked => TreeFilter::All,
482        };
483        self.tree_state = TreeState::new();
484    }
485
486    /// Start tree search mode.
487    pub fn start_tree_search(&mut self) {
488        self.tree_search_active = true;
489        self.tree_search_query.clear();
490    }
491
492    /// Stop tree search mode.
493    pub const fn stop_tree_search(&mut self) {
494        self.tree_search_active = false;
495    }
496
497    /// Clear tree search and exit search mode.
498    pub fn clear_tree_search(&mut self) {
499        self.tree_search_query.clear();
500        self.tree_search_active = false;
501        self.tree_state = TreeState::new();
502    }
503
504    /// Add character to tree search query.
505    pub fn tree_search_push_char(&mut self, c: char) {
506        self.tree_search_query.push(c);
507        self.tree_state = TreeState::new();
508    }
509
510    /// Remove character from tree search query.
511    pub fn tree_search_pop_char(&mut self) {
512        self.tree_search_query.pop();
513        self.tree_state = TreeState::new();
514    }
515
516    /// Cycle to next component detail tab.
517    pub const fn next_component_tab(&mut self) {
518        self.component_tab = match self.component_tab {
519            ComponentDetailTab::Overview => ComponentDetailTab::Identifiers,
520            ComponentDetailTab::Identifiers => ComponentDetailTab::Vulnerabilities,
521            ComponentDetailTab::Vulnerabilities => ComponentDetailTab::Dependencies,
522            ComponentDetailTab::Dependencies => ComponentDetailTab::Overview,
523        };
524    }
525
526    /// Cycle to previous component detail tab.
527    pub const fn prev_component_tab(&mut self) {
528        self.component_tab = match self.component_tab {
529            ComponentDetailTab::Overview => ComponentDetailTab::Dependencies,
530            ComponentDetailTab::Identifiers => ComponentDetailTab::Overview,
531            ComponentDetailTab::Vulnerabilities => ComponentDetailTab::Identifiers,
532            ComponentDetailTab::Dependencies => ComponentDetailTab::Vulnerabilities,
533        };
534    }
535
536    /// Select a specific component detail tab.
537    pub(crate) const fn select_component_tab(&mut self, tab: ComponentDetailTab) {
538        self.component_tab = tab;
539    }
540
541    /// Toggle help overlay.
542    pub const fn toggle_help(&mut self) {
543        self.show_help = !self.show_help;
544        if self.show_help {
545            self.show_export = false;
546            self.show_legend = false;
547        }
548    }
549
550    /// Toggle export dialog.
551    pub const fn toggle_export(&mut self) {
552        self.show_export = !self.show_export;
553        if self.show_export {
554            self.show_help = false;
555            self.show_legend = false;
556        }
557    }
558
559    /// Toggle legend overlay.
560    pub const fn toggle_legend(&mut self) {
561        self.show_legend = !self.show_legend;
562        if self.show_legend {
563            self.show_help = false;
564            self.show_export = false;
565        }
566    }
567
568    /// Close all overlays.
569    pub const fn close_overlays(&mut self) {
570        self.show_help = false;
571        self.show_export = false;
572        self.show_legend = false;
573        self.search_state.active = false;
574        self.compliance_state.show_detail = false;
575    }
576
577    /// Check if any overlay is open.
578    #[must_use]
579    pub const fn has_overlay(&self) -> bool {
580        self.show_help
581            || self.show_export
582            || self.show_legend
583            || self.search_state.active
584            || self.compliance_state.show_detail
585    }
586
587    /// Set a temporary status message.
588    pub fn set_status_message(&mut self, msg: impl Into<String>) {
589        self.status_message = Some(msg.into());
590    }
591
592    /// Clear the status message.
593    ///
594    /// If `status_sticky` is set the message is kept for one extra keypress,
595    /// then cleared on the subsequent call.
596    pub fn clear_status_message(&mut self) {
597        if self.status_sticky {
598            self.status_sticky = false;
599        } else {
600            self.status_message = None;
601        }
602    }
603
604    /// Export the current SBOM to a file.
605    ///
606    /// The export is scoped to the active tab: e.g. if the user is on the
607    /// Vulnerabilities tab only vulnerability data is included.
608    pub fn export(&mut self, format: crate::tui::export::ExportFormat) {
609        use crate::reports::ReportConfig;
610        use crate::tui::export::{export_view, view_tab_to_report_type};
611
612        let report_type = view_tab_to_report_type(self.active_tab);
613        let config = ReportConfig::with_types(vec![report_type]);
614        let result = export_view(
615            format,
616            &self.sbom,
617            None,
618            &config,
619            self.export_template.as_deref(),
620        );
621
622        if result.success {
623            self.set_status_message(result.message);
624            self.status_sticky = true;
625        } else {
626            self.set_status_message(format!("Export failed: {}", result.message));
627        }
628    }
629
630    /// Export compliance results from the compliance tab
631    pub fn export_compliance(&mut self, format: crate::tui::export::ExportFormat) {
632        use crate::tui::export::export_compliance;
633
634        self.ensure_compliance_results();
635        let results = match self.compliance_results.as_ref() {
636            Some(r) if !r.is_empty() => r,
637            _ => {
638                self.set_status_message("No compliance results to export");
639                return;
640            }
641        };
642
643        let result = export_compliance(
644            format,
645            results,
646            self.compliance_state.selected_standard,
647            None,
648            self.export_template.as_deref(),
649        );
650        if result.success {
651            self.set_status_message(result.message);
652            self.status_sticky = true;
653        } else {
654            self.set_status_message(format!("Export failed: {}", result.message));
655        }
656    }
657
658    /// Navigate back using breadcrumb history.
659    pub fn go_back(&mut self) -> bool {
660        if let Some(breadcrumb) = self.navigation_ctx.pop_breadcrumb() {
661            self.active_tab = breadcrumb.tab;
662            // Restore selection index based on tab
663            match breadcrumb.tab {
664                ViewTab::Vulnerabilities => {
665                    self.vuln_state.selected = breadcrumb.selection_index;
666                }
667                ViewTab::Licenses => {
668                    self.license_state.selected = breadcrumb.selection_index;
669                }
670                ViewTab::Dependencies => {
671                    self.dependency_state.selected = breadcrumb.selection_index;
672                }
673                ViewTab::Tree => {
674                    self.tree_state.selected = breadcrumb.selection_index;
675                }
676                ViewTab::Source => {
677                    self.source_state.selected = breadcrumb.selection_index;
678                }
679                _ => {}
680            }
681            self.focus_panel = FocusPanel::Left;
682            true
683        } else {
684            false
685        }
686    }
687
688    /// Handle navigation in current view.
689    pub fn navigate_up(&mut self) {
690        match self.active_tab {
691            ViewTab::Tree => self.tree_state.select_prev(),
692            ViewTab::Vulnerabilities => self.vuln_state.select_prev(),
693            ViewTab::Licenses => self.license_state.select_prev(),
694            ViewTab::Dependencies => self.dependency_state.select_prev(),
695            ViewTab::Quality => self.quality_state.select_prev(),
696            ViewTab::Compliance => self.compliance_state.select_prev(),
697            ViewTab::Source => self.source_state.select_prev(),
698            ViewTab::Overview => {} // Overview has no list navigation
699        }
700    }
701
702    /// Handle navigation in current view.
703    pub fn navigate_down(&mut self) {
704        match self.active_tab {
705            ViewTab::Tree => self.tree_state.select_next(),
706            ViewTab::Vulnerabilities => self.vuln_state.select_next(),
707            ViewTab::Licenses => self.license_state.select_next(),
708            ViewTab::Dependencies => self.dependency_state.select_next(),
709            ViewTab::Quality => self.quality_state.select_next(),
710            ViewTab::Compliance => {
711                self.ensure_compliance_results();
712                let max = self.filtered_compliance_violation_count();
713                self.compliance_state.select_next(max);
714            }
715            ViewTab::Source => self.source_state.select_next(),
716            ViewTab::Overview => {} // Overview has no list navigation
717        }
718    }
719
720    /// Count compliance violations that pass the current severity filter.
721    pub(crate) fn filtered_compliance_violation_count(&self) -> usize {
722        self.compliance_results
723            .as_ref()
724            .and_then(|r| r.get(self.compliance_state.selected_standard))
725            .map_or(0, |r| {
726                r.violations
727                    .iter()
728                    .filter(|v| self.compliance_state.severity_filter.matches(v.severity))
729                    .count()
730            })
731    }
732
733    /// Page up - move up by page size.
734    pub fn page_up(&mut self) {
735        use crate::tui::constants::PAGE_SIZE;
736        if self.active_tab == ViewTab::Source {
737            self.source_state.page_up();
738        } else {
739            for _ in 0..PAGE_SIZE {
740                self.navigate_up();
741            }
742        }
743    }
744
745    /// Page down - move down by page size.
746    pub fn page_down(&mut self) {
747        use crate::tui::constants::PAGE_SIZE;
748        if self.active_tab == ViewTab::Source {
749            self.source_state.page_down();
750        } else {
751            for _ in 0..PAGE_SIZE {
752                self.navigate_down();
753            }
754        }
755    }
756
757    /// Go to first item in current view.
758    pub const fn go_first(&mut self) {
759        match self.active_tab {
760            ViewTab::Tree => self.tree_state.select_first(),
761            ViewTab::Vulnerabilities => self.vuln_state.selected = 0,
762            ViewTab::Licenses => self.license_state.selected = 0,
763            ViewTab::Dependencies => self.dependency_state.selected = 0,
764            ViewTab::Quality => self.quality_state.scroll_offset = 0,
765            ViewTab::Compliance => self.compliance_state.selected_violation = 0,
766            ViewTab::Source => self.source_state.select_first(),
767            ViewTab::Overview => {}
768        }
769    }
770
771    /// Go to last item in current view.
772    pub fn go_last(&mut self) {
773        match self.active_tab {
774            ViewTab::Tree => self.tree_state.select_last(),
775            ViewTab::Vulnerabilities => {
776                self.vuln_state.selected = self.vuln_state.total.saturating_sub(1);
777            }
778            ViewTab::Licenses => {
779                self.license_state.selected = self.license_state.total.saturating_sub(1);
780            }
781            ViewTab::Dependencies => {
782                self.dependency_state.selected = self.dependency_state.total.saturating_sub(1);
783            }
784            ViewTab::Quality => {
785                self.quality_state.scroll_offset =
786                    self.quality_state.total_recommendations.saturating_sub(1);
787            }
788            ViewTab::Compliance => {
789                self.ensure_compliance_results();
790                let max = self.filtered_compliance_violation_count();
791                self.compliance_state.selected_violation = max.saturating_sub(1);
792            }
793            ViewTab::Source => self.source_state.select_last(),
794            ViewTab::Overview => {}
795        }
796    }
797
798    /// Handle enter/select action.
799    pub fn handle_enter(&mut self) {
800        match self.active_tab {
801            ViewTab::Tree => {
802                // Toggle expand or select component
803                if let Some(node) = self.get_selected_tree_node() {
804                    match node {
805                        SelectedTreeNode::Group(id) => {
806                            self.tree_state.toggle_expand(&id);
807                        }
808                        SelectedTreeNode::Component(id) => {
809                            self.selected_component = Some(id);
810                            self.focus_panel = FocusPanel::Right;
811                            self.component_tab = ComponentDetailTab::Overview;
812                        }
813                    }
814                }
815            }
816            ViewTab::Vulnerabilities => {
817                // In grouped mode, check if we're on a group header
818                if self.vuln_state.group_by != VulnGroupBy::Flat
819                    && let Some(cache) = &self.vuln_state.cached_data
820                {
821                    let items = super::views::build_display_items(
822                        &cache.vulns,
823                        &self.vuln_state.group_by,
824                        &self.vuln_state.expanded_groups,
825                    );
826                    if let Some(item) = items.get(self.vuln_state.selected) {
827                        match item {
828                            super::views::VulnDisplayItem::GroupHeader { label, .. } => {
829                                let label = label.clone();
830                                self.vuln_state.toggle_vuln_group(&label);
831                                return;
832                            }
833                            super::views::VulnDisplayItem::Vuln(_) => {
834                                // Fall through to normal navigation
835                            }
836                        }
837                    }
838                }
839                // Select vulnerability's component - push breadcrumb for back navigation
840                if let Some((comp_id, vuln)) = self.vuln_state.get_selected(&self.sbom) {
841                    // Push breadcrumb so we can go back
842                    self.navigation_ctx.push_breadcrumb(
843                        ViewTab::Vulnerabilities,
844                        vuln.id.clone(),
845                        self.vuln_state.selected,
846                    );
847                    self.selected_component = Some(comp_id);
848                    self.component_tab = ComponentDetailTab::Overview;
849                    self.active_tab = ViewTab::Tree;
850                }
851            }
852            ViewTab::Dependencies => {
853                // Toggle expand on the selected dependency node
854                // Node ID is calculated from the flattened view
855                if let Some(node_id) = self.get_selected_dependency_node_id() {
856                    self.dependency_state.toggle_expand(&node_id);
857                }
858            }
859            ViewTab::Compliance => {
860                // Toggle violation detail overlay
861                self.ensure_compliance_results();
862                let idx = self.compliance_state.selected_standard;
863                let has_violations = self
864                    .compliance_results
865                    .as_ref()
866                    .and_then(|r| r.get(idx))
867                    .is_some_and(|r| !r.violations.is_empty());
868                if has_violations {
869                    self.compliance_state.show_detail = !self.compliance_state.show_detail;
870                }
871            }
872            ViewTab::Source => {
873                // Toggle expand/collapse in tree mode
874                if self.source_state.view_mode == crate::tui::app_states::SourceViewMode::Tree
875                    && let Some(ref tree) = self.source_state.json_tree
876                {
877                    let mut items = Vec::new();
878                    crate::tui::shared::source::flatten_json_tree(
879                        tree,
880                        "",
881                        0,
882                        &self.source_state.expanded,
883                        &mut items,
884                        true,
885                        &[],
886                        self.source_state.sort_mode,
887                    );
888                    if let Some(item) = items.get(self.source_state.selected)
889                        && item.is_expandable
890                    {
891                        let node_id = item.node_id.clone();
892                        self.source_state.toggle_expand(&node_id);
893                    }
894                }
895            }
896            ViewTab::Quality => {
897                if self.quality_state.view_mode == QualityViewMode::Summary {
898                    // Jump to Recommendations view preserving selection
899                    self.quality_state.view_mode = QualityViewMode::Recommendations;
900                }
901            }
902            ViewTab::Licenses | ViewTab::Overview => {}
903        }
904    }
905
906    /// Jump the source panel to the section selected in the map.
907    pub fn handle_source_map_enter(&mut self) {
908        // Build sections from JSON tree root children
909        let Some(tree) = &self.source_state.json_tree else {
910            return;
911        };
912        let Some(children) = tree.children() else {
913            return;
914        };
915
916        // Find the Nth expandable section
917        let expandable: Vec<_> = children.iter().filter(|c| c.is_expandable()).collect();
918
919        let target = match expandable.get(self.source_state.map_selected) {
920            Some(t) => *t,
921            None => return,
922        };
923
924        let target_id = target.node_id("root");
925
926        match self.source_state.view_mode {
927            crate::tui::app_states::SourceViewMode::Tree => {
928                // Ensure section is expanded
929                if !self.source_state.expanded.contains(&target_id) {
930                    self.source_state.expanded.insert(target_id.clone());
931                }
932                // Flatten and find the target node's index
933                let mut items = Vec::new();
934                crate::tui::shared::source::flatten_json_tree(
935                    tree,
936                    "",
937                    0,
938                    &self.source_state.expanded,
939                    &mut items,
940                    true,
941                    &[],
942                    self.source_state.sort_mode,
943                );
944                if let Some(idx) = items.iter().position(|item| item.node_id == target_id) {
945                    self.source_state.selected = idx;
946                    self.source_state.scroll_offset = idx.saturating_sub(2);
947                }
948            }
949            crate::tui::app_states::SourceViewMode::Raw => {
950                // Find the line that starts this section
951                let key = match target {
952                    crate::tui::app_states::source::JsonTreeNode::Object { key, .. }
953                    | crate::tui::app_states::source::JsonTreeNode::Array { key, .. }
954                    | crate::tui::app_states::source::JsonTreeNode::Leaf { key, .. } => key.clone(),
955                };
956                // Search raw_lines for the top-level key
957                for (i, line) in self.source_state.raw_lines.iter().enumerate() {
958                    let search = format!("\"{key}\":");
959                    if line.contains(&search) && line.starts_with("  ") && !line.starts_with("    ")
960                    {
961                        self.source_state.selected = i;
962                        self.source_state.scroll_offset = i.saturating_sub(2);
963                        break;
964                    }
965                }
966            }
967        }
968
969        // Switch focus back to source panel after jumping
970        self.focus_panel = FocusPanel::Left;
971    }
972
973    /// Get the component ID currently shown in the source map context footer.
974    /// Returns the canonical ID value string if inside the "components" section.
975    #[must_use]
976    pub fn get_map_context_component_id(&self) -> Option<String> {
977        let tree = self.source_state.json_tree.as_ref()?;
978        let mut items = Vec::new();
979        crate::tui::shared::source::flatten_json_tree(
980            tree,
981            "",
982            0,
983            &self.source_state.expanded,
984            &mut items,
985            true,
986            &[],
987            self.source_state.sort_mode,
988        );
989        let item = items.get(self.source_state.selected)?;
990        let parts: Vec<&str> = item.node_id.split('.').collect();
991        if parts.len() < 3 || parts[1] != "components" {
992            return None;
993        }
994        let idx_part = parts[2];
995        if idx_part.starts_with('[') && idx_part.ends_with(']') {
996            let idx: usize = idx_part[1..idx_part.len() - 1].parse().ok()?;
997            let (canon_id, _) = self.sbom.components.iter().nth(idx)?;
998            Some(canon_id.value().to_string())
999        } else {
1000            None
1001        }
1002    }
1003
1004    /// Get the currently selected dependency node ID (if any).
1005    #[must_use]
1006    pub fn get_selected_dependency_node_id(&self) -> Option<String> {
1007        // Build the flattened list of visible dependency nodes
1008        let mut visible_nodes = Vec::new();
1009        self.collect_visible_dependency_nodes(&mut visible_nodes);
1010        visible_nodes.get(self.dependency_state.selected).cloned()
1011    }
1012
1013    /// Collect visible dependency nodes in tree order.
1014    fn collect_visible_dependency_nodes(&self, nodes: &mut Vec<String>) {
1015        // Build edges map from sbom.edges
1016        let mut edges: std::collections::HashMap<String, Vec<String>> =
1017            std::collections::HashMap::new();
1018        let mut has_parent: std::collections::HashSet<String> = std::collections::HashSet::new();
1019        let mut all_nodes: std::collections::HashSet<String> = std::collections::HashSet::new();
1020
1021        for (id, _) in &self.sbom.components {
1022            all_nodes.insert(id.value().to_string());
1023        }
1024
1025        for edge in &self.sbom.edges {
1026            let from = edge.from.value().to_string();
1027            let to = edge.to.value().to_string();
1028            if all_nodes.contains(&from) && all_nodes.contains(&to) {
1029                edges.entry(from).or_default().push(to.clone());
1030                has_parent.insert(to);
1031            }
1032        }
1033
1034        // Find roots, sorted for stable ordering matching render traversal
1035        let mut roots: Vec<_> = all_nodes
1036            .iter()
1037            .filter(|id| !has_parent.contains(*id))
1038            .cloned()
1039            .collect();
1040        roots.sort();
1041
1042        // Traverse and collect visible nodes
1043        for root in roots {
1044            self.collect_dep_nodes_recursive(
1045                &root,
1046                &edges,
1047                nodes,
1048                &mut std::collections::HashSet::new(),
1049            );
1050        }
1051    }
1052
1053    fn collect_dep_nodes_recursive(
1054        &self,
1055        node_id: &str,
1056        edges: &std::collections::HashMap<String, Vec<String>>,
1057        nodes: &mut Vec<String>,
1058        visited: &mut std::collections::HashSet<String>,
1059    ) {
1060        if visited.contains(node_id) {
1061            return;
1062        }
1063        visited.insert(node_id.to_string());
1064        nodes.push(node_id.to_string());
1065
1066        if self.dependency_state.is_expanded(node_id)
1067            && let Some(children) = edges.get(node_id)
1068        {
1069            for child in children {
1070                self.collect_dep_nodes_recursive(child, edges, nodes, visited);
1071            }
1072        }
1073    }
1074
1075    /// Get the currently selected tree node.
1076    fn get_selected_tree_node(&self) -> Option<SelectedTreeNode> {
1077        let nodes = self.build_tree_nodes();
1078        let mut flat_items = Vec::new();
1079        flatten_tree_for_selection(&nodes, &self.tree_state, &mut flat_items);
1080
1081        flat_items.get(self.tree_state.selected).cloned()
1082    }
1083
1084    /// Build tree nodes based on current grouping.
1085    #[must_use]
1086    pub fn build_tree_nodes(&self) -> Vec<crate::tui::widgets::TreeNode> {
1087        match self.tree_group_by {
1088            TreeGroupBy::Ecosystem => self.build_ecosystem_tree(),
1089            TreeGroupBy::License => self.build_license_tree(),
1090            TreeGroupBy::VulnStatus => self.build_vuln_status_tree(),
1091            TreeGroupBy::ComponentType => self.build_type_tree(),
1092            TreeGroupBy::Flat => self.build_flat_tree(),
1093        }
1094    }
1095
1096    fn build_ecosystem_tree(&self) -> Vec<crate::tui::widgets::TreeNode> {
1097        use crate::tui::widgets::TreeNode;
1098
1099        let mut ecosystem_map: HashMap<String, Vec<&Component>> = HashMap::new();
1100
1101        for comp in self.sbom.components.values() {
1102            if !self.matches_filter(comp) {
1103                continue;
1104            }
1105            let eco = comp
1106                .ecosystem
1107                .as_ref()
1108                .map_or_else(|| "Unknown".to_string(), std::string::ToString::to_string);
1109            ecosystem_map.entry(eco).or_default().push(comp);
1110        }
1111
1112        let mut groups: Vec<TreeNode> = ecosystem_map
1113            .into_iter()
1114            .map(|(eco, mut components)| {
1115                let vuln_count: usize = components.iter().map(|c| c.vulnerabilities.len()).sum();
1116                components.sort_by(|a, b| a.name.cmp(&b.name));
1117                let children: Vec<TreeNode> = components
1118                    .into_iter()
1119                    .map(|c| TreeNode::Component {
1120                        id: c.canonical_id.value().to_string(),
1121                        name: c.name.clone(),
1122                        version: c.version.clone(),
1123                        vuln_count: c.vulnerabilities.len(),
1124                        max_severity: get_max_severity(c),
1125                        component_type: Some(
1126                            crate::tui::widgets::detect_component_type(&c.name).to_string(),
1127                        ),
1128                        ecosystem: c.ecosystem.as_ref().map(std::string::ToString::to_string),
1129                        is_bookmarked: self.bookmarked.contains(c.canonical_id.value()),
1130                    })
1131                    .collect();
1132                let count = children.len();
1133                TreeNode::Group {
1134                    id: format!("eco:{eco}"),
1135                    label: eco,
1136                    children,
1137                    item_count: count,
1138                    vuln_count,
1139                }
1140            })
1141            .collect();
1142
1143        groups.sort_by(|a, b| match (a, b) {
1144            (
1145                TreeNode::Group {
1146                    item_count: ac,
1147                    label: al,
1148                    ..
1149                },
1150                TreeNode::Group {
1151                    item_count: bc,
1152                    label: bl,
1153                    ..
1154                },
1155            ) => bc.cmp(ac).then_with(|| al.cmp(bl)),
1156            _ => std::cmp::Ordering::Equal,
1157        });
1158
1159        groups
1160    }
1161
1162    fn build_license_tree(&self) -> Vec<crate::tui::widgets::TreeNode> {
1163        use crate::tui::widgets::TreeNode;
1164
1165        let mut license_map: HashMap<String, Vec<&Component>> = HashMap::new();
1166
1167        for comp in self.sbom.components.values() {
1168            if !self.matches_filter(comp) {
1169                continue;
1170            }
1171            let license = if comp.licenses.declared.is_empty() {
1172                "Unknown".to_string()
1173            } else {
1174                comp.licenses.declared[0].expression.clone()
1175            };
1176            license_map.entry(license).or_default().push(comp);
1177        }
1178
1179        let mut groups: Vec<TreeNode> = license_map
1180            .into_iter()
1181            .map(|(license, mut components)| {
1182                let vuln_count: usize = components.iter().map(|c| c.vulnerabilities.len()).sum();
1183                components.sort_by(|a, b| a.name.cmp(&b.name));
1184                let children: Vec<TreeNode> = components
1185                    .into_iter()
1186                    .map(|c| TreeNode::Component {
1187                        id: c.canonical_id.value().to_string(),
1188                        name: c.name.clone(),
1189                        version: c.version.clone(),
1190                        vuln_count: c.vulnerabilities.len(),
1191                        max_severity: get_max_severity(c),
1192                        component_type: Some(
1193                            crate::tui::widgets::detect_component_type(&c.name).to_string(),
1194                        ),
1195                        ecosystem: c.ecosystem.as_ref().map(std::string::ToString::to_string),
1196                        is_bookmarked: self.bookmarked.contains(c.canonical_id.value()),
1197                    })
1198                    .collect();
1199                let count = children.len();
1200                TreeNode::Group {
1201                    id: format!("lic:{license}"),
1202                    label: license,
1203                    children,
1204                    item_count: count,
1205                    vuln_count,
1206                }
1207            })
1208            .collect();
1209
1210        groups.sort_by(|a, b| match (a, b) {
1211            (
1212                TreeNode::Group {
1213                    item_count: ac,
1214                    label: al,
1215                    ..
1216                },
1217                TreeNode::Group {
1218                    item_count: bc,
1219                    label: bl,
1220                    ..
1221                },
1222            ) => bc.cmp(ac).then_with(|| al.cmp(bl)),
1223            _ => std::cmp::Ordering::Equal,
1224        });
1225
1226        groups
1227    }
1228
1229    fn build_vuln_status_tree(&self) -> Vec<crate::tui::widgets::TreeNode> {
1230        use super::severity::severity_category;
1231        use crate::tui::widgets::TreeNode;
1232
1233        let mut critical_comps = Vec::new();
1234        let mut high_comps = Vec::new();
1235        let mut other_vuln_comps = Vec::new();
1236        let mut clean_comps = Vec::new();
1237
1238        for comp in self.sbom.components.values() {
1239            if !self.matches_filter(comp) {
1240                continue;
1241            }
1242
1243            match severity_category(&comp.vulnerabilities) {
1244                "critical" => critical_comps.push(comp),
1245                "high" => high_comps.push(comp),
1246                "clean" => clean_comps.push(comp),
1247                _ => other_vuln_comps.push(comp),
1248            }
1249        }
1250
1251        let build_group = |label: &str,
1252                           id: &str,
1253                           comps: Vec<&Component>,
1254                           bookmarked: &HashSet<String>|
1255         -> TreeNode {
1256            let vuln_count: usize = comps.iter().map(|c| c.vulnerabilities.len()).sum();
1257            let children: Vec<TreeNode> = comps
1258                .into_iter()
1259                .map(|c| TreeNode::Component {
1260                    id: c.canonical_id.value().to_string(),
1261                    name: c.name.clone(),
1262                    version: c.version.clone(),
1263                    vuln_count: c.vulnerabilities.len(),
1264                    max_severity: get_max_severity(c),
1265                    component_type: Some(
1266                        crate::tui::widgets::detect_component_type(&c.name).to_string(),
1267                    ),
1268                    ecosystem: c.ecosystem.as_ref().map(std::string::ToString::to_string),
1269                    is_bookmarked: bookmarked.contains(c.canonical_id.value()),
1270                })
1271                .collect();
1272            let count = children.len();
1273            TreeNode::Group {
1274                id: id.to_string(),
1275                label: label.to_string(),
1276                children,
1277                item_count: count,
1278                vuln_count,
1279            }
1280        };
1281
1282        let mut groups = Vec::new();
1283        if !critical_comps.is_empty() {
1284            groups.push(build_group(
1285                "Critical",
1286                "vuln:critical",
1287                critical_comps,
1288                &self.bookmarked,
1289            ));
1290        }
1291        if !high_comps.is_empty() {
1292            groups.push(build_group(
1293                "High",
1294                "vuln:high",
1295                high_comps,
1296                &self.bookmarked,
1297            ));
1298        }
1299        if !other_vuln_comps.is_empty() {
1300            groups.push(build_group(
1301                "Other Vulnerabilities",
1302                "vuln:other",
1303                other_vuln_comps,
1304                &self.bookmarked,
1305            ));
1306        }
1307        if !clean_comps.is_empty() {
1308            groups.push(build_group(
1309                "No Vulnerabilities",
1310                "vuln:clean",
1311                clean_comps,
1312                &self.bookmarked,
1313            ));
1314        }
1315
1316        groups
1317    }
1318
1319    fn build_type_tree(&self) -> Vec<crate::tui::widgets::TreeNode> {
1320        use crate::tui::widgets::TreeNode;
1321
1322        let mut type_map: HashMap<&'static str, Vec<&Component>> = HashMap::new();
1323
1324        for comp in self.sbom.components.values() {
1325            if !self.matches_filter(comp) {
1326                continue;
1327            }
1328            let comp_type = crate::tui::widgets::detect_component_type(&comp.name);
1329            type_map.entry(comp_type).or_default().push(comp);
1330        }
1331
1332        // Define type order and labels
1333        let type_order = vec![
1334            ("lib", "Libraries"),
1335            ("bin", "Binaries"),
1336            ("cert", "Certificates"),
1337            ("fs", "Filesystems"),
1338            ("file", "Other Files"),
1339        ];
1340
1341        let mut groups = Vec::new();
1342        for (type_key, type_label) in type_order {
1343            if let Some(mut components) = type_map.remove(type_key) {
1344                if components.is_empty() {
1345                    continue;
1346                }
1347                let vuln_count: usize = components.iter().map(|c| c.vulnerabilities.len()).sum();
1348                components.sort_by(|a, b| a.name.cmp(&b.name));
1349                let children: Vec<TreeNode> = components
1350                    .into_iter()
1351                    .map(|c| TreeNode::Component {
1352                        id: c.canonical_id.value().to_string(),
1353                        name: c.name.clone(),
1354                        version: c.version.clone(),
1355                        vuln_count: c.vulnerabilities.len(),
1356                        max_severity: get_max_severity(c),
1357                        component_type: Some(type_key.to_string()),
1358                        ecosystem: c.ecosystem.as_ref().map(std::string::ToString::to_string),
1359                        is_bookmarked: self.bookmarked.contains(c.canonical_id.value()),
1360                    })
1361                    .collect();
1362                let count = children.len();
1363                groups.push(TreeNode::Group {
1364                    id: format!("type:{type_key}"),
1365                    label: type_label.to_string(),
1366                    children,
1367                    item_count: count,
1368                    vuln_count,
1369                });
1370            }
1371        }
1372
1373        groups
1374    }
1375
1376    fn build_flat_tree(&self) -> Vec<crate::tui::widgets::TreeNode> {
1377        use crate::tui::widgets::TreeNode;
1378
1379        self.sbom
1380            .components
1381            .values()
1382            .filter(|c| self.matches_filter(c))
1383            .map(|c| TreeNode::Component {
1384                id: c.canonical_id.value().to_string(),
1385                name: c.name.clone(),
1386                version: c.version.clone(),
1387                vuln_count: c.vulnerabilities.len(),
1388                max_severity: get_max_severity(c),
1389                component_type: Some(
1390                    crate::tui::widgets::detect_component_type(&c.name).to_string(),
1391                ),
1392                ecosystem: c.ecosystem.as_ref().map(std::string::ToString::to_string),
1393                is_bookmarked: self.bookmarked.contains(c.canonical_id.value()),
1394            })
1395            .collect()
1396    }
1397
1398    fn matches_filter(&self, comp: &Component) -> bool {
1399        use super::severity::severity_matches;
1400
1401        // Check tree filter first
1402        let passes_filter = match self.tree_filter {
1403            TreeFilter::All => true,
1404            TreeFilter::HasVulnerabilities => !comp.vulnerabilities.is_empty(),
1405            TreeFilter::Critical => comp
1406                .vulnerabilities
1407                .iter()
1408                .any(|v| severity_matches(v.severity.as_ref(), "critical")),
1409            TreeFilter::Bookmarked => self.bookmarked.contains(comp.canonical_id.value()),
1410        };
1411
1412        if !passes_filter {
1413            return false;
1414        }
1415
1416        // Check search query
1417        if self.tree_search_query.is_empty() {
1418            return true;
1419        }
1420
1421        let query_lower = self.tree_search_query.to_lowercase();
1422        let name_lower = comp.name.to_lowercase();
1423
1424        // Match against name
1425        if name_lower.contains(&query_lower) {
1426            return true;
1427        }
1428
1429        // Match against version
1430        if let Some(ref version) = comp.version
1431            && version.to_lowercase().contains(&query_lower)
1432        {
1433            return true;
1434        }
1435
1436        // Match against ecosystem
1437        if let Some(ref eco) = comp.ecosystem
1438            && eco.to_string().to_lowercase().contains(&query_lower)
1439        {
1440            return true;
1441        }
1442
1443        false
1444    }
1445
1446    fn tree_group_id_for_component(&self, comp: &Component) -> Option<String> {
1447        match self.tree_group_by {
1448            TreeGroupBy::Ecosystem => {
1449                let eco = comp
1450                    .ecosystem
1451                    .as_ref()
1452                    .map_or_else(|| "Unknown".to_string(), std::string::ToString::to_string);
1453                Some(format!("eco:{eco}"))
1454            }
1455            TreeGroupBy::License => {
1456                let license = if comp.licenses.declared.is_empty() {
1457                    "Unknown".to_string()
1458                } else {
1459                    comp.licenses.declared[0].expression.clone()
1460                };
1461                Some(format!("lic:{license}"))
1462            }
1463            TreeGroupBy::VulnStatus => {
1464                use super::severity::severity_category;
1465                let group = match severity_category(&comp.vulnerabilities) {
1466                    "critical" => "vuln:critical",
1467                    "high" => "vuln:high",
1468                    "clean" => "vuln:clean",
1469                    _ => "vuln:other",
1470                };
1471                Some(group.to_string())
1472            }
1473            TreeGroupBy::ComponentType => {
1474                let comp_type = crate::tui::widgets::detect_component_type(&comp.name);
1475                Some(format!("type:{comp_type}"))
1476            }
1477            TreeGroupBy::Flat => None,
1478        }
1479    }
1480}
1481
1482/// Get the maximum severity level from a component's vulnerabilities
1483fn get_max_severity(comp: &Component) -> Option<String> {
1484    super::severity::max_severity_from_vulns(&comp.vulnerabilities)
1485}
1486
1487/// Selected tree node for navigation.
1488#[derive(Debug, Clone)]
1489enum SelectedTreeNode {
1490    Group(String),
1491    Component(String),
1492}
1493
1494fn flatten_tree_for_selection(
1495    nodes: &[crate::tui::widgets::TreeNode],
1496    state: &TreeState,
1497    items: &mut Vec<SelectedTreeNode>,
1498) {
1499    use crate::tui::widgets::TreeNode;
1500
1501    for node in nodes {
1502        match node {
1503            TreeNode::Group { id, children, .. } => {
1504                items.push(SelectedTreeNode::Group(id.clone()));
1505                if state.is_expanded(id) {
1506                    flatten_tree_for_selection(children, state, items);
1507                }
1508            }
1509            TreeNode::Component { id, .. } => {
1510                items.push(SelectedTreeNode::Component(id.clone()));
1511            }
1512        }
1513    }
1514}
1515
1516/// View tabs for the single SBOM viewer.
1517#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1518pub enum ViewTab {
1519    /// High-level SBOM overview with stats
1520    Overview,
1521    /// Hierarchical component tree
1522    Tree,
1523    /// Vulnerability explorer
1524    Vulnerabilities,
1525    /// License analysis view
1526    Licenses,
1527    /// Dependency graph view
1528    Dependencies,
1529    /// Quality score view
1530    Quality,
1531    /// Compliance validation view
1532    Compliance,
1533    /// Original SBOM source viewer
1534    Source,
1535}
1536
1537impl ViewTab {
1538    #[must_use]
1539    pub const fn title(&self) -> &'static str {
1540        match self {
1541            Self::Overview => "Overview",
1542            Self::Tree => "Components",
1543            Self::Vulnerabilities => "Vulnerabilities",
1544            Self::Licenses => "Licenses",
1545            Self::Dependencies => "Dependencies",
1546            Self::Quality => "Quality",
1547            Self::Compliance => "Compliance",
1548            Self::Source => "Source",
1549        }
1550    }
1551
1552    #[must_use]
1553    pub const fn shortcut(&self) -> &'static str {
1554        match self {
1555            Self::Overview => "1",
1556            Self::Tree => "2",
1557            Self::Vulnerabilities => "3",
1558            Self::Licenses => "4",
1559            Self::Dependencies => "5",
1560            Self::Quality => "6",
1561            Self::Compliance => "7",
1562            Self::Source => "8",
1563        }
1564    }
1565
1566    /// Stable string identifier for persistence.
1567    #[must_use]
1568    pub const fn as_str(&self) -> &'static str {
1569        match self {
1570            Self::Overview => "overview",
1571            Self::Tree => "tree",
1572            Self::Vulnerabilities => "vulnerabilities",
1573            Self::Licenses => "licenses",
1574            Self::Dependencies => "dependencies",
1575            Self::Quality => "quality",
1576            Self::Compliance => "compliance",
1577            Self::Source => "source",
1578        }
1579    }
1580
1581    /// Parse from a persisted string identifier.
1582    #[must_use]
1583    pub fn from_str_opt(s: &str) -> Option<Self> {
1584        match s {
1585            "overview" => Some(Self::Overview),
1586            "tree" => Some(Self::Tree),
1587            "vulnerabilities" => Some(Self::Vulnerabilities),
1588            "licenses" => Some(Self::Licenses),
1589            "dependencies" => Some(Self::Dependencies),
1590            "quality" => Some(Self::Quality),
1591            "compliance" => Some(Self::Compliance),
1592            "source" => Some(Self::Source),
1593            _ => None,
1594        }
1595    }
1596}
1597
1598/// Tree grouping modes.
1599#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1600pub enum TreeGroupBy {
1601    Ecosystem,
1602    License,
1603    VulnStatus,
1604    ComponentType,
1605    Flat,
1606}
1607
1608impl TreeGroupBy {
1609    #[must_use]
1610    pub const fn label(&self) -> &'static str {
1611        match self {
1612            Self::Ecosystem => "Ecosystem",
1613            Self::License => "License",
1614            Self::VulnStatus => "Vuln Status",
1615            Self::ComponentType => "Type",
1616            Self::Flat => "Flat List",
1617        }
1618    }
1619}
1620
1621/// Tree filter options.
1622#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1623pub enum TreeFilter {
1624    All,
1625    HasVulnerabilities,
1626    Critical,
1627    Bookmarked,
1628}
1629
1630impl TreeFilter {
1631    #[must_use]
1632    pub const fn label(&self) -> &'static str {
1633        match self {
1634            Self::All => "All",
1635            Self::HasVulnerabilities => "Has Vulns",
1636            Self::Critical => "Critical",
1637            Self::Bookmarked => "Bookmarked",
1638        }
1639    }
1640}
1641
1642/// Component detail sub-tabs.
1643#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1644pub(crate) enum ComponentDetailTab {
1645    #[default]
1646    Overview,
1647    Identifiers,
1648    Vulnerabilities,
1649    Dependencies,
1650}
1651
1652impl ComponentDetailTab {
1653    pub const fn title(self) -> &'static str {
1654        match self {
1655            Self::Overview => "Overview",
1656            Self::Identifiers => "Identifiers",
1657            Self::Vulnerabilities => "Vulnerabilities",
1658            Self::Dependencies => "Dependencies",
1659        }
1660    }
1661
1662    pub const fn shortcut(self) -> &'static str {
1663        match self {
1664            Self::Overview => "1",
1665            Self::Identifiers => "2",
1666            Self::Vulnerabilities => "3",
1667            Self::Dependencies => "4",
1668        }
1669    }
1670
1671    pub const fn all() -> [Self; 4] {
1672        [
1673            Self::Overview,
1674            Self::Identifiers,
1675            Self::Vulnerabilities,
1676            Self::Dependencies,
1677        ]
1678    }
1679}
1680
1681/// Focus panel (for split views).
1682#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1683pub(crate) enum FocusPanel {
1684    Left,
1685    Right,
1686}
1687
1688/// State for vulnerability explorer.
1689#[derive(Debug, Clone)]
1690pub(crate) struct VulnExplorerState {
1691    pub selected: usize,
1692    pub total: usize,
1693    pub scroll_offset: usize,
1694    pub group_by: VulnGroupBy,
1695    pub sort_by: VulnSortBy,
1696    pub filter_severity: Option<String>,
1697    /// When true, deduplicate vulnerabilities by CVE ID and show affected component count
1698    pub deduplicate: bool,
1699    /// Local search/filter query for vulnerability list
1700    pub search_query: String,
1701    /// Whether search input mode is active
1702    pub search_active: bool,
1703    /// Scroll offset for the detail panel (right side)
1704    pub detail_scroll: u16,
1705    /// Expanded group IDs for grouped view (severity labels or component names)
1706    pub expanded_groups: HashSet<String>,
1707    /// Cache key to detect when we need to rebuild the vulnerability list
1708    cache_key: Option<VulnCacheKey>,
1709    /// Cached vulnerability list for performance (Arc-wrapped for zero-cost cloning)
1710    pub cached_data: Option<super::views::VulnCacheRef>,
1711}
1712
1713/// Cache key for vulnerability list - rebuild when any of these change
1714#[derive(Debug, Clone, PartialEq, Eq)]
1715struct VulnCacheKey {
1716    filter_severity: Option<String>,
1717    deduplicate: bool,
1718    sort_by: VulnSortBy,
1719    search_query: String,
1720}
1721
1722impl VulnExplorerState {
1723    pub fn new() -> Self {
1724        Self {
1725            selected: 0,
1726            total: 0,
1727            scroll_offset: 0,
1728            group_by: VulnGroupBy::Severity,
1729            sort_by: VulnSortBy::Severity,
1730            filter_severity: None,
1731            deduplicate: true,
1732            search_query: String::new(),
1733            search_active: false,
1734            detail_scroll: 0,
1735            expanded_groups: HashSet::new(),
1736            cache_key: None,
1737            cached_data: None,
1738        }
1739    }
1740
1741    /// Get current cache key based on filter settings
1742    fn current_cache_key(&self) -> VulnCacheKey {
1743        VulnCacheKey {
1744            filter_severity: self.filter_severity.clone(),
1745            deduplicate: self.deduplicate,
1746            sort_by: self.sort_by,
1747            search_query: self.search_query.clone(),
1748        }
1749    }
1750
1751    /// Check if cache is valid
1752    pub fn is_cache_valid(&self) -> bool {
1753        self.cache_key.as_ref() == Some(&self.current_cache_key()) && self.cached_data.is_some()
1754    }
1755
1756    /// Store cache with current settings (wraps in Arc for cheap cloning)
1757    pub fn set_cache(&mut self, cache: super::views::VulnCache) {
1758        self.cache_key = Some(self.current_cache_key());
1759        self.cached_data = Some(std::sync::Arc::new(cache));
1760    }
1761
1762    /// Invalidate the cache
1763    pub fn invalidate_cache(&mut self) {
1764        self.cache_key = None;
1765        self.cached_data = None;
1766    }
1767
1768    pub const fn select_next(&mut self) {
1769        if self.total > 0 && self.selected < self.total.saturating_sub(1) {
1770            self.selected += 1;
1771            self.detail_scroll = 0;
1772        }
1773    }
1774
1775    pub const fn select_prev(&mut self) {
1776        if self.selected > 0 {
1777            self.selected -= 1;
1778            self.detail_scroll = 0;
1779        }
1780    }
1781
1782    /// Scroll detail panel down
1783    pub const fn detail_scroll_down(&mut self) {
1784        self.detail_scroll = self.detail_scroll.saturating_add(1);
1785    }
1786
1787    /// Scroll detail panel up
1788    pub const fn detail_scroll_up(&mut self) {
1789        self.detail_scroll = self.detail_scroll.saturating_sub(1);
1790    }
1791
1792    /// Ensure selected index is within bounds
1793    pub const fn clamp_selection(&mut self) {
1794        if self.total == 0 {
1795            self.selected = 0;
1796        } else if self.selected >= self.total {
1797            self.selected = self.total.saturating_sub(1);
1798        }
1799    }
1800
1801    pub fn toggle_group(&mut self) {
1802        self.group_by = match self.group_by {
1803            VulnGroupBy::Severity => VulnGroupBy::Component,
1804            VulnGroupBy::Component => VulnGroupBy::Flat,
1805            VulnGroupBy::Flat => VulnGroupBy::Severity,
1806        };
1807        self.selected = 0;
1808        self.expanded_groups.clear();
1809        self.invalidate_cache();
1810    }
1811
1812    /// Toggle expansion of a vulnerability group header.
1813    pub fn toggle_vuln_group(&mut self, group_id: &str) {
1814        if self.expanded_groups.contains(group_id) {
1815            self.expanded_groups.remove(group_id);
1816        } else {
1817            self.expanded_groups.insert(group_id.to_string());
1818        }
1819    }
1820
1821    pub fn toggle_filter(&mut self) {
1822        self.filter_severity = match &self.filter_severity {
1823            None => Some("critical".to_string()),
1824            Some(s) if s == "critical" => Some("high".to_string()),
1825            Some(s) if s == "high" => Some("medium".to_string()),
1826            Some(s) if s == "medium" => Some("low".to_string()),
1827            Some(s) if s == "low" => Some("unknown".to_string()),
1828            Some(s) if s == "unknown" => None,
1829            _ => None,
1830        };
1831        self.selected = 0;
1832        self.invalidate_cache();
1833    }
1834
1835    pub fn toggle_sort(&mut self) {
1836        self.sort_by = self.sort_by.next();
1837        self.selected = 0;
1838        self.invalidate_cache();
1839    }
1840
1841    pub fn toggle_deduplicate(&mut self) {
1842        self.deduplicate = !self.deduplicate;
1843        self.selected = 0;
1844        self.invalidate_cache();
1845    }
1846
1847    /// Start local search mode for vulnerability list
1848    pub fn start_vuln_search(&mut self) {
1849        self.search_active = true;
1850        self.search_query.clear();
1851    }
1852
1853    /// Stop search mode (keep query for filtering)
1854    pub const fn stop_vuln_search(&mut self) {
1855        self.search_active = false;
1856    }
1857
1858    /// Clear search completely
1859    pub fn clear_vuln_search(&mut self) {
1860        self.search_active = false;
1861        self.search_query.clear();
1862        self.selected = 0;
1863        self.invalidate_cache();
1864    }
1865
1866    /// Push a character to search query
1867    pub fn search_push(&mut self, c: char) {
1868        self.search_query.push(c);
1869        self.selected = 0;
1870        self.invalidate_cache();
1871    }
1872
1873    /// Pop a character from search query
1874    pub fn search_pop(&mut self) {
1875        self.search_query.pop();
1876        self.selected = 0;
1877        self.invalidate_cache();
1878    }
1879
1880    /// Get the selected vulnerability.
1881    pub fn get_selected<'a>(
1882        &self,
1883        sbom: &'a NormalizedSbom,
1884    ) -> Option<(String, &'a VulnerabilityRef)> {
1885        let mut idx = 0;
1886        for (comp_id, comp) in &sbom.components {
1887            for vuln in &comp.vulnerabilities {
1888                if let Some(ref filter) = self.filter_severity {
1889                    let sev = vuln.severity.as_ref().map(|s| s.to_string().to_lowercase());
1890                    if sev.as_deref() != Some(filter) {
1891                        continue;
1892                    }
1893                }
1894                if idx == self.selected {
1895                    return Some((comp_id.value().to_string(), vuln));
1896                }
1897                idx += 1;
1898            }
1899        }
1900        None
1901    }
1902}
1903
1904impl Default for VulnExplorerState {
1905    fn default() -> Self {
1906        Self::new()
1907    }
1908}
1909
1910impl ListNavigation for VulnExplorerState {
1911    fn selected(&self) -> usize {
1912        self.selected
1913    }
1914
1915    fn set_selected(&mut self, idx: usize) {
1916        self.selected = idx;
1917    }
1918
1919    fn total(&self) -> usize {
1920        self.total
1921    }
1922
1923    fn set_total(&mut self, total: usize) {
1924        self.total = total;
1925    }
1926}
1927
1928/// View mode for quality panel
1929#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1930pub(crate) enum QualityViewMode {
1931    #[default]
1932    Summary,
1933    Breakdown,
1934    Metrics,
1935    Recommendations,
1936}
1937
1938/// Quality view state
1939pub(crate) struct QualityViewState {
1940    pub view_mode: QualityViewMode,
1941    pub selected_recommendation: usize,
1942    pub total_recommendations: usize,
1943    pub scroll_offset: usize,
1944}
1945
1946impl QualityViewState {
1947    pub const fn new(total_recommendations: usize) -> Self {
1948        Self {
1949            view_mode: QualityViewMode::Summary,
1950            selected_recommendation: 0,
1951            total_recommendations,
1952            scroll_offset: 0,
1953        }
1954    }
1955
1956    pub const fn toggle_view(&mut self) {
1957        self.view_mode = match self.view_mode {
1958            QualityViewMode::Summary => QualityViewMode::Breakdown,
1959            QualityViewMode::Breakdown => QualityViewMode::Metrics,
1960            QualityViewMode::Metrics => QualityViewMode::Recommendations,
1961            QualityViewMode::Recommendations => QualityViewMode::Summary,
1962        };
1963        self.selected_recommendation = 0;
1964        self.scroll_offset = 0;
1965    }
1966}
1967
1968impl ListNavigation for QualityViewState {
1969    fn selected(&self) -> usize {
1970        self.selected_recommendation
1971    }
1972
1973    fn set_selected(&mut self, idx: usize) {
1974        self.selected_recommendation = idx;
1975    }
1976
1977    fn total(&self) -> usize {
1978        self.total_recommendations
1979    }
1980
1981    fn set_total(&mut self, total: usize) {
1982        self.total_recommendations = total;
1983    }
1984}
1985
1986impl Default for QualityViewState {
1987    fn default() -> Self {
1988        Self::new(0)
1989    }
1990}
1991
1992/// Vulnerability grouping modes.
1993#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1994pub(crate) enum VulnGroupBy {
1995    Severity,
1996    Component,
1997    Flat,
1998}
1999
2000/// Vulnerability sorting modes.
2001#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2002pub(crate) enum VulnSortBy {
2003    Severity,
2004    Cvss,
2005    CveId,
2006    Component,
2007}
2008
2009impl VulnSortBy {
2010    pub const fn next(self) -> Self {
2011        match self {
2012            Self::Severity => Self::Cvss,
2013            Self::Cvss => Self::CveId,
2014            Self::CveId => Self::Component,
2015            Self::Component => Self::Severity,
2016        }
2017    }
2018
2019    pub const fn label(self) -> &'static str {
2020        match self {
2021            Self::Severity => "Severity",
2022            Self::Cvss => "CVSS",
2023            Self::CveId => "CVE ID",
2024            Self::Component => "Component",
2025        }
2026    }
2027}
2028
2029/// State for license view.
2030#[derive(Debug, Clone)]
2031pub(crate) struct LicenseViewState {
2032    pub selected: usize,
2033    pub total: usize,
2034    pub scroll_offset: usize,
2035    pub group_by: LicenseGroupBy,
2036    /// Scroll position within component list in details panel
2037    pub component_scroll: usize,
2038    /// Total components for the selected license
2039    pub component_total: usize,
2040}
2041
2042impl LicenseViewState {
2043    pub const fn new() -> Self {
2044        Self {
2045            selected: 0,
2046            total: 0,
2047            scroll_offset: 0,
2048            group_by: LicenseGroupBy::License,
2049            component_scroll: 0,
2050            component_total: 0,
2051        }
2052    }
2053
2054    /// Scroll component list up
2055    pub const fn scroll_components_up(&mut self) {
2056        if self.component_scroll > 0 {
2057            self.component_scroll -= 1;
2058        }
2059    }
2060
2061    /// Scroll component list down
2062    pub const fn scroll_components_down(&mut self, visible_count: usize) {
2063        if self.component_total > visible_count
2064            && self.component_scroll < self.component_total - visible_count
2065        {
2066            self.component_scroll += 1;
2067        }
2068    }
2069
2070    /// Reset component scroll when license selection changes
2071    pub const fn reset_component_scroll(&mut self) {
2072        self.component_scroll = 0;
2073    }
2074
2075    pub const fn select_next(&mut self) {
2076        if self.total > 0 && self.selected < self.total.saturating_sub(1) {
2077            self.selected += 1;
2078            self.reset_component_scroll();
2079        }
2080    }
2081
2082    pub const fn select_prev(&mut self) {
2083        if self.selected > 0 {
2084            self.selected -= 1;
2085            self.reset_component_scroll();
2086        }
2087    }
2088
2089    /// Ensure selected index is within bounds
2090    pub const fn clamp_selection(&mut self) {
2091        if self.total == 0 {
2092            self.selected = 0;
2093        } else if self.selected >= self.total {
2094            self.selected = self.total.saturating_sub(1);
2095        }
2096    }
2097
2098    pub const fn toggle_group(&mut self) {
2099        self.group_by = match self.group_by {
2100            LicenseGroupBy::License => LicenseGroupBy::Category,
2101            LicenseGroupBy::Category => LicenseGroupBy::License,
2102        };
2103        self.selected = 0;
2104        self.reset_component_scroll();
2105    }
2106}
2107
2108impl Default for LicenseViewState {
2109    fn default() -> Self {
2110        Self::new()
2111    }
2112}
2113
2114impl ListNavigation for LicenseViewState {
2115    fn selected(&self) -> usize {
2116        self.selected
2117    }
2118
2119    fn set_selected(&mut self, idx: usize) {
2120        self.selected = idx;
2121    }
2122
2123    fn total(&self) -> usize {
2124        self.total
2125    }
2126
2127    fn set_total(&mut self, total: usize) {
2128        self.total = total;
2129    }
2130}
2131
2132/// License grouping modes.
2133#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2134pub(crate) enum LicenseGroupBy {
2135    License,
2136    Category,
2137}
2138
2139/// Dependency view state.
2140#[derive(Debug, Clone)]
2141pub(crate) struct DependencyViewState {
2142    /// Currently selected node in the dependency tree
2143    pub selected: usize,
2144    /// Total number of visible nodes
2145    pub total: usize,
2146    /// Set of expanded node IDs
2147    pub expanded: HashSet<String>,
2148    /// Scroll offset for the tree view
2149    pub scroll_offset: usize,
2150    /// Search query for dependency tree filtering
2151    pub search_query: String,
2152    /// Whether search input is active
2153    pub search_active: bool,
2154    /// Scroll offset for the detail/stats panel
2155    pub detail_scroll: u16,
2156    /// Whether roots have been auto-expanded on first visit
2157    pub roots_initialized: bool,
2158}
2159
2160impl DependencyViewState {
2161    pub fn new() -> Self {
2162        Self {
2163            selected: 0,
2164            total: 0,
2165            expanded: HashSet::new(),
2166            scroll_offset: 0,
2167            search_query: String::new(),
2168            search_active: false,
2169            detail_scroll: 0,
2170            roots_initialized: false,
2171        }
2172    }
2173
2174    pub fn toggle_expand(&mut self, node_id: &str) {
2175        if self.expanded.contains(node_id) {
2176            self.expanded.remove(node_id);
2177        } else {
2178            self.expanded.insert(node_id.to_string());
2179        }
2180    }
2181
2182    pub fn is_expanded(&self, node_id: &str) -> bool {
2183        self.expanded.contains(node_id)
2184    }
2185
2186    pub fn expand_all(&mut self, all_node_ids: &[String]) {
2187        self.expanded.extend(all_node_ids.iter().cloned());
2188    }
2189
2190    pub fn collapse_all(&mut self) {
2191        self.expanded.clear();
2192    }
2193
2194    pub fn start_search(&mut self) {
2195        self.search_active = true;
2196    }
2197
2198    pub fn stop_search(&mut self) {
2199        self.search_active = false;
2200    }
2201
2202    pub fn clear_search(&mut self) {
2203        self.search_query.clear();
2204        self.search_active = false;
2205    }
2206
2207    pub fn search_push(&mut self, c: char) {
2208        self.search_query.push(c);
2209    }
2210
2211    pub fn search_pop(&mut self) {
2212        self.search_query.pop();
2213    }
2214}
2215
2216impl Default for DependencyViewState {
2217    fn default() -> Self {
2218        Self::new()
2219    }
2220}
2221
2222impl ListNavigation for DependencyViewState {
2223    fn selected(&self) -> usize {
2224        self.selected
2225    }
2226
2227    fn set_selected(&mut self, idx: usize) {
2228        self.selected = idx;
2229    }
2230
2231    fn total(&self) -> usize {
2232        self.total
2233    }
2234
2235    fn set_total(&mut self, total: usize) {
2236        self.total = total;
2237    }
2238}
2239
2240/// Global search state.
2241#[derive(Debug, Clone)]
2242pub(crate) struct SearchState {
2243    pub active: bool,
2244    pub query: String,
2245    pub results: Vec<SearchResult>,
2246    pub selected: usize,
2247}
2248
2249impl SearchState {
2250    pub const fn new() -> Self {
2251        Self {
2252            active: false,
2253            query: String::new(),
2254            results: Vec::new(),
2255            selected: 0,
2256        }
2257    }
2258
2259    pub fn push_char(&mut self, c: char) {
2260        self.query.push(c);
2261    }
2262
2263    pub fn pop_char(&mut self) {
2264        self.query.pop();
2265    }
2266
2267    pub fn select_next(&mut self) {
2268        if !self.results.is_empty() && self.selected < self.results.len() - 1 {
2269            self.selected += 1;
2270        }
2271    }
2272
2273    pub const fn select_prev(&mut self) {
2274        if self.selected > 0 {
2275            self.selected -= 1;
2276        }
2277    }
2278}
2279
2280impl Default for SearchState {
2281    fn default() -> Self {
2282        Self::new()
2283    }
2284}
2285
2286/// Search result types.
2287#[derive(Debug, Clone)]
2288pub(crate) enum SearchResult {
2289    Component {
2290        id: String,
2291        name: String,
2292        version: Option<String>,
2293        match_field: String,
2294    },
2295    Vulnerability {
2296        id: String,
2297        /// Component canonical ID for navigation
2298        component_id: String,
2299        /// Component name for display
2300        component_name: String,
2301        severity: Option<String>,
2302    },
2303}
2304
2305/// Cached SBOM statistics.
2306#[derive(Debug, Clone)]
2307pub struct SbomStats {
2308    pub component_count: usize,
2309    pub vuln_count: usize,
2310    pub license_count: usize,
2311    pub ecosystem_counts: HashMap<String, usize>,
2312    pub vuln_by_severity: HashMap<String, usize>,
2313    pub license_counts: HashMap<String, usize>,
2314    pub critical_count: usize,
2315    pub high_count: usize,
2316    pub medium_count: usize,
2317    pub low_count: usize,
2318    pub unknown_count: usize,
2319    pub eol_count: usize,
2320    pub eol_approaching_count: usize,
2321    pub eol_supported_count: usize,
2322    pub eol_security_only_count: usize,
2323    pub eol_enriched: bool,
2324}
2325
2326impl SbomStats {
2327    pub fn from_sbom(sbom: &NormalizedSbom) -> Self {
2328        let mut ecosystem_counts: HashMap<String, usize> = HashMap::new();
2329        let mut vuln_by_severity: HashMap<String, usize> = HashMap::new();
2330        let mut license_counts: HashMap<String, usize> = HashMap::new();
2331        let mut vuln_count = 0;
2332        let mut critical_count = 0;
2333        let mut high_count = 0;
2334        let mut medium_count = 0;
2335        let mut low_count = 0;
2336        let mut unknown_count = 0;
2337        let mut eol_count = 0;
2338        let mut eol_approaching_count = 0;
2339        let mut eol_supported_count = 0;
2340        let mut eol_security_only_count = 0;
2341        let mut eol_enriched = false;
2342
2343        for comp in sbom.components.values() {
2344            // Count ecosystems
2345            let eco = comp
2346                .ecosystem
2347                .as_ref()
2348                .map_or_else(|| "Unknown".to_string(), std::string::ToString::to_string);
2349            *ecosystem_counts.entry(eco).or_insert(0) += 1;
2350
2351            // Count licenses
2352            for lic in &comp.licenses.declared {
2353                *license_counts.entry(lic.expression.clone()).or_insert(0) += 1;
2354            }
2355            if comp.licenses.declared.is_empty() {
2356                *license_counts.entry("Unknown".to_string()).or_insert(0) += 1;
2357            }
2358
2359            // Count vulnerabilities
2360            for vuln in &comp.vulnerabilities {
2361                vuln_count += 1;
2362                let sev = vuln
2363                    .severity
2364                    .as_ref()
2365                    .map_or_else(|| "Unknown".to_string(), std::string::ToString::to_string);
2366                *vuln_by_severity.entry(sev.clone()).or_insert(0) += 1;
2367
2368                match sev.to_lowercase().as_str() {
2369                    "critical" => critical_count += 1,
2370                    "high" => high_count += 1,
2371                    "medium" => medium_count += 1,
2372                    "low" => low_count += 1,
2373                    _ => unknown_count += 1,
2374                }
2375            }
2376
2377            // Count EOL statuses
2378            if let Some(eol) = &comp.eol {
2379                use crate::model::EolStatus;
2380                eol_enriched = true;
2381                match eol.status {
2382                    EolStatus::EndOfLife => eol_count += 1,
2383                    EolStatus::ApproachingEol => eol_approaching_count += 1,
2384                    EolStatus::Supported => eol_supported_count += 1,
2385                    EolStatus::SecurityOnly => eol_security_only_count += 1,
2386                    _ => {}
2387                }
2388            }
2389        }
2390
2391        Self {
2392            component_count: sbom.components.len(),
2393            vuln_count,
2394            license_count: license_counts.len(),
2395            ecosystem_counts,
2396            vuln_by_severity,
2397            license_counts,
2398            critical_count,
2399            high_count,
2400            medium_count,
2401            low_count,
2402            unknown_count,
2403            eol_count,
2404            eol_approaching_count,
2405            eol_supported_count,
2406            eol_security_only_count,
2407            eol_enriched,
2408        }
2409    }
2410}
2411
2412/// Breadcrumb entry for navigation history in view mode.
2413#[derive(Debug, Clone)]
2414pub struct ViewBreadcrumb {
2415    /// Tab we came from
2416    pub tab: ViewTab,
2417    /// Description of what was selected (e.g., "CVE-2024-1234", "lodash")
2418    pub label: String,
2419    /// Selection index to restore when going back
2420    pub selection_index: usize,
2421}
2422
2423/// Navigation context for cross-view navigation and breadcrumbs in view mode.
2424#[derive(Debug, Clone, Default)]
2425pub struct ViewNavigationContext {
2426    /// Breadcrumb trail for back navigation
2427    pub breadcrumbs: Vec<ViewBreadcrumb>,
2428    /// Target component name to navigate to (for vuln → component navigation)
2429    pub target_component: Option<String>,
2430    /// Target vulnerability ID to navigate to (for component → vuln navigation)
2431    pub target_vulnerability: Option<String>,
2432}
2433
2434impl ViewNavigationContext {
2435    #[must_use]
2436    pub const fn new() -> Self {
2437        Self {
2438            breadcrumbs: Vec::new(),
2439            target_component: None,
2440            target_vulnerability: None,
2441        }
2442    }
2443
2444    /// Push a new breadcrumb onto the trail
2445    pub fn push_breadcrumb(&mut self, tab: ViewTab, label: String, selection_index: usize) {
2446        self.breadcrumbs.push(ViewBreadcrumb {
2447            tab,
2448            label,
2449            selection_index,
2450        });
2451    }
2452
2453    /// Pop the last breadcrumb and return it (for back navigation)
2454    pub fn pop_breadcrumb(&mut self) -> Option<ViewBreadcrumb> {
2455        self.breadcrumbs.pop()
2456    }
2457
2458    /// Clear all breadcrumbs (on explicit tab switch)
2459    pub fn clear_breadcrumbs(&mut self) {
2460        self.breadcrumbs.clear();
2461    }
2462
2463    /// Check if we have navigation history
2464    #[must_use]
2465    pub fn has_history(&self) -> bool {
2466        !self.breadcrumbs.is_empty()
2467    }
2468
2469    /// Get the current breadcrumb trail as a string
2470    #[must_use]
2471    pub fn breadcrumb_trail(&self) -> String {
2472        self.breadcrumbs
2473            .iter()
2474            .map(|b| format!("{}: {}", b.tab.title(), b.label))
2475            .collect::<Vec<_>>()
2476            .join(" > ")
2477    }
2478
2479    /// Clear navigation targets
2480    pub fn clear_targets(&mut self) {
2481        self.target_component = None;
2482        self.target_vulnerability = None;
2483    }
2484}
2485
2486#[cfg(test)]
2487mod tests {
2488    use super::*;
2489    use crate::model::NormalizedSbom;
2490
2491    #[test]
2492    fn test_view_app_creation() {
2493        let sbom = NormalizedSbom::default();
2494        let mut app = ViewApp::new(sbom, "");
2495        // Reset to known state (preferences may override default)
2496        app.active_tab = ViewTab::Overview;
2497        assert_eq!(app.active_tab, ViewTab::Overview);
2498        assert!(!app.should_quit);
2499    }
2500
2501    #[test]
2502    fn test_tab_navigation() {
2503        let sbom = NormalizedSbom::default();
2504        let mut app = ViewApp::new(sbom, "");
2505        // Start from known state regardless of saved preferences
2506        app.active_tab = ViewTab::Overview;
2507
2508        app.next_tab();
2509        assert_eq!(app.active_tab, ViewTab::Tree);
2510
2511        app.next_tab();
2512        assert_eq!(app.active_tab, ViewTab::Vulnerabilities);
2513
2514        app.prev_tab();
2515        assert_eq!(app.active_tab, ViewTab::Tree);
2516    }
2517
2518    #[test]
2519    fn test_vuln_state_navigation_with_zero_total() {
2520        // This was causing a crash due to underflow: total - 1 when total = 0
2521        let mut state = VulnExplorerState::new();
2522        assert_eq!(state.total, 0);
2523        assert_eq!(state.selected, 0);
2524
2525        // This should not panic or change selection
2526        state.select_next();
2527        assert_eq!(state.selected, 0);
2528
2529        state.select_prev();
2530        assert_eq!(state.selected, 0);
2531    }
2532
2533    #[test]
2534    fn test_vuln_state_clamp_selection() {
2535        let mut state = VulnExplorerState::new();
2536        state.total = 5;
2537        state.selected = 10; // Out of bounds
2538
2539        state.clamp_selection();
2540        assert_eq!(state.selected, 4); // Should be clamped to last valid index
2541
2542        state.total = 0;
2543        state.clamp_selection();
2544        assert_eq!(state.selected, 0); // Should be 0 when empty
2545    }
2546
2547    #[test]
2548    fn test_license_state_navigation_with_zero_total() {
2549        let mut state = LicenseViewState::new();
2550        assert_eq!(state.total, 0);
2551        assert_eq!(state.selected, 0);
2552
2553        // This should not panic or change selection
2554        state.select_next();
2555        assert_eq!(state.selected, 0);
2556
2557        state.select_prev();
2558        assert_eq!(state.selected, 0);
2559    }
2560
2561    #[test]
2562    fn test_license_state_clamp_selection() {
2563        let mut state = LicenseViewState::new();
2564        state.total = 3;
2565        state.selected = 5; // Out of bounds
2566
2567        state.clamp_selection();
2568        assert_eq!(state.selected, 2); // Should be clamped to last valid index
2569    }
2570
2571    #[test]
2572    fn test_dependency_state_navigation() {
2573        let mut state = DependencyViewState::new();
2574        assert_eq!(state.total, 0);
2575        assert_eq!(state.selected, 0);
2576
2577        // Test with zero total - should not change
2578        state.select_next();
2579        assert_eq!(state.selected, 0);
2580
2581        // Test with items
2582        state.total = 5;
2583        state.select_next();
2584        assert_eq!(state.selected, 1);
2585
2586        state.select_next();
2587        state.select_next();
2588        state.select_next();
2589        assert_eq!(state.selected, 4); // At end
2590
2591        state.select_next();
2592        assert_eq!(state.selected, 4); // Should not go past end
2593
2594        state.select_prev();
2595        assert_eq!(state.selected, 3);
2596    }
2597
2598    #[test]
2599    fn test_dependency_state_expand_collapse() {
2600        let mut state = DependencyViewState::new();
2601
2602        assert!(!state.is_expanded("node1"));
2603
2604        state.toggle_expand("node1");
2605        assert!(state.is_expanded("node1"));
2606
2607        state.toggle_expand("node1");
2608        assert!(!state.is_expanded("node1"));
2609    }
2610}