use crate::model::{Component, DependencyType, EolStatus};
use crate::tui::theme::colors;
use crate::tui::view::app::{ComponentDetailTab, FocusPanel, TreeFilter, ViewApp};
use crate::tui::widgets::{SeverityBadge, Tree, TreeNode, truncate_str};
use ratatui::{
prelude::*,
widgets::{Block, Borders, Paragraph, Wrap},
};
pub fn render_tree(frame: &mut Frame, area: Rect, app: &mut ViewApp) {
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(55), Constraint::Percentage(45)])
.split(area);
render_tree_panel(frame, chunks[0], app);
render_detail_panel(frame, chunks[1], app);
}
fn render_tree_panel(frame: &mut Frame, area: Rect, app: &mut ViewApp) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(2), Constraint::Min(5)])
.split(area);
app.ensure_tree_cache();
let scheme = colors();
let is_filtered =
!app.tree_search_query.is_empty() || !matches!(app.tree_filter, TreeFilter::All);
let filtered_count = if is_filtered {
Some(count_tree_leaves(&app.cached_tree_nodes))
} else {
None
};
render_filter_bar(frame, chunks[0], app, filtered_count);
let border_color = if app.focus_panel == FocusPanel::Left {
scheme.border_focused
} else {
scheme.border
};
let title = if let Some(count) = filtered_count {
format!(" Components ({count}/{}) ", app.stats.component_count)
} else {
format!(" Components ({}) ", app.stats.component_count)
};
let tree = Tree::new(&app.cached_tree_nodes)
.block(
Block::default()
.title(title)
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color)),
)
.highlight_style(
Style::default()
.bg(scheme.selection)
.add_modifier(Modifier::BOLD),
)
.search_query(&app.tree_search_query);
frame.render_stateful_widget(tree, chunks[1], &mut app.tree_state);
}
fn count_tree_leaves(nodes: &[TreeNode]) -> usize {
nodes
.iter()
.map(|n| match n {
TreeNode::Component { .. } => 1,
TreeNode::Group { children, .. } => count_tree_leaves(children),
})
.sum()
}
fn render_filter_bar(frame: &mut Frame, area: Rect, app: &ViewApp, filtered_count: Option<usize>) {
let scheme = colors();
if app.tree_search_active {
let cursor = if app.tick % 10 < 5 { "▌" } else { " " };
let mut spans = vec![
Span::styled("Search: ", Style::default().fg(scheme.accent).bold()),
Span::styled(
format!("{}{}", app.tree_search_query, cursor),
Style::default().fg(scheme.text).bg(scheme.selection),
),
];
if let Some(count) = filtered_count {
spans.push(Span::styled(
format!(" ({count})"),
Style::default().fg(scheme.text_muted),
));
}
spans.extend([
Span::raw(" "),
Span::styled("[Esc]", Style::default().fg(scheme.text_muted)),
Span::styled(" cancel ", Style::default().fg(scheme.text_muted)),
Span::styled("[Enter]", Style::default().fg(scheme.text_muted)),
Span::styled(" done", Style::default().fg(scheme.text_muted)),
]);
let para = Paragraph::new(Line::from(spans));
frame.render_widget(para, area);
return;
}
let mut spans = vec![
Span::styled("Group: ", Style::default().fg(scheme.text_muted)),
Span::styled(
format!(" {} ", app.tree_group_by.label()),
Style::default()
.fg(scheme.badge_fg_dark)
.bg(scheme.accent)
.bold(),
),
Span::raw(" "),
Span::styled("Filter: ", Style::default().fg(scheme.text_muted)),
Span::styled(
format!(" {} ", app.tree_filter.label()),
Style::default()
.fg(scheme.badge_fg_dark)
.bg(scheme.highlight)
.bold(),
),
];
if !app.tree_search_query.is_empty() {
spans.push(Span::raw(" "));
spans.push(Span::styled(
"Search: ",
Style::default().fg(scheme.text_muted),
));
spans.push(Span::styled(
format!("\"{}\"", app.tree_search_query),
Style::default().fg(scheme.info),
));
}
if let Some(count) = filtered_count {
spans.push(Span::styled(
format!(" ({count})"),
Style::default().fg(scheme.text_muted),
));
}
spans.push(Span::raw(" │ "));
spans.push(Span::styled("[/]", Style::default().fg(scheme.accent)));
spans.push(Span::raw(" search "));
spans.push(Span::styled("[g]", Style::default().fg(scheme.accent)));
spans.push(Span::raw(" group "));
spans.push(Span::styled("[f]", Style::default().fg(scheme.accent)));
spans.push(Span::raw(" filter "));
spans.push(Span::styled("[m]", Style::default().fg(scheme.accent)));
spans.push(Span::raw(" bookmark"));
let para = Paragraph::new(Line::from(spans));
frame.render_widget(para, area);
}
fn render_detail_panel(frame: &mut Frame, area: Rect, app: &ViewApp) {
let scheme = colors();
let border_color = if app.focus_panel == FocusPanel::Right {
scheme.border_focused
} else {
scheme.border
};
if let Some(comp) = app.get_selected_component() {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(2), Constraint::Min(5), ])
.split(area);
render_component_tab_bar(frame, chunks[0], app);
let scroll = app.component_detail_scroll;
match app.component_tab {
ComponentDetailTab::Overview => {
render_overview_tab(frame, chunks[1], comp, border_color, scroll);
}
ComponentDetailTab::Identifiers => {
render_identifiers_tab(frame, chunks[1], comp, border_color, scroll);
}
ComponentDetailTab::Vulnerabilities => {
render_vulnerabilities_tab(frame, chunks[1], comp, border_color, scroll);
}
ComponentDetailTab::Dependencies => {
render_dependencies_tab(frame, chunks[1], app, comp, border_color, scroll);
}
}
} else if let Some((group_label, child_ids)) = app.get_selected_group_info() {
render_group_stats_panel(frame, area, app, &group_label, &child_ids, border_color);
} else {
render_component_stats_panel(frame, area, app, border_color);
}
}
fn render_component_tab_bar(frame: &mut Frame, area: Rect, app: &ViewApp) {
let scheme = colors();
let tabs = ComponentDetailTab::all();
let selected_idx = match app.component_tab {
ComponentDetailTab::Overview => 0,
ComponentDetailTab::Identifiers => 1,
ComponentDetailTab::Vulnerabilities => 2,
ComponentDetailTab::Dependencies => 3,
};
let mut spans = Vec::new();
spans.push(Span::styled(
"◀ ",
Style::default().fg(if selected_idx > 0 {
scheme.accent
} else {
scheme.border
}),
));
for (i, tab) in tabs.iter().enumerate() {
if i > 0 {
spans.push(Span::styled(" │ ", Style::default().fg(scheme.border)));
}
let is_selected = i == selected_idx;
let key_style = if is_selected {
Style::default().fg(scheme.accent).bold()
} else {
Style::default().fg(scheme.text_muted)
};
let label_style = if is_selected {
Style::default()
.fg(scheme.badge_fg_dark)
.bg(scheme.accent)
.bold()
.add_modifier(Modifier::UNDERLINED)
} else {
Style::default().fg(scheme.text_muted)
};
spans.push(Span::styled(format!("[{}]", tab.shortcut()), key_style));
spans.push(Span::styled(format!(" {} ", tab.title()), label_style));
}
spans.push(Span::styled(
" ▶",
Style::default().fg(if selected_idx < tabs.len() - 1 {
scheme.accent
} else {
scheme.border
}),
));
frame.render_widget(Paragraph::new(Line::from(spans)), area);
}
fn render_overview_tab(
frame: &mut Frame,
area: Rect,
comp: &Component,
border_color: Color,
scroll: u16,
) {
let scheme = colors();
let label_style = Style::default().fg(scheme.text_muted);
let absent = Style::default().fg(scheme.muted);
let mut lines = vec![];
lines.push(Line::from(vec![Span::styled(
comp.name.clone(),
Style::default().fg(scheme.text).bold(),
)]));
lines.push(Line::from(vec![
Span::styled("Version: ", label_style),
if let Some(ver) = &comp.version {
Span::styled(ver, Style::default().fg(scheme.accent))
} else {
Span::styled("—", absent)
},
]));
lines.push(Line::from(vec![
Span::styled("Ecosystem: ", label_style),
if let Some(eco) = &comp.ecosystem {
Span::styled(eco.to_string(), Style::default().fg(scheme.success))
} else {
Span::styled("—", absent)
},
]));
let mut type_spans = vec![
Span::styled("Type: ", label_style),
Span::styled(
comp.component_type.to_string(),
Style::default().fg(scheme.highlight),
),
];
if let Some(detected) = crate::tui::widgets::detect_component_label(&comp.name) {
type_spans.push(Span::styled(
format!(" ({detected})"),
Style::default().fg(scheme.text_muted),
));
}
lines.push(Line::from(type_spans));
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::styled("PURL: ", label_style),
if let Some(purl) = &comp.identifiers.purl {
Span::styled(purl.as_str(), Style::default().fg(scheme.info))
} else {
Span::styled("—", absent)
},
]));
if let Some(hash) = comp.hashes.first() {
let algo = hash.algorithm.to_string();
lines.push(Line::from(vec![
Span::styled("Hash: ", label_style),
Span::styled(format!("{algo}:"), Style::default().fg(scheme.text_muted)),
]));
lines.push(Line::from(vec![
Span::styled(" ", label_style),
Span::styled(&hash.value, Style::default().fg(scheme.text)),
]));
} else {
lines.push(Line::from(vec![
Span::styled("Hash: ", label_style),
Span::styled("—", absent),
]));
}
if let Some(supplier) = &comp.supplier {
lines.push(Line::from(""));
lines.push(Line::styled(
"Supplier:",
Style::default().fg(scheme.accent).bold(),
));
lines.push(Line::from(vec![
Span::styled(" ", Style::default()),
Span::raw(&supplier.name),
]));
if let Some(url) = supplier.urls.first() {
lines.push(Line::from(vec![
Span::styled(" URL: ", label_style),
Span::styled(url, Style::default().fg(scheme.info)),
]));
}
}
lines.push(Line::from(""));
if comp.licenses.declared.is_empty() {
lines.push(Line::from(vec![
Span::styled("Licenses: ", label_style),
Span::styled("None declared", absent),
]));
} else {
lines.push(Line::styled(
"Licenses:",
Style::default().fg(scheme.success).bold(),
));
for lic in comp.licenses.declared.iter().take(3) {
lines.push(Line::from(vec![
Span::styled(" • ", label_style),
Span::raw(&lic.expression),
]));
}
if comp.licenses.declared.len() > 3 {
lines.push(Line::from(vec![Span::styled(
format!(" ... and {} more", comp.licenses.declared.len() - 3),
label_style,
)]));
}
}
lines.push(Line::from(""));
if comp.vulnerabilities.is_empty() {
lines.push(Line::from(vec![
Span::styled("Vulns: ", label_style),
Span::styled("0 (clean)", Style::default().fg(scheme.success)),
]));
} else {
lines.push(Line::from(vec![
Span::styled("Vulns: ", Style::default().fg(scheme.critical).bold()),
Span::styled(
format!("{}", comp.vulnerabilities.len()),
Style::default().fg(scheme.critical).bold(),
),
Span::styled(" (see [3] tab)", label_style),
]));
}
if let Some(vex) = &comp.vex_status {
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::styled("VEX Status: ", label_style),
Span::styled(
vex.status.to_string(),
Style::default().fg(scheme.highlight).bold(),
),
]));
if let Some(just) = &vex.justification {
lines.push(Line::from(vec![
Span::styled(" Justification: ", label_style),
Span::raw(just.to_string()),
]));
}
}
if let Some(staleness) = &comp.staleness {
use crate::model::StalenessLevel;
lines.push(Line::from(""));
let stale_color = match staleness.level {
StalenessLevel::Fresh => scheme.success,
StalenessLevel::Aging => scheme.warning,
StalenessLevel::Stale => scheme.high,
StalenessLevel::Abandoned | StalenessLevel::Deprecated => scheme.critical,
StalenessLevel::Archived => scheme.error,
};
lines.push(Line::from(vec![
Span::styled("Staleness: ", label_style),
Span::styled(
format!(" {} ", staleness.level.label()),
Style::default()
.fg(scheme.badge_fg_dark)
.bg(stale_color)
.bold(),
),
]));
if let Some(days) = staleness.days_since_update {
lines.push(Line::from(vec![
Span::styled(" Last release: ", label_style),
Span::styled(format!("{days} days ago"), Style::default().fg(stale_color)),
]));
}
}
if let Some(eol) = &comp.eol {
use crate::model::EolStatus;
lines.push(Line::from(""));
let eol_color = match eol.status {
EolStatus::Supported => scheme.success,
EolStatus::SecurityOnly => scheme.warning,
EolStatus::ApproachingEol => scheme.high,
EolStatus::EndOfLife => scheme.critical,
_ => scheme.muted,
};
lines.push(Line::from(vec![
Span::styled(format!("{} End-of-Life: ", eol.status.icon()), label_style),
Span::styled(
format!(" {} ", eol.status.label()),
Style::default()
.fg(scheme.badge_fg_dark)
.bg(eol_color)
.bold(),
),
]));
if let Some(days) = eol.days_until_eol {
let days_text = if days < 0 {
format!("{} days past EOL", days.abs())
} else if days == 0 {
"EOL today".to_string()
} else {
format!("{days} days remaining")
};
lines.push(Line::from(vec![
Span::styled(" ", Style::default()),
Span::styled(days_text, Style::default().fg(eol_color)),
]));
}
lines.push(Line::from(vec![
Span::styled(" Product: ", label_style),
Span::styled(&eol.product, Style::default().fg(scheme.text)),
Span::styled(format!(" (cycle {})", eol.cycle), label_style),
]));
if let Some(date) = eol.eol_date {
lines.push(Line::from(vec![
Span::styled(" EOL Date: ", label_style),
Span::styled(date.to_string(), Style::default().fg(eol_color)),
]));
}
if eol.is_lts {
lines.push(Line::from(vec![
Span::styled(" LTS: ", label_style),
Span::styled(
" Yes ",
Style::default()
.fg(scheme.badge_fg_dark)
.bg(scheme.info)
.bold(),
),
]));
}
if let Some(latest) = &eol.latest_in_cycle {
let is_outdated = comp
.version
.as_deref()
.is_some_and(|v| v != latest.as_str());
lines.push(Line::from(vec![
Span::styled(" Latest: ", label_style),
Span::styled(
latest,
Style::default().fg(if is_outdated {
scheme.warning
} else {
scheme.success
}),
),
if is_outdated {
Span::styled(" (update available)", Style::default().fg(scheme.warning))
} else {
Span::styled(" (up to date)", Style::default().fg(scheme.success))
},
]));
}
}
let field_count = 6;
let present = u8::from(comp.version.is_some())
+ u8::from(comp.ecosystem.is_some())
+ u8::from(comp.identifiers.purl.is_some())
+ u8::from(!comp.licenses.declared.is_empty())
+ u8::from(!comp.hashes.is_empty())
+ u8::from(comp.supplier.is_some());
let filled = "█".repeat(present as usize);
let empty = "░".repeat(field_count - present as usize);
let completeness_color = if present >= 5 {
scheme.success
} else if present >= 3 {
scheme.warning
} else {
scheme.critical
};
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::styled("Data: ", label_style),
Span::styled(filled, Style::default().fg(completeness_color)),
Span::styled(empty, Style::default().fg(scheme.muted)),
Span::styled(
format!(" {present}/{field_count} fields"),
Style::default().fg(scheme.text_muted),
),
]));
let panel = Paragraph::new(lines)
.block(
Block::default()
.title(" Overview ")
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color)),
)
.wrap(Wrap { trim: true })
.scroll((scroll, 0));
frame.render_widget(panel, area);
}
fn render_identifiers_tab(
frame: &mut Frame,
area: Rect,
comp: &Component,
border_color: Color,
scroll: u16,
) {
let scheme = colors();
let mut lines = vec![];
let width = area.width as usize;
if let Some(purl) = &comp.identifiers.purl {
lines.push(Line::styled(
"Package URL (PURL):",
Style::default().fg(scheme.accent).bold(),
));
let purl_display = if purl.len() > width - 6 {
format!("{}...", &purl[..width.saturating_sub(9)])
} else {
purl.clone()
};
lines.push(Line::from(vec![
Span::styled(" ", Style::default()),
Span::raw(purl_display),
]));
lines.push(Line::from(""));
}
if !comp.identifiers.cpe.is_empty() {
lines.push(Line::styled(
format!("CPE ({}):", comp.identifiers.cpe.len()),
Style::default().fg(scheme.accent).bold(),
));
for cpe in &comp.identifiers.cpe {
lines.push(Line::from(vec![
Span::styled(" ", Style::default()),
Span::raw(truncate_str(cpe, width - 4)),
]));
}
lines.push(Line::from(""));
}
if let Some(swid) = &comp.identifiers.swid {
lines.push(Line::styled(
"SWID Tag:",
Style::default().fg(scheme.accent).bold(),
));
lines.push(Line::from(vec![
Span::styled(" ", Style::default()),
Span::raw(truncate_str(swid, width - 4)),
]));
lines.push(Line::from(""));
}
if !comp.hashes.is_empty() {
lines.push(Line::styled(
format!("Hashes ({}):", comp.hashes.len()),
Style::default().fg(scheme.highlight).bold(),
));
for hash in &comp.hashes {
let algo_prefix_len = format!(" {}: ", hash.algorithm).len();
let max_hash_width = width.saturating_sub(algo_prefix_len + 1);
let hash_display = if hash.value.len() > max_hash_width {
format!("{}...", &hash.value[..max_hash_width.saturating_sub(3)])
} else {
hash.value.clone()
};
lines.push(Line::from(vec![
Span::styled(
format!(" {}: ", hash.algorithm),
Style::default().fg(scheme.text_muted),
),
Span::raw(hash_display),
]));
}
lines.push(Line::from(""));
}
if !comp.external_refs.is_empty() {
lines.push(Line::styled(
format!("External References ({}):", comp.external_refs.len()),
Style::default().fg(scheme.info).bold(),
));
for ext_ref in comp.external_refs.iter().take(5) {
lines.push(Line::from(vec![
Span::styled(
format!(" [{}] ", ext_ref.ref_type),
Style::default().fg(scheme.text_muted),
),
Span::styled(
truncate_str(&ext_ref.url, width - 15),
Style::default().fg(scheme.info),
),
]));
}
if comp.external_refs.len() > 5 {
lines.push(Line::from(vec![Span::styled(
format!(" ... and {} more", comp.external_refs.len() - 5),
Style::default().fg(scheme.text_muted),
)]));
}
}
let display_name = crate::tui::widgets::extract_display_name(&comp.name);
let canonical = comp.canonical_id.value();
if canonical != comp.name && canonical != display_name {
lines.push(Line::styled(
"Canonical ID:",
Style::default().fg(scheme.text_muted).bold(),
));
lines.push(Line::from(vec![
Span::styled(" ", Style::default()),
Span::raw(canonical),
]));
lines.push(Line::from(""));
}
if lines.is_empty() {
lines.push(Line::from(""));
lines.push(Line::styled(
"No identifiers available for this component",
Style::default().fg(scheme.text_muted),
));
}
let panel = Paragraph::new(lines)
.block(
Block::default()
.title(" Identifiers ")
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color)),
)
.wrap(Wrap { trim: true })
.scroll((scroll, 0));
frame.render_widget(panel, area);
}
fn render_vulnerabilities_tab(
frame: &mut Frame,
area: Rect,
comp: &Component,
border_color: Color,
scroll: u16,
) {
let scheme = colors();
let mut lines = vec![];
let width = area.width as usize;
if comp.vulnerabilities.is_empty() {
lines.push(Line::from(""));
lines.push(Line::styled(
"No vulnerabilities detected",
Style::default().fg(scheme.success),
));
lines.push(Line::from(""));
lines.push(Line::styled(
"This component has no known security issues.",
Style::default().fg(scheme.text_muted),
));
} else {
lines.push(Line::from(vec![Span::styled(
format!("{} vulnerabilities found", comp.vulnerabilities.len()),
Style::default().fg(scheme.critical).bold(),
)]));
lines.push(Line::from(""));
for vuln in &comp.vulnerabilities {
let sev = vuln
.severity
.as_ref()
.map_or_else(|| "?".to_string(), std::string::ToString::to_string);
let sev_color = SeverityBadge::fg_color(&sev);
let mut vuln_line = vec![
Span::styled("• ", Style::default().fg(scheme.critical)),
Span::styled(&vuln.id, Style::default().fg(sev_color).bold()),
];
if let Some(cvss) = vuln.max_cvss_score() {
vuln_line.push(Span::styled(
format!(" [CVSS: {cvss:.1}]"),
Style::default().fg(sev_color),
));
}
vuln_line.push(Span::styled(
format!(" [{}]", sev.to_uppercase()),
Style::default().fg(sev_color),
));
lines.push(Line::from(vuln_line));
if let Some(desc) = &vuln.description {
let desc_short = truncate_str(desc, width - 4);
lines.push(Line::from(vec![
Span::styled(" ", Style::default()),
Span::styled(desc_short, Style::default().fg(scheme.text_muted)),
]));
}
lines.push(Line::from(vec![
Span::styled(" Source: ", Style::default().fg(scheme.text_muted)),
Span::styled(vuln.source.to_string(), Style::default().fg(scheme.info)),
]));
lines.push(Line::from(""));
}
}
let panel = Paragraph::new(lines)
.block(
Block::default()
.title(" Vulnerabilities ")
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color)),
)
.wrap(Wrap { trim: true })
.scroll((scroll, 0));
frame.render_widget(panel, area);
}
fn render_dependencies_tab(
frame: &mut Frame,
area: Rect,
app: &ViewApp,
comp: &Component,
border_color: Color,
scroll: u16,
) {
let scheme = colors();
let mut lines = vec![];
let comp_id = &comp.canonical_id;
let mut direct_deps: Vec<(&Component, &DependencyType)> = Vec::new();
let mut dependents: Vec<(&Component, &DependencyType)> = Vec::new();
for edge in &app.sbom.edges {
if edge.from == *comp_id {
if let Some(dep) = app.sbom.components.get(&edge.to) {
direct_deps.push((dep, &edge.relationship));
}
}
if edge.to == *comp_id {
if let Some(dependent) = app.sbom.components.get(&edge.from) {
dependents.push((dependent, &edge.relationship));
}
}
}
lines.push(Line::styled(
format!("Dependencies ({}):", direct_deps.len()),
Style::default().fg(scheme.accent).bold(),
));
if direct_deps.is_empty() {
lines.push(Line::styled(
" No direct dependencies",
Style::default().fg(scheme.text_muted),
));
} else {
for (dep, rel) in direct_deps.iter().take(10) {
let version = dep.version.as_deref().unwrap_or("");
let vuln_indicator = if dep.vulnerabilities.is_empty() {
String::new()
} else {
format!(" [{}]", dep.vulnerabilities.len())
};
let vuln_color = if dep.vulnerabilities.is_empty() {
scheme.text_muted
} else {
scheme.critical
};
let tag = dependency_tag(rel);
let mut spans = vec![
Span::styled(" → ", Style::default().fg(scheme.accent)),
Span::styled(&dep.name, Style::default().fg(scheme.text)),
Span::styled(
format!(" {version}"),
Style::default().fg(scheme.text_muted),
),
];
if !tag.is_empty() {
spans.push(Span::styled(tag, Style::default().fg(scheme.info)));
}
spans.push(Span::styled(
vuln_indicator,
Style::default().fg(vuln_color),
));
lines.push(Line::from(spans));
}
if direct_deps.len() > 10 {
lines.push(Line::styled(
format!(" ... and {} more", direct_deps.len() - 10),
Style::default().fg(scheme.text_muted),
));
}
}
lines.push(Line::from(""));
lines.push(Line::styled(
format!("Dependents ({}):", dependents.len()),
Style::default().fg(scheme.highlight).bold(),
));
if dependents.is_empty() {
lines.push(Line::styled(
" No components depend on this",
Style::default().fg(scheme.text_muted),
));
} else {
for (dep, rel) in dependents.iter().take(10) {
let version = dep.version.as_deref().unwrap_or("");
let tag = dependency_tag(rel);
let mut spans = vec![
Span::styled(" ← ", Style::default().fg(scheme.highlight)),
Span::styled(&dep.name, Style::default().fg(scheme.text)),
Span::styled(
format!(" {version}"),
Style::default().fg(scheme.text_muted),
),
];
if !tag.is_empty() {
spans.push(Span::styled(tag, Style::default().fg(scheme.info)));
}
lines.push(Line::from(spans));
}
if dependents.len() > 10 {
lines.push(Line::styled(
format!(" ... and {} more", dependents.len() - 10),
Style::default().fg(scheme.text_muted),
));
}
}
let panel = Paragraph::new(lines)
.block(
Block::default()
.title(" Dependencies ")
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color)),
)
.wrap(Wrap { trim: true })
.scroll((scroll, 0));
frame.render_widget(panel, area);
}
fn render_component_stats_panel(frame: &mut Frame, area: Rect, app: &ViewApp, border_color: Color) {
use crate::tui::view::severity::severity_category;
let scheme = colors();
let mut lines = Vec::with_capacity(30);
let width = area.width.saturating_sub(4) as usize;
lines.push(Line::from(vec![Span::styled(
"Component Statistics",
Style::default().fg(scheme.accent).bold(),
)]));
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::styled("Total Components: ", Style::default().fg(scheme.text_muted)),
Span::styled(
app.stats.component_count.to_string(),
Style::default().fg(scheme.text).bold(),
),
]));
lines.push(Line::from(""));
let mut type_counts: std::collections::HashMap<&str, usize> =
std::collections::HashMap::with_capacity(5);
let mut vuln_counts: std::collections::HashMap<&str, usize> =
std::collections::HashMap::with_capacity(5);
vuln_counts.insert("critical", 0);
vuln_counts.insert("high", 0);
vuln_counts.insert("medium", 0);
vuln_counts.insert("low", 0);
vuln_counts.insert("clean", 0);
for comp in app.sbom.components.values() {
let comp_type = crate::tui::widgets::detect_component_type(&comp.name);
*type_counts.entry(comp_type).or_insert(0) += 1;
let category = severity_category(&comp.vulnerabilities);
*vuln_counts.entry(category).or_insert(0) += 1;
}
lines.push(Line::styled(
"By Type:",
Style::default().fg(scheme.highlight).bold(),
));
let total = app.stats.component_count.max(1);
let type_order = vec![
("lib", "Libraries", scheme.info),
("bin", "Binaries", scheme.accent),
("cert", "Certificates", scheme.success),
("fs", "Filesystems", scheme.highlight),
("file", "Other Files", scheme.text_muted),
];
let max_type_count = type_counts.values().copied().max().unwrap_or(1);
let bar_width = width.saturating_sub(26).min(30);
for (key, label, color) in &type_order {
let count = type_counts.get(key).copied().unwrap_or(0);
if count == 0 {
continue;
}
let pct = (count * 100) / total;
let bar_len = if max_type_count > 0 {
(count * bar_width) / max_type_count
} else {
0
};
let bar = "█".repeat(bar_len);
lines.push(Line::from(vec![
Span::styled(format!(" {label:12}"), Style::default().fg(*color)),
Span::styled(format!("{count:>5}"), Style::default().fg(scheme.text)),
Span::styled(
format!(" {pct:>2}% "),
Style::default().fg(scheme.text_muted),
),
Span::styled(bar, Style::default().fg(*color)),
]));
}
lines.push(Line::from(""));
lines.push(Line::styled(
"Vulnerability Status:",
Style::default().fg(scheme.critical).bold(),
));
let vuln_order = vec![
("critical", "Critical", scheme.critical),
("high", "High", scheme.high),
("medium", "Medium", scheme.warning),
("low", "Low", scheme.low),
("clean", "Clean", scheme.success),
];
let max_vuln_count = vuln_counts.values().copied().max().unwrap_or(1);
for (key, label, color) in &vuln_order {
let count = vuln_counts.get(key).copied().unwrap_or(0);
let pct = (count * 100) / total;
let bar_len = if max_vuln_count > 0 {
(count * bar_width) / max_vuln_count
} else {
0
};
let bar = "█".repeat(bar_len);
lines.push(Line::from(vec![
Span::styled(format!(" {label:12}"), Style::default().fg(*color)),
Span::styled(format!("{count:>5}"), Style::default().fg(scheme.text)),
Span::styled(
format!(" {pct:>2}% "),
Style::default().fg(scheme.text_muted),
),
Span::styled(bar, Style::default().fg(*color)),
]));
}
if app.stats.eol_enriched {
lines.push(Line::from(""));
lines.push(Line::styled(
"EOL Status:",
Style::default().fg(scheme.warning).bold(),
));
let mut eol_counts: std::collections::HashMap<&str, usize> =
std::collections::HashMap::with_capacity(5);
eol_counts.insert("eol", 0);
eol_counts.insert("approaching", 0);
eol_counts.insert("security", 0);
eol_counts.insert("supported", 0);
eol_counts.insert("unknown", 0);
for comp in app.sbom.components.values() {
let key = eol_status_key(comp.eol.as_ref().map(|e| &e.status));
*eol_counts.entry(key).or_insert(0) += 1;
}
let eol_order = [
("eol", "EOL", scheme.critical),
("approaching", "Near EOL", scheme.high),
("security", "Sec Only", scheme.warning),
("supported", "Supported", scheme.success),
("unknown", "Unknown", scheme.muted),
];
let max_eol_count = eol_counts.values().copied().max().unwrap_or(1);
for (key, label, color) in &eol_order {
let count = eol_counts.get(key).copied().unwrap_or(0);
if count == 0 {
continue;
}
let pct = (count * 100) / total;
let bar_len = if max_eol_count > 0 {
(count * bar_width) / max_eol_count
} else {
0
};
let bar = "█".repeat(bar_len);
lines.push(Line::from(vec![
Span::styled(format!(" {label:12}"), Style::default().fg(*color)),
Span::styled(format!("{count:>5}"), Style::default().fg(scheme.text)),
Span::styled(
format!(" {pct:>2}% "),
Style::default().fg(scheme.text_muted),
),
Span::styled(bar, Style::default().fg(*color)),
]));
}
}
let panel = Paragraph::new(lines)
.block(
Block::default()
.title(" Component Overview ")
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color)),
)
.wrap(Wrap { trim: true });
frame.render_widget(panel, area);
}
fn render_group_stats_panel(
frame: &mut Frame,
area: Rect,
app: &ViewApp,
group_label: &str,
child_ids: &[String],
border_color: Color,
) {
use crate::tui::view::severity::severity_category;
let scheme = colors();
let mut lines = Vec::with_capacity(30);
lines.push(Line::from(vec![Span::styled(
group_label,
Style::default().fg(scheme.accent).bold(),
)]));
lines.push(Line::from(vec![
Span::styled("Components: ", Style::default().fg(scheme.text_muted)),
Span::styled(
child_ids.len().to_string(),
Style::default().fg(scheme.text).bold(),
),
]));
lines.push(Line::from(""));
let group_comps: Vec<&Component> = child_ids
.iter()
.filter_map(|id| {
app.sbom
.components
.iter()
.find(|(cid, _)| cid.value() == id)
.map(|(_, c)| c)
})
.collect();
let mut vuln_counts: std::collections::HashMap<&str, usize> =
std::collections::HashMap::with_capacity(5);
vuln_counts.insert("critical", 0);
vuln_counts.insert("high", 0);
vuln_counts.insert("medium", 0);
vuln_counts.insert("low", 0);
vuln_counts.insert("clean", 0);
let mut total_vulns = 0usize;
for comp in &group_comps {
let category = severity_category(&comp.vulnerabilities);
*vuln_counts.entry(category).or_insert(0) += 1;
total_vulns += comp.vulnerabilities.len();
}
lines.push(Line::styled(
format!("Vulnerabilities: {total_vulns}"),
Style::default().fg(scheme.high).bold(),
));
let width = area.width.saturating_sub(4) as usize;
let bar_width = width.saturating_sub(20).min(25);
let max_vuln = vuln_counts.values().copied().max().unwrap_or(1);
let vuln_order = [
("critical", "Critical", scheme.critical),
("high", "High", scheme.high),
("medium", "Medium", scheme.warning),
("low", "Low", scheme.info),
("clean", "Clean", scheme.success),
];
for (key, label, color) in &vuln_order {
let count = vuln_counts.get(key).copied().unwrap_or(0);
if count == 0 {
continue;
}
let bar_len = if max_vuln > 0 {
(count * bar_width) / max_vuln
} else {
0
};
lines.push(Line::from(vec![
Span::styled(format!(" {label:12}"), Style::default().fg(*color)),
Span::styled(format!("{count:>5} "), Style::default().fg(scheme.text)),
Span::styled("\u{2588}".repeat(bar_len), Style::default().fg(*color)),
]));
}
if app.stats.eol_enriched {
lines.push(Line::from(""));
lines.push(Line::styled(
"EOL Status:",
Style::default().fg(scheme.warning).bold(),
));
let mut eol_counts: std::collections::HashMap<&str, usize> =
std::collections::HashMap::with_capacity(5);
eol_counts.insert("eol", 0);
eol_counts.insert("approaching", 0);
eol_counts.insert("security", 0);
eol_counts.insert("supported", 0);
eol_counts.insert("unknown", 0);
for comp in &group_comps {
let key = eol_status_key(comp.eol.as_ref().map(|e| &e.status));
*eol_counts.entry(key).or_insert(0) += 1;
}
let eol_order = [
("eol", "EOL", scheme.critical),
("approaching", "Near EOL", scheme.high),
("security", "Sec Only", scheme.warning),
("supported", "Supported", scheme.success),
("unknown", "Unknown", scheme.muted),
];
let max_eol = eol_counts.values().copied().max().unwrap_or(1);
for (key, label, color) in &eol_order {
let count = eol_counts.get(key).copied().unwrap_or(0);
if count == 0 {
continue;
}
let bar_len = if max_eol > 0 {
(count * bar_width) / max_eol
} else {
0
};
lines.push(Line::from(vec![
Span::styled(format!(" {label:12}"), Style::default().fg(*color)),
Span::styled(format!("{count:>5} "), Style::default().fg(scheme.text)),
Span::styled("█".repeat(bar_len), Style::default().fg(*color)),
]));
}
}
lines.push(Line::from(""));
let mut vuln_comps: Vec<(&str, usize)> = group_comps
.iter()
.filter(|c| !c.vulnerabilities.is_empty())
.map(|c| (c.name.as_str(), c.vulnerabilities.len()))
.collect();
vuln_comps.sort_by(|a, b| b.1.cmp(&a.1));
if !vuln_comps.is_empty() {
lines.push(Line::styled(
"Most Vulnerable:",
Style::default().fg(scheme.critical).bold(),
));
for (name, count) in vuln_comps.iter().take(5) {
let display = crate::tui::widgets::extract_display_name(name);
lines.push(Line::from(vec![
Span::styled(" ", Style::default()),
Span::styled(
truncate_str(&display, width.saturating_sub(10)),
Style::default().fg(scheme.text),
),
Span::styled(format!(" ({count})"), Style::default().fg(scheme.high)),
]));
}
lines.push(Line::from(""));
}
let mut eco_counts: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
for comp in &group_comps {
let eco = comp
.ecosystem
.as_ref()
.map_or_else(|| "Unknown".to_string(), std::string::ToString::to_string);
*eco_counts.entry(eco).or_insert(0) += 1;
}
if eco_counts.len() > 1 {
lines.push(Line::styled(
"Ecosystems:",
Style::default().fg(scheme.primary).bold(),
));
let mut eco_sorted: Vec<_> = eco_counts.iter().collect();
eco_sorted.sort_by(|a, b| b.1.cmp(a.1));
for (eco, count) in eco_sorted.iter().take(5) {
lines.push(Line::from(vec![
Span::styled(format!(" {eco}: "), Style::default().fg(scheme.text_muted)),
Span::styled(count.to_string(), Style::default().fg(scheme.text)),
]));
}
}
let panel = Paragraph::new(lines)
.block(
Block::default()
.title(format!(" {group_label} "))
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color)),
)
.wrap(Wrap { trim: true });
frame.render_widget(panel, area);
}
fn dependency_tag(rel: &DependencyType) -> &'static str {
match rel {
DependencyType::DevDependsOn => " dev",
DependencyType::BuildDependsOn => " build",
DependencyType::TestDependsOn => " test",
DependencyType::OptionalDependsOn => " opt",
DependencyType::ProvidedDependsOn => " provided",
DependencyType::RuntimeDependsOn => " runtime",
DependencyType::Contains => " contains",
DependencyType::StaticLink => " static",
DependencyType::DynamicLink => " dynamic",
_ => "",
}
}
fn eol_status_key(status: Option<&EolStatus>) -> &'static str {
match status {
Some(EolStatus::EndOfLife) => "eol",
Some(EolStatus::ApproachingEol) => "approaching",
Some(EolStatus::SecurityOnly) => "security",
Some(EolStatus::Supported) => "supported",
_ => "unknown",
}
}