use crate::diff::ComponentChange;
use crate::tui::app::{AppMode, ComponentFilter};
use crate::tui::render_context::RenderContext;
use crate::tui::theme::colors;
use crate::tui::widgets;
use ratatui::{
prelude::*,
widgets::{Block, Borders, Cell, Paragraph, Row, Table, TableState},
};
pub enum ComponentListData<'a> {
Diff(Vec<&'a ComponentChange>),
Empty,
}
pub fn render_components(frame: &mut Frame, area: Rect, ctx: &RenderContext) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(widgets::FILTER_BAR_HEIGHT),
Constraint::Min(10),
])
.split(area);
render_filter_bar(frame, chunks[0], ctx);
let total_unfiltered = match ctx.mode {
AppMode::Diff | AppMode::View => ctx.diff_result.map_or(0, |r| r.components.total()),
AppMode::MultiDiff | AppMode::Timeline | AppMode::Matrix => 0,
};
let component_data = match ctx.mode {
AppMode::Diff | AppMode::View => ComponentListData::Diff(ctx.diff_component_items()),
AppMode::MultiDiff | AppMode::Timeline | AppMode::Matrix => ComponentListData::Empty,
};
let content_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints(widgets::MASTER_DETAIL_SPLIT)
.split(chunks[1]);
render_component_table(
frame,
content_chunks[0],
ctx,
&component_data,
total_unfiltered,
);
render_detail_panel(frame, content_chunks[1], ctx, &component_data);
}
fn render_filter_bar(frame: &mut Frame, area: Rect, ctx: &RenderContext) {
use crate::tui::viewmodel::security_filter::QuickFilter;
let filter = ctx.components.filter;
let sort = &ctx.components.sort_by;
let multi_select = ctx.components.multi_select_mode;
let selection_count = ctx.components.selection_count();
let mut filter_spans = vec![
Span::styled("Filter: ", Style::default().fg(colors().text_muted)),
status_badge(filter.label(), filter_color(filter)),
Span::raw(" "),
Span::styled("Sort: ", Style::default().fg(colors().text_muted)),
Span::styled(
format!("{sort:?}"),
Style::default().fg(colors().accent).bold(),
),
];
if multi_select {
filter_spans.push(Span::raw(" "));
filter_spans.push(Span::styled(
format!(" ✓ SELECT: {selection_count} "),
Style::default()
.fg(colors().badge_fg_dark)
.bg(colors().secondary)
.bold(),
));
}
let security_filter = &ctx.components.security_filter;
if security_filter.has_active_filters() {
filter_spans.push(Span::raw(" "));
filter_spans.push(Span::styled("│", Style::default().fg(colors().border)));
filter_spans.push(Span::raw(" "));
for quick_filter in QuickFilter::all() {
if quick_filter.is_active(&security_filter.criteria) {
let label = quick_filter.label();
filter_spans.push(Span::styled(
format!(" {label} "),
Style::default()
.fg(colors().badge_fg_dark)
.bg(colors().accent)
.bold(),
));
filter_spans.push(Span::raw(" "));
}
}
}
filter_spans.extend(vec![
Span::raw(" │ "),
Span::styled("[f]", Style::default().fg(colors().accent)),
Span::styled(" filter ", Style::default().fg(colors().text_muted)),
Span::styled("[s]", Style::default().fg(colors().accent)),
Span::styled(" sort ", Style::default().fg(colors().text_muted)),
Span::styled("[", Style::default().fg(colors().accent)),
]);
for (i, qf) in QuickFilter::all().iter().enumerate() {
if i > 0 {
filter_spans.push(Span::styled("/", Style::default().fg(colors().border)));
}
filter_spans.push(Span::styled(
qf.shortcut().to_string(),
Style::default().fg(colors().accent),
));
}
filter_spans.extend(vec![
Span::styled("]", Style::default().fg(colors().accent)),
Span::styled(" quick ", Style::default().fg(colors().text_muted)),
Span::styled("[v]", Style::default().fg(colors().accent)),
Span::styled(
if multi_select {
" exit select"
} else {
" multi-select"
},
Style::default().fg(colors().text_muted),
),
]);
let paragraph = Paragraph::new(Line::from(filter_spans))
.block(
Block::default()
.borders(Borders::BOTTOM)
.border_style(Style::default().fg(colors().border)),
)
.style(Style::default());
frame.render_widget(paragraph, area);
}
fn filter_color(filter: ComponentFilter) -> Color {
match filter {
ComponentFilter::All => colors().primary,
ComponentFilter::Added => colors().added,
ComponentFilter::Removed => colors().removed,
ComponentFilter::Modified => colors().modified,
ComponentFilter::EolOnly => colors().critical,
ComponentFilter::EolRisk => colors().high,
}
}
fn status_badge(text: &str, color: Color) -> Span<'static> {
Span::styled(
format!(" {text} "),
Style::default().fg(colors().badge_fg_dark).bg(color).bold(),
)
}
fn render_component_table(
frame: &mut Frame,
area: Rect,
ctx: &RenderContext,
component_data: &ComponentListData,
total_unfiltered: usize,
) {
let is_diff = matches!(component_data, ComponentListData::Diff(_));
let header_cells: Vec<Cell> = if is_diff {
[
"",
"Name",
"Old Version",
"New Version",
"Ecosystem",
"Changes",
]
.into_iter()
.map(|h| Cell::from(h).style(Style::default().fg(colors().accent).bold()))
.collect()
} else {
["", "Name", "Version", "", "Ecosystem", "Staleness", "EOL"]
.into_iter()
.map(|h| Cell::from(h).style(Style::default().fg(colors().accent).bold()))
.collect()
};
let header = Row::new(header_cells).height(1);
let rows: Vec<Row> = match component_data {
ComponentListData::Diff(components) => get_diff_rows(ctx, components),
ComponentListData::Empty => vec![],
};
if rows.is_empty() {
if total_unfiltered == 0 {
widgets::render_empty_state_enhanced(
frame,
area,
"--",
"No components found",
Some("The SBOM contains no component entries"),
None,
);
} else {
widgets::render_no_results_state(frame, area, "Filter", ctx.components.filter.label());
}
return;
}
let widths: Vec<Constraint> = if is_diff {
vec![
Constraint::Length(12),
Constraint::Min(16),
Constraint::Length(11),
Constraint::Length(11),
Constraint::Length(9),
Constraint::Length(7),
]
} else {
vec![
Constraint::Length(12),
Constraint::Min(14),
Constraint::Length(10),
Constraint::Length(0),
Constraint::Length(9),
Constraint::Length(9),
Constraint::Length(12),
]
};
let selected_idx = ctx.components.selected;
let scheme = colors();
let table_focused = !ctx.components.focus_detail;
let table_border_color = if table_focused {
scheme.border_focused
} else {
scheme.border
};
let table_title_style = if table_focused {
Style::default().fg(scheme.border_focused).bold()
} else {
Style::default().fg(scheme.text_muted)
};
let table = Table::new(rows.clone(), widths)
.header(header)
.block(
Block::default()
.title(format!(" Components ({}) ", rows.len()))
.title_style(table_title_style)
.borders(Borders::ALL)
.border_style(Style::default().fg(table_border_color)),
)
.row_highlight_style(
Style::default()
.bg(colors().selection)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol("▶ ");
let mut state = TableState::default()
.with_offset(ctx.components.scroll_offset)
.with_selected(Some(selected_idx));
frame.render_stateful_widget(table, area, &mut state);
let scroll_offset = state.offset();
if rows.len() > area.height.saturating_sub(3) as usize {
widgets::render_scrollbar(
frame,
area.inner(Margin {
vertical: 1,
horizontal: 0,
}),
rows.len(),
scroll_offset,
);
}
}
fn render_detail_panel(
frame: &mut Frame,
area: Rect,
ctx: &RenderContext,
component_data: &ComponentListData,
) {
match component_data {
ComponentListData::Diff(components) => render_diff_detail(frame, area, ctx, components),
ComponentListData::Empty => {}
}
}
fn render_diff_detail(
frame: &mut Frame,
area: Rect,
ctx: &RenderContext,
components: &[&ComponentChange],
) {
let selected = ctx.components.selected;
if let Some(comp) = components.get(selected) {
let change_type = &comp.change_type;
let (status_text, status_color, status_symbol) = match change_type {
crate::diff::ChangeType::Added => ("ADDED", colors().added, "+"),
crate::diff::ChangeType::Removed => ("REMOVED", colors().removed, "-"),
crate::diff::ChangeType::Modified => ("MODIFIED", colors().modified, "~"),
crate::diff::ChangeType::Unchanged => ("UNCHANGED", colors().muted, "="),
};
let mut lines = vec![
Line::from(vec![
Span::styled(
format!(" {status_symbol} {status_text} "),
Style::default()
.fg(colors().badge_fg_dark)
.bg(status_color)
.bold(),
),
Span::styled(
format!(" Change Weight: {}", comp.cost),
Style::default().fg(colors().text_muted),
),
]),
Line::from(""),
Line::from(vec![
Span::styled("Name: ", Style::default().fg(colors().text_muted)),
Span::styled(&comp.name, Style::default().fg(colors().text).bold()),
]),
Line::from(vec![
Span::styled("ID: ", Style::default().fg(colors().text_muted)),
Span::styled(&comp.id, Style::default().fg(colors().text)),
]),
];
match (&comp.old_version, &comp.new_version) {
(Some(old), Some(new)) if old != new => {
lines.push(Line::from(vec![
Span::styled("Version: ", Style::default().fg(colors().text_muted)),
Span::styled(old, Style::default().fg(colors().removed)),
Span::styled(" → ", Style::default().fg(colors().text_muted)),
Span::styled(new, Style::default().fg(colors().added)),
]));
}
(Some(old), None) => {
lines.push(Line::from(vec![
Span::styled("Version: ", Style::default().fg(colors().text_muted)),
Span::styled(old, Style::default().fg(colors().removed)),
Span::styled(" (removed)", Style::default().fg(colors().text_muted)),
]));
}
(None, Some(new)) => {
lines.push(Line::from(vec![
Span::styled("Version: ", Style::default().fg(colors().text_muted)),
Span::styled(new, Style::default().fg(colors().added)),
Span::styled(" (new)", Style::default().fg(colors().text_muted)),
]));
}
(Some(ver), Some(_)) => {
lines.push(Line::from(vec![
Span::styled("Version: ", Style::default().fg(colors().text_muted)),
Span::styled(ver, Style::default().fg(colors().text)),
]));
}
_ => {}
}
if let (Some(old_ver), Some(new_ver)) = (&comp.old_version, &comp.new_version) {
use crate::tui::security::{
VersionChange, analyze_downgrade, detect_version_downgrade,
};
let version_change = detect_version_downgrade(old_ver, new_ver);
if version_change == VersionChange::Downgrade {
let downgrade_severity = analyze_downgrade(old_ver, new_ver);
let (warning_text, warning_color) = match downgrade_severity {
Some(crate::tui::security::DowngradeSeverity::Major) => (
"⚠ MAJOR DOWNGRADE - Supply chain attack risk!",
colors().critical,
),
Some(crate::tui::security::DowngradeSeverity::Suspicious) => (
"⚠ SUSPICIOUS - Security patch may be removed!",
colors().critical,
),
Some(crate::tui::security::DowngradeSeverity::Minor) => {
("⚠ Version Downgrade Detected", colors().warning)
}
None => ("⚠ Downgrade", colors().warning),
};
lines.push(Line::from(vec![Span::styled(
format!(" {warning_text} "),
Style::default()
.fg(colors().badge_fg_dark)
.bg(warning_color)
.bold(),
)]));
}
}
if let Some(eco) = &comp.ecosystem {
lines.push(Line::from(vec![
Span::styled("Ecosystem: ", Style::default().fg(colors().text_muted)),
Span::styled(eco, Style::default().fg(colors().secondary)),
]));
}
let depth = ctx
.dependencies
.cached_depths
.get(comp.id.as_str())
.copied();
if let Some(d) = depth {
let label = match d {
0 => "Root",
1 => "Direct",
_ => "Transitive",
};
let depth_color = match d {
0 => colors().primary,
1 => colors().accent,
_ => colors().text_muted,
};
lines.push(Line::from(vec![
Span::styled("Depth: ", Style::default().fg(colors().text_muted)),
Span::styled(format!("D{d}"), Style::default().fg(depth_color).bold()),
Span::styled(
format!(" ({label})"),
Style::default().fg(colors().text_muted),
),
]));
}
let deps_out = ctx
.dependencies
.cached_graph
.get(comp.id.as_str())
.map_or(0, Vec::len);
let deps_in = ctx
.dependencies
.cached_reverse_graph
.get(comp.id.as_str())
.map_or(0, Vec::len);
if deps_out > 0 || deps_in > 0 {
lines.push(Line::from(vec![
Span::styled("Dependencies: ", Style::default().fg(colors().text_muted)),
Span::styled(deps_out.to_string(), Style::default().fg(colors().primary)),
Span::styled(" Dependents: ", Style::default().fg(colors().text_muted)),
Span::styled(deps_in.to_string(), Style::default().fg(colors().primary)),
]));
}
let full_component = ctx.new_sbom.as_ref().and_then(|sbom| {
let canonical_id = crate::model::CanonicalId::from_format_id(&comp.id);
sbom.components.get(&canonical_id)
});
if let Some(full_comp) = full_component
&& !full_comp.hashes.is_empty()
{
for hash in full_comp.hashes.iter().take(2) {
let truncated_value = if hash.value.len() > 16 {
format!("{}...", &hash.value[..16])
} else {
hash.value.clone()
};
lines.push(Line::from(vec![
Span::styled(
format!("{}: ", hash.algorithm),
Style::default().fg(colors().text_muted),
),
Span::styled(truncated_value, Style::default().fg(colors().text)),
]));
}
if full_comp.hashes.len() > 2 {
lines.push(Line::styled(
format!(" ... and {} more", full_comp.hashes.len() - 2),
Style::default().fg(colors().text_muted),
));
}
}
if let Some(match_info) = &comp.match_info {
let scheme = colors();
let score_color = if match_info.score >= 0.9 {
scheme.success
} else if match_info.score >= 0.7 {
scheme.warning
} else {
scheme.error
};
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::styled("━━━ ", Style::default().fg(scheme.border)),
Span::styled("Match", Style::default().fg(scheme.accent).bold()),
Span::styled(" ━━━", Style::default().fg(scheme.border)),
]));
lines.push(Line::from(vec![
Span::styled("Score: ", Style::default().fg(scheme.text_muted)),
Span::styled(
format!("{:.2}", match_info.score),
Style::default().fg(score_color).bold(),
),
Span::styled(" via ", Style::default().fg(scheme.text_muted)),
Span::styled(&match_info.method, Style::default().fg(scheme.secondary)),
]));
if !match_info.reason.is_empty() {
lines.push(Line::from(vec![
Span::styled("Reason: ", Style::default().fg(scheme.text_muted)),
Span::styled(
widgets::truncate_str(&match_info.reason, area.width as usize - 10),
Style::default().fg(scheme.text),
),
]));
}
for sc in match_info.score_breakdown.iter().take(3) {
lines.push(Line::from(vec![
Span::styled(" \u{2022} ", Style::default().fg(scheme.text_muted)),
Span::styled(&sc.name, Style::default().fg(scheme.accent)),
Span::styled(
format!(" {:.2} (w={:.1})", sc.raw_score, sc.weight),
Style::default().fg(scheme.text_muted),
),
]));
}
}
let non_version_changes: Vec<_> = comp
.field_changes
.iter()
.filter(|c| c.field != "version")
.collect();
if !non_version_changes.is_empty() {
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::styled("━━━ ", Style::default().fg(colors().border)),
Span::styled("Changes", Style::default().fg(colors().modified).bold()),
Span::styled(" ━━━", Style::default().fg(colors().border)),
]));
for change in &non_version_changes {
let old_val = change.old_value.as_deref().unwrap_or("(none)");
let new_val = change.new_value.as_deref().unwrap_or("(none)");
lines.push(Line::from(vec![
Span::styled(" • ", Style::default().fg(colors().text_muted)),
Span::styled(&change.field, Style::default().fg(colors().accent)),
]));
lines.push(Line::from(vec![
Span::styled(" - ", Style::default().fg(colors().removed)),
Span::styled(
widgets::truncate_str(old_val, area.width as usize - 8),
Style::default().fg(colors().removed),
),
]));
lines.push(Line::from(vec![
Span::styled(" + ", Style::default().fg(colors().added)),
Span::styled(
widgets::truncate_str(new_val, area.width as usize - 8),
Style::default().fg(colors().added),
),
]));
}
}
let related_vulns: Vec<_> = ctx
.diff_result
.map(|r| {
r.vulnerabilities
.introduced
.iter()
.filter(|v| v.component_id == comp.id) .collect::<Vec<_>>()
})
.unwrap_or_default();
if !related_vulns.is_empty() {
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::styled("━━━ ", Style::default().fg(colors().border)),
Span::styled(
format!("⚠ Vulnerabilities ({})", related_vulns.len()),
Style::default().fg(colors().high).bold(),
),
Span::styled(" ━━━", Style::default().fg(colors().border)),
]));
let vuln_entries: Vec<(&str, &str, Option<&str>)> = related_vulns
.iter()
.map(|v| (v.severity.as_str(), v.id.as_str(), v.description.as_deref()))
.collect();
lines.extend(
crate::tui::shared::components::render_vulnerability_list_lines(
&vuln_entries,
5,
related_vulns.len(),
area.width,
),
);
}
let reverse_graph = &ctx.dependencies.cached_reverse_graph;
let (direct_deps, transitive_count) =
crate::tui::shared::components::compute_blast_radius(&comp.name, reverse_graph);
let license_text = ctx
.new_sbom
.and_then(|sbom| {
let canonical_id = crate::model::CanonicalId::from_format_id(&comp.id);
sbom.components.get(&canonical_id)
})
.and_then(|c| c.licenses.declared.first())
.map_or("Unknown", |l| l.expression.as_str());
lines.extend(
crate::tui::shared::components::render_security_analysis_lines(
related_vulns.len(),
direct_deps,
transitive_count,
license_text,
),
);
let is_flagged = ctx.security_cache.is_flagged(&comp.name);
lines.extend(crate::tui::shared::components::render_flagged_lines(
is_flagged,
ctx.security_cache.get_note(&comp.name),
area.width,
"",
));
lines.extend(crate::tui::shared::components::render_quick_actions_hint(
!related_vulns.is_empty(),
));
crate::tui::shared::components::render_detail_block(
frame,
area,
lines,
" Component Details ",
ctx.components.focus_detail,
);
} else {
render_empty_detail(frame, area, ctx.components.focus_detail);
}
}
fn render_empty_detail(frame: &mut Frame, area: Rect, focused: bool) {
crate::tui::shared::components::render_empty_detail_panel(
frame,
area,
" Component Details ",
"--",
"Select a component to view details",
&[("[↑↓]", " navigate "), ("[p]", " toggle focus")],
focused,
);
}
fn get_diff_rows(ctx: &RenderContext, components: &[&ComponentChange]) -> Vec<Row<'static>> {
let multi_select = ctx.components.multi_select_mode;
components
.iter()
.enumerate()
.map(|(idx, comp)| {
let is_selected = ctx.components.is_selected(idx);
let checkbox = if multi_select {
if is_selected { "☑ " } else { "☐ " }
} else {
""
};
let scheme = colors();
let (label, status_bg, status_fg, row_style) = match comp.change_type {
crate::diff::ChangeType::Added => (
" + ADDED ",
scheme.added,
scheme.badge_fg_dark,
Style::default().fg(scheme.added),
),
crate::diff::ChangeType::Removed => (
" - REMOVED ",
scheme.removed,
scheme.badge_fg_light,
Style::default().fg(scheme.removed),
),
crate::diff::ChangeType::Modified => (
" ~ MODIFIED ",
scheme.modified,
scheme.badge_fg_dark,
Style::default().fg(scheme.modified),
),
crate::diff::ChangeType::Unchanged => (
" = SAME ",
scheme.muted,
scheme.badge_fg_light,
Style::default().fg(scheme.text),
),
};
let row_style = if is_selected {
row_style.bg(scheme.selection)
} else {
row_style
};
let new_version_cell = if let (Some(old_ver), Some(new_ver)) =
(&comp.old_version, &comp.new_version)
{
use crate::tui::security::{VersionChange, detect_version_downgrade};
if detect_version_downgrade(old_ver, new_ver) == VersionChange::Downgrade {
Cell::from(Line::from(vec![
Span::raw(new_ver.clone()),
Span::styled(" \u{2193}DG", Style::default().fg(colors().critical).bold()),
]))
} else {
Cell::from(
comp.new_version
.clone()
.unwrap_or_else(|| "\u{2014}".to_string()),
)
}
} else {
Cell::from(
comp.new_version
.clone()
.unwrap_or_else(|| "\u{2014}".to_string()),
)
};
Row::new(vec![
Cell::from(Span::styled(
format!("{checkbox}{label}"),
Style::default().fg(status_fg).bg(status_bg).bold(),
)),
Cell::from(comp.name.clone()),
Cell::from(
comp.old_version
.clone()
.unwrap_or_else(|| "\u{2014}".to_string()),
),
new_version_cell,
Cell::from(comp.ecosystem.clone().unwrap_or_else(|| "-".to_string())),
Cell::from(if comp.field_changes.is_empty() {
"-".to_string()
} else {
comp.field_changes.len().to_string()
}),
])
.style(row_style)
})
.collect()
}