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