use crate::diff::{ChangeType, DiffResult};
use crate::tui::app::{AlignmentMode, AppMode};
use crate::tui::render_context::RenderContext;
use crate::tui::security::{VersionChange, detect_version_downgrade};
use crate::tui::theme::colors;
use ratatui::{
prelude::*,
widgets::{Block, Borders, Clear, Paragraph, Wrap},
};
#[derive(Debug, Clone)]
pub struct AlignedRow {
pub left_name: Option<String>,
pub left_version: Option<String>,
pub right_name: Option<String>,
pub right_version: Option<String>,
pub change_type: ChangeType,
pub component_id: Option<String>,
}
#[derive(Debug, Clone)]
pub struct DiffSpan {
pub text: String,
pub style: DiffSpanStyle,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiffSpanStyle {
Unchanged,
Removed,
Added,
}
#[derive(Debug, Clone)]
struct UnifiedEntry {
name: String,
old_version: Option<String>,
new_version: Option<String>,
change_type: UnifiedChangeType,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum UnifiedChangeType {
Upgrade,
Downgrade,
Modified,
Added,
Removed,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
enum SemverBump {
Major,
Minor,
Patch,
Unknown,
}
pub fn render_sidebyside(frame: &mut Frame, area: Rect, ctx: &RenderContext) {
match ctx.mode {
AppMode::Diff | AppMode::View => render_diff_sidebyside(frame, area, ctx),
AppMode::MultiDiff | AppMode::Timeline | AppMode::Matrix => {
crate::tui::widgets::render_empty_state_enhanced(
frame,
area,
"⇔",
"Side-by-side view is only available in Diff mode",
Some("This mode compares exactly two SBOMs"),
None,
);
}
}
}
fn render_diff_sidebyside(frame: &mut Frame, area: Rect, ctx: &RenderContext) {
if ctx.side_by_side.search_active {
render_with_search_input(frame, area, ctx);
return;
}
if ctx.side_by_side.show_detail_modal {
render_with_detail_modal(frame, area, ctx);
return;
}
match ctx.side_by_side.alignment_mode {
AlignmentMode::Grouped => render_grouped_mode(frame, area, ctx),
AlignmentMode::Aligned => render_aligned_mode(frame, area, ctx),
AlignmentMode::Unified => render_unified_mode(frame, area, ctx),
}
}
fn render_grouped_mode(frame: &mut Frame, area: Rect, ctx: &RenderContext) {
let main_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(2), Constraint::Min(10), ])
.split(area);
render_sidebyside_context_bar(frame, main_chunks[0], ctx);
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(49),
Constraint::Length(2),
Constraint::Percentage(49),
])
.split(main_chunks[1]);
let left_area = chunks[0];
let divider_area = chunks[1];
let right_area = chunks[2];
let old_name = ctx
.old_sbom
.and_then(|s| s.document.name.clone())
.unwrap_or_else(|| "Old SBOM".to_string());
let new_name = ctx
.new_sbom
.and_then(|s| s.document.name.clone())
.unwrap_or_else(|| "New SBOM".to_string());
let focus_right = ctx.side_by_side.focus_right;
render_old_panel(frame, left_area, ctx, &old_name, !focus_right);
render_divider(frame, divider_area, focus_right);
render_new_panel(frame, right_area, ctx, &new_name, focus_right);
}
fn render_aligned_mode(frame: &mut Frame, area: Rect, ctx: &RenderContext) {
let scheme = colors();
let main_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(2), Constraint::Min(10), ])
.split(area);
render_sidebyside_context_bar(frame, main_chunks[0], ctx);
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(49),
Constraint::Length(2),
Constraint::Percentage(49),
])
.split(main_chunks[1]);
let left_area = chunks[0];
let divider_area = chunks[1];
let right_area = chunks[2];
let rows = build_aligned_rows(ctx);
let scroll = ctx.side_by_side.left_scroll;
let visible_height = (left_area.height.saturating_sub(2)) as usize; let selected = ctx.side_by_side.selected_row;
let search_query = ctx.side_by_side.search_query.clone();
let mut left_lines: Vec<Line> = vec![];
let mut right_lines: Vec<Line> = vec![];
let old_name = ctx
.old_sbom
.and_then(|s| s.document.name.clone())
.unwrap_or_else(|| "Old SBOM".to_string());
let new_name = ctx
.new_sbom
.and_then(|s| s.document.name.clone())
.unwrap_or_else(|| "New SBOM".to_string());
for (idx, row) in rows.iter().enumerate().skip(scroll).take(visible_height) {
let is_selected = idx == selected;
let is_search_match = search_query.as_ref().is_some_and(|q| {
row.left_name.as_ref().is_some_and(|n| n.contains(q))
|| row.right_name.as_ref().is_some_and(|n| n.contains(q))
});
let (left_line, right_line) = render_aligned_row(
row,
is_selected,
is_search_match,
search_query.as_ref(),
&scheme,
);
left_lines.push(left_line);
right_lines.push(right_line);
}
let focus_right = ctx.side_by_side.focus_right;
let left_border_style = if focus_right {
Style::default().fg(scheme.muted)
} else {
Style::default().fg(scheme.removed).bold()
};
let left_panel = Paragraph::new(left_lines).block(
Block::default()
.title(format!(" {old_name} (Old) "))
.title_style(Style::default().fg(scheme.removed).bold())
.borders(Borders::ALL)
.border_style(left_border_style),
);
frame.render_widget(left_panel, left_area);
render_aligned_divider(frame, divider_area, &rows, scroll, visible_height, &scheme);
let right_border_style = if focus_right {
Style::default().fg(scheme.added).bold()
} else {
Style::default().fg(scheme.muted)
};
let right_panel = Paragraph::new(right_lines).block(
Block::default()
.title(format!(" {new_name} (New) "))
.title_style(Style::default().fg(scheme.added).bold())
.borders(Borders::ALL)
.border_style(right_border_style),
);
frame.render_widget(right_panel, right_area);
}
fn build_aligned_rows(ctx: &RenderContext) -> Vec<AlignedRow> {
let mut rows = Vec::new();
let filter = &ctx.side_by_side.filter;
if let Some(result) = ctx.diff_result {
if filter.show_removed {
for comp in &result.components.removed {
rows.push(AlignedRow {
left_name: Some(comp.name.clone()),
left_version: comp.old_version.clone(),
right_name: None,
right_version: None,
change_type: ChangeType::Removed,
component_id: Some(comp.id.clone()), });
}
}
if filter.show_modified {
for comp in &result.components.modified {
rows.push(AlignedRow {
left_name: Some(comp.name.clone()),
left_version: comp.old_version.clone(),
right_name: Some(comp.name.clone()),
right_version: comp.new_version.clone(),
change_type: ChangeType::Modified,
component_id: Some(comp.id.clone()), });
}
}
if filter.show_added {
for comp in &result.components.added {
rows.push(AlignedRow {
left_name: None,
left_version: None,
right_name: Some(comp.name.clone()),
right_version: comp.new_version.clone(),
change_type: ChangeType::Added,
component_id: Some(comp.id.clone()), });
}
}
}
rows
}
fn build_unified_entries(result: &DiffResult) -> Vec<UnifiedEntry> {
let mut entries = Vec::new();
for comp in &result.components.modified {
let change_type = match (comp.old_version.as_deref(), comp.new_version.as_deref()) {
(Some(old), Some(new)) if old != new => match detect_version_downgrade(old, new) {
VersionChange::Downgrade => UnifiedChangeType::Downgrade,
VersionChange::Upgrade => UnifiedChangeType::Upgrade,
VersionChange::NoChange | VersionChange::Unknown => UnifiedChangeType::Modified,
},
_ => UnifiedChangeType::Modified,
};
entries.push(UnifiedEntry {
name: comp.name.clone(),
old_version: comp.old_version.clone(),
new_version: comp.new_version.clone(),
change_type,
});
}
let mut matched_added: std::collections::HashSet<usize> = std::collections::HashSet::new();
for removed_comp in &result.components.removed {
let removed_name_lower = removed_comp.name.to_lowercase();
let matched = result
.components
.added
.iter()
.enumerate()
.find(|(idx, added_comp)| {
!matched_added.contains(idx) && added_comp.name.to_lowercase() == removed_name_lower
});
if let Some((idx, added_comp)) = matched {
matched_added.insert(idx);
let change_type = match (
removed_comp.old_version.as_deref(),
added_comp.new_version.as_deref(),
) {
(Some(old), Some(new)) => match detect_version_downgrade(old, new) {
VersionChange::Downgrade => UnifiedChangeType::Downgrade,
_ => UnifiedChangeType::Upgrade,
},
_ => UnifiedChangeType::Upgrade,
};
entries.push(UnifiedEntry {
name: removed_comp.name.clone(),
old_version: removed_comp.old_version.clone(),
new_version: added_comp.new_version.clone(),
change_type,
});
} else {
entries.push(UnifiedEntry {
name: removed_comp.name.clone(),
old_version: removed_comp.old_version.clone(),
new_version: None,
change_type: UnifiedChangeType::Removed,
});
}
}
for (idx, comp) in result.components.added.iter().enumerate() {
if !matched_added.contains(&idx) {
entries.push(UnifiedEntry {
name: comp.name.clone(),
old_version: None,
new_version: comp.new_version.clone(),
change_type: UnifiedChangeType::Added,
});
}
}
entries.sort_by(|a, b| {
let priority_a = unified_sort_key(a);
let priority_b = unified_sort_key(b);
priority_a.cmp(&priority_b)
});
entries
}
fn unified_sort_key(entry: &UnifiedEntry) -> (u8, SemverBump, String) {
let (priority, bump) = match entry.change_type {
UnifiedChangeType::Upgrade => (
0,
classify_semver_bump(entry.old_version.as_deref(), entry.new_version.as_deref()),
),
UnifiedChangeType::Downgrade => (1, SemverBump::Unknown),
UnifiedChangeType::Modified => (2, SemverBump::Unknown),
UnifiedChangeType::Removed => (3, SemverBump::Unknown),
UnifiedChangeType::Added => (4, SemverBump::Unknown),
};
(priority, bump, entry.name.to_lowercase())
}
fn classify_semver_bump(old: Option<&str>, new: Option<&str>) -> SemverBump {
let (Some(old), Some(new)) = (old, new) else {
return SemverBump::Unknown;
};
let old_parts: Vec<u32> = old
.split('.')
.filter_map(|s| s.trim_start_matches('v').parse().ok())
.collect();
let new_parts: Vec<u32> = new
.split('.')
.filter_map(|s| s.trim_start_matches('v').parse().ok())
.collect();
if old_parts.first() != new_parts.first() {
SemverBump::Major
} else if old_parts.get(1) != new_parts.get(1) {
SemverBump::Minor
} else if old_parts.get(2) != new_parts.get(2) {
SemverBump::Patch
} else {
SemverBump::Unknown
}
}
fn version_badge(
old: Option<&str>,
new: Option<&str>,
scheme: &crate::tui::theme::ColorScheme,
) -> (&'static str, Color) {
match (old, new) {
(Some(o), Some(n)) => match detect_version_downgrade(o, n) {
VersionChange::Downgrade => ("DOWN!", scheme.critical),
VersionChange::NoChange => ("=", scheme.muted),
VersionChange::Upgrade | VersionChange::Unknown => classify_upgrade_badge(o, n, scheme),
},
(None, Some(_)) => ("(new)", scheme.added),
(Some(_), None) => ("(gone)", scheme.removed),
(None, None) => ("", scheme.muted),
}
}
fn classify_upgrade_badge(
old: &str,
new: &str,
scheme: &crate::tui::theme::ColorScheme,
) -> (&'static str, Color) {
let old_parts: Vec<u32> = old
.split('.')
.filter_map(|s| s.trim_start_matches('v').parse().ok())
.collect();
let new_parts: Vec<u32> = new
.split('.')
.filter_map(|s| s.trim_start_matches('v').parse().ok())
.collect();
if old_parts.first() != new_parts.first() {
("MAJOR", scheme.warning)
} else if old_parts.get(1) != new_parts.get(1) {
("minor", scheme.modified)
} else {
("patch", scheme.muted)
}
}
fn render_unified_mode(frame: &mut Frame, area: Rect, ctx: &RenderContext) {
let scheme = colors();
let main_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(2), Constraint::Min(10), ])
.split(area);
render_sidebyside_context_bar(frame, main_chunks[0], ctx);
let content_area = main_chunks[1];
let Some(result) = ctx.diff_result else {
let empty = Paragraph::new("No diff result available")
.style(Style::default().fg(scheme.muted))
.block(
Block::default()
.title(" Unified Upgrade View ")
.borders(Borders::ALL)
.border_style(Style::default().fg(scheme.muted)),
);
frame.render_widget(empty, content_area);
return;
};
let entries = build_unified_entries(result);
let block = Block::default()
.title(" Unified Upgrade View ")
.title_style(Style::default().fg(scheme.accent).bold())
.borders(Borders::ALL)
.border_style(Style::default().fg(scheme.accent));
let inner = block.inner(content_area);
frame.render_widget(block, content_area);
if inner.height < 2 || inner.width < 40 {
return;
}
let buf = frame.buffer_mut();
let x = inner.x;
let col_name: u16 = 1;
let name_width = 25u16.min(inner.width.saturating_sub(42));
let col_old = col_name + name_width + 1;
let col_arrow = col_old + 15;
let col_new = col_arrow + 3;
let col_badge = col_new + 15;
let header_style = Style::default().fg(scheme.muted).bold();
buf.set_string(x + col_name, inner.y, "Component", header_style);
buf.set_string(x + col_old, inner.y, "Old Version", header_style);
buf.set_string(x + col_arrow, inner.y, " ", header_style);
buf.set_string(x + col_new, inner.y, "New Version", header_style);
buf.set_string(x + col_badge, inner.y, "Change", header_style);
if inner.height >= 2 {
let sep: String = "\u{2500}".repeat(inner.width.saturating_sub(1) as usize);
buf.set_string(x, inner.y + 1, &sep, Style::default().fg(scheme.muted));
}
let visible_height = (inner.height.saturating_sub(2)) as usize; let scroll = ctx.side_by_side.left_scroll;
let selected = ctx.side_by_side.selected_row;
for (i, entry) in entries.iter().enumerate().skip(scroll).take(visible_height) {
let y = inner.y + 2 + (i - scroll) as u16;
if y >= inner.y + inner.height {
break;
}
let is_selected = i == selected;
if is_selected {
let sel_style = Style::default().bg(scheme.selection_bg);
for cx in x..(x + inner.width) {
buf[(cx, y)].set_style(sel_style);
}
}
let row_color = match entry.change_type {
UnifiedChangeType::Upgrade => {
match classify_semver_bump(
entry.old_version.as_deref(),
entry.new_version.as_deref(),
) {
SemverBump::Major => scheme.warning,
SemverBump::Minor => scheme.modified,
SemverBump::Patch | SemverBump::Unknown => scheme.muted,
}
}
UnifiedChangeType::Downgrade => scheme.critical,
UnifiedChangeType::Modified => scheme.modified,
UnifiedChangeType::Added => scheme.added,
UnifiedChangeType::Removed => scheme.removed,
};
let name_style = Style::default().fg(row_color);
let version_style = Style::default().fg(scheme.text);
let name_display = if entry.name.len() > name_width as usize {
format!("{}..", &entry.name[..name_width as usize - 2])
} else {
format!("{:<width$}", entry.name, width = name_width as usize)
};
buf.set_string(x + col_name, y, &name_display, name_style);
let old_display = entry
.old_version
.as_deref()
.unwrap_or(match entry.change_type {
UnifiedChangeType::Added => "(new)",
_ => "",
});
let old_style = if entry.change_type == UnifiedChangeType::Added {
Style::default()
.fg(scheme.muted)
.add_modifier(Modifier::DIM)
} else {
version_style
};
buf.set_string(x + col_old, y, format!("{old_display:<15}"), old_style);
let arrow = match entry.change_type {
UnifiedChangeType::Upgrade | UnifiedChangeType::Modified => "\u{2192}",
UnifiedChangeType::Downgrade => "\u{2193}",
UnifiedChangeType::Added => " + ",
UnifiedChangeType::Removed => " x ",
};
let arrow_style = Style::default().fg(row_color);
buf.set_string(x + col_arrow, y, format!(" {arrow} "), arrow_style);
let new_display = entry
.new_version
.as_deref()
.unwrap_or(match entry.change_type {
UnifiedChangeType::Removed => "(removed)",
_ => "",
});
let new_style = if entry.change_type == UnifiedChangeType::Removed {
Style::default()
.fg(scheme.removed)
.add_modifier(Modifier::DIM)
} else {
version_style
};
buf.set_string(x + col_new, y, format!("{new_display:<15}"), new_style);
if col_badge < inner.width {
let (badge_text, badge_color) = version_badge(
entry.old_version.as_deref(),
entry.new_version.as_deref(),
&scheme,
);
let badge_style = if entry.change_type == UnifiedChangeType::Downgrade {
Style::default().fg(badge_color).bold()
} else {
Style::default().fg(badge_color)
};
buf.set_string(x + col_badge, y, badge_text, badge_style);
}
}
if entries.len() > visible_height {
crate::tui::widgets::render_scrollbar(frame, inner, entries.len(), scroll);
}
}
fn render_aligned_row<'a>(
row: &AlignedRow,
is_selected: bool,
is_search_match: bool,
search_query: Option<&String>,
scheme: &crate::tui::theme::ColorScheme,
) -> (Line<'a>, Line<'a>) {
let base_style = if is_selected {
Style::default().bg(scheme.selection_bg)
} else if is_search_match {
Style::default().bg(scheme.search_highlight_bg)
} else {
Style::default()
};
let left_line = row.left_name.as_ref().map_or_else(
|| {
Line::styled(
" ...",
base_style.fg(scheme.muted).add_modifier(Modifier::DIM),
)
},
|name| {
let version = row.left_version.as_deref().unwrap_or("");
let (name_spans, version_spans) =
highlight_with_search(name, version, search_query, scheme, row.change_type, false);
let mut spans = vec![match row.change_type {
ChangeType::Removed => Span::styled("- ", base_style.fg(scheme.removed).bold()),
ChangeType::Modified => Span::styled("~ ", base_style.fg(scheme.modified)),
_ => Span::styled(" ", base_style),
}];
spans.extend(name_spans);
spans.push(Span::styled(" ", base_style));
spans.extend(version_spans);
Line::from(spans)
},
);
let right_line = row.right_name.as_ref().map_or_else(
|| {
Line::styled(
" ...",
base_style.fg(scheme.muted).add_modifier(Modifier::DIM),
)
},
|name| {
let version = row.right_version.as_deref().unwrap_or("");
let (name_spans, version_spans) =
highlight_with_search(name, version, search_query, scheme, row.change_type, true);
let mut spans = vec![match row.change_type {
ChangeType::Added => Span::styled("+ ", base_style.fg(scheme.added).bold()),
ChangeType::Modified => Span::styled("~ ", base_style.fg(scheme.modified)),
_ => Span::styled(" ", base_style),
}];
spans.extend(name_spans);
spans.push(Span::styled(" ", base_style));
spans.extend(version_spans);
Line::from(spans)
},
);
(left_line, right_line)
}
fn highlight_with_search<'a>(
name: &str,
version: &str,
search_query: Option<&String>,
scheme: &crate::tui::theme::ColorScheme,
change_type: ChangeType,
is_right: bool,
) -> (Vec<Span<'a>>, Vec<Span<'a>>) {
let name_color = match change_type {
ChangeType::Added => scheme.added,
ChangeType::Removed => scheme.removed,
ChangeType::Modified => {
if is_right {
scheme.added
} else {
scheme.removed
}
}
ChangeType::Unchanged => scheme.text,
};
let name_spans = search_query.map_or_else(
|| {
vec![Span::styled(
name.to_string(),
Style::default().fg(name_color),
)]
},
|query| {
if query.is_empty() {
vec![Span::styled(
name.to_string(),
Style::default().fg(name_color),
)]
} else {
highlight_search_matches(name, query, name_color, scheme.search_highlight_bg)
}
},
);
let version_spans = vec![Span::styled(
version.to_string(),
Style::default().fg(scheme.muted),
)];
(name_spans, version_spans)
}
fn highlight_search_matches<'a>(
text: &str,
query: &str,
base_color: Color,
highlight_bg: Color,
) -> Vec<Span<'a>> {
if query.is_empty() {
return vec![Span::styled(
text.to_string(),
Style::default().fg(base_color),
)];
}
let mut spans = Vec::new();
let text_lower = text.to_lowercase();
let query_lower = query.to_lowercase();
let mut last_end = 0;
for (start, _) in text_lower.match_indices(&query_lower) {
if start > last_end {
spans.push(Span::styled(
text[last_end..start].to_string(),
Style::default().fg(base_color),
));
}
let end = start + query.len();
spans.push(Span::styled(
text[start..end].to_string(),
Style::default().fg(base_color).bg(highlight_bg).bold(),
));
last_end = end;
}
if last_end < text.len() {
spans.push(Span::styled(
text[last_end..].to_string(),
Style::default().fg(base_color),
));
}
if spans.is_empty() {
spans.push(Span::styled(
text.to_string(),
Style::default().fg(base_color),
));
}
spans
}
pub fn compute_inline_diff(old: &str, new: &str) -> (Vec<DiffSpan>, Vec<DiffSpan>) {
if old == new {
return (
vec![DiffSpan {
text: old.to_string(),
style: DiffSpanStyle::Unchanged,
}],
vec![DiffSpan {
text: new.to_string(),
style: DiffSpanStyle::Unchanged,
}],
);
}
let old_chars: Vec<char> = old.chars().collect();
let new_chars: Vec<char> = new.chars().collect();
let mut old_spans = Vec::new();
let mut new_spans = Vec::new();
let prefix_len = old_chars
.iter()
.zip(new_chars.iter())
.take_while(|(a, b)| a == b)
.count();
let old_remaining = &old_chars[prefix_len..];
let new_remaining = &new_chars[prefix_len..];
let suffix_len = old_remaining
.iter()
.rev()
.zip(new_remaining.iter().rev())
.take_while(|(a, b)| a == b)
.count();
if prefix_len > 0 {
old_spans.push(DiffSpan {
text: old_chars[..prefix_len].iter().collect(),
style: DiffSpanStyle::Unchanged,
});
new_spans.push(DiffSpan {
text: new_chars[..prefix_len].iter().collect(),
style: DiffSpanStyle::Unchanged,
});
}
let old_mid_end = old_chars.len().saturating_sub(suffix_len);
let new_mid_end = new_chars.len().saturating_sub(suffix_len);
if prefix_len < old_mid_end {
old_spans.push(DiffSpan {
text: old_chars[prefix_len..old_mid_end].iter().collect(),
style: DiffSpanStyle::Removed,
});
}
if prefix_len < new_mid_end {
new_spans.push(DiffSpan {
text: new_chars[prefix_len..new_mid_end].iter().collect(),
style: DiffSpanStyle::Added,
});
}
if suffix_len > 0 {
old_spans.push(DiffSpan {
text: old_chars[old_mid_end..].iter().collect(),
style: DiffSpanStyle::Unchanged,
});
new_spans.push(DiffSpan {
text: new_chars[new_mid_end..].iter().collect(),
style: DiffSpanStyle::Unchanged,
});
}
(old_spans, new_spans)
}
fn render_aligned_divider(
frame: &mut Frame,
area: Rect,
rows: &[AlignedRow],
scroll: usize,
visible_height: usize,
scheme: &crate::tui::theme::ColorScheme,
) {
let mut lines: Vec<Line> = Vec::with_capacity(area.height as usize);
lines.push(Line::styled("┬", Style::default().fg(scheme.muted)));
for (idx, row) in rows.iter().enumerate().skip(scroll).take(visible_height) {
let (char, style) = match row.change_type {
ChangeType::Added => ("►", Style::default().fg(scheme.added)),
ChangeType::Removed => ("◄", Style::default().fg(scheme.removed)),
ChangeType::Modified => ("◆", Style::default().fg(scheme.modified)),
ChangeType::Unchanged => ("│", Style::default().fg(scheme.muted)),
};
let _ = idx; lines.push(Line::styled(char, style));
}
while lines.len() < area.height as usize - 1 {
lines.push(Line::styled("│", Style::default().fg(scheme.muted)));
}
lines.push(Line::styled("┴", Style::default().fg(scheme.muted)));
let divider = Paragraph::new(lines).alignment(Alignment::Center);
frame.render_widget(divider, area);
}
fn render_sidebyside_context_bar(frame: &mut Frame, area: Rect, ctx: &RenderContext) {
let scheme = colors();
let state = &ctx.side_by_side;
let mut spans = vec![
Span::styled("Mode: ", Style::default().fg(scheme.text_muted)),
Span::styled(
state.alignment_mode.name(),
Style::default().fg(scheme.accent).bold(),
),
Span::styled(" │ ", Style::default().fg(scheme.muted)),
];
spans.push(Span::styled(
"Focus: ",
Style::default().fg(scheme.text_muted),
));
if state.focus_right {
spans.push(Span::styled(" Old ", Style::default().fg(scheme.removed)));
} else {
spans.push(Span::styled(
" ◄ Old ",
Style::default()
.fg(scheme.badge_fg_light)
.bg(scheme.removed)
.bold(),
));
}
spans.push(Span::styled("│", Style::default().fg(scheme.muted)));
if state.focus_right {
spans.push(Span::styled(
" New ► ",
Style::default()
.fg(scheme.badge_fg_dark)
.bg(scheme.added)
.bold(),
));
} else {
spans.push(Span::styled(" New ", Style::default().fg(scheme.added)));
}
spans.push(Span::styled(" │ ", Style::default().fg(scheme.muted)));
spans.push(Span::styled(
"Sync: ",
Style::default().fg(scheme.text_muted),
));
spans.push(Span::styled(
state.sync_mode.name(),
Style::default().fg(scheme.text),
));
spans.push(Span::styled(" │ ", Style::default().fg(scheme.muted)));
if state.filter.is_filtered() {
spans.push(Span::styled(
"Filter: ",
Style::default().fg(scheme.text_muted),
));
spans.push(Span::styled(
state.filter.summary(),
Style::default().fg(scheme.warning),
));
spans.push(Span::styled(" │ ", Style::default().fg(scheme.muted)));
}
spans.push(Span::styled(
"Change: ",
Style::default().fg(scheme.text_muted),
));
spans.push(Span::styled(
state.change_position(),
Style::default().fg(scheme.modified),
));
spans.push(Span::styled(" │ ", Style::default().fg(scheme.muted)));
if state.search_query.is_some() {
spans.push(Span::styled(
"Search: ",
Style::default().fg(scheme.text_muted),
));
spans.push(Span::styled(
state.match_position(),
Style::default().fg(scheme.accent),
));
spans.push(Span::styled(" │ ", Style::default().fg(scheme.muted)));
}
spans.push(Span::styled("[a]", Style::default().fg(scheme.accent)));
spans.push(Span::styled(
"lign ",
Style::default().fg(scheme.text_muted),
));
spans.push(Span::styled("[/]", Style::default().fg(scheme.accent)));
spans.push(Span::styled(
"search ",
Style::default().fg(scheme.text_muted),
));
spans.push(Span::styled("[n/N]", Style::default().fg(scheme.accent)));
spans.push(Span::styled(
"ext/prev",
Style::default().fg(scheme.text_muted),
));
let context_line = Line::from(spans);
let paragraph = Paragraph::new(context_line).style(Style::default().fg(scheme.text_muted));
frame.render_widget(paragraph, area);
}
fn render_old_panel(frame: &mut Frame, area: Rect, ctx: &RenderContext, name: &str, focused: bool) {
let scheme = colors();
let mut lines: Vec<Line> = vec![];
let filter = &ctx.side_by_side.filter;
if let Some(result) = ctx.diff_result {
lines.push(Line::from(vec![
Span::styled("Removed: ", Style::default().fg(scheme.removed)),
Span::styled(
format!("{}", result.summary.components_removed),
Style::default().fg(scheme.removed).bold(),
),
Span::styled(" │ ", Style::default().fg(scheme.muted)),
Span::styled("Modified: ", Style::default().fg(scheme.modified)),
Span::styled(
format!("{}", result.summary.components_modified),
Style::default().fg(scheme.modified).bold(),
),
]));
lines.push(Line::raw(""));
if filter.show_removed && !result.components.removed.is_empty() {
lines.push(Line::styled(
"─── Removed ───",
Style::default().fg(scheme.removed).bold(),
));
for comp in &result.components.removed {
let version = comp.old_version.as_deref().unwrap_or("");
lines.push(Line::from(vec![
Span::styled("- ", Style::default().fg(scheme.removed).bold()),
Span::styled(&comp.name, Style::default().fg(scheme.removed)),
Span::styled(format!(" {version}"), Style::default().fg(scheme.muted)),
]));
}
lines.push(Line::raw(""));
}
if filter.show_modified && !result.components.modified.is_empty() {
lines.push(Line::styled(
"─── Modified (old) ───",
Style::default().fg(scheme.modified).bold(),
));
for comp in &result.components.modified {
let old_version = comp.old_version.as_deref().unwrap_or("");
let new_version = comp.new_version.as_deref().unwrap_or("");
let (old_spans, _) = compute_inline_diff(old_version, new_version);
let mut line_spans = vec![
Span::styled("~ ", Style::default().fg(scheme.modified)),
Span::styled(&comp.name, Style::default().fg(scheme.modified)),
Span::styled(" ", Style::default()),
];
for span in old_spans {
let style = match span.style {
DiffSpanStyle::Unchanged => Style::default().fg(scheme.muted),
DiffSpanStyle::Removed => Style::default()
.fg(scheme.removed)
.add_modifier(Modifier::CROSSED_OUT),
DiffSpanStyle::Added => Style::default().fg(scheme.added),
};
line_spans.push(Span::styled(span.text, style));
}
lines.push(Line::from(line_spans));
}
}
if !result.vulnerabilities.resolved.is_empty() {
lines.push(Line::raw(""));
lines.push(Line::styled(
"─── Resolved Vulns ───",
Style::default().fg(scheme.added).bold(),
));
for vuln in result.vulnerabilities.resolved.iter().take(10) {
let severity_style = Style::default().fg(scheme.severity_color(&vuln.severity));
lines.push(Line::from(vec![
Span::styled("✓ ", Style::default().fg(scheme.added)),
Span::styled(&vuln.id, severity_style),
Span::styled(
format!(" [{}]", vuln.severity),
Style::default().fg(scheme.muted),
),
]));
}
}
}
let scroll = ctx.side_by_side.left_scroll;
let border_style = if focused {
Style::default().fg(scheme.removed).bold()
} else {
Style::default().fg(scheme.muted)
};
let title_suffix = if focused { " ◄" } else { "" };
let panel = Paragraph::new(lines)
.block(
Block::default()
.title(format!(" {name} (Old){title_suffix} "))
.title_style(Style::default().fg(scheme.removed).bold())
.borders(Borders::ALL)
.border_style(border_style),
)
.scroll((scroll as u16, 0));
frame.render_widget(panel, area);
}
fn render_new_panel(frame: &mut Frame, area: Rect, ctx: &RenderContext, name: &str, focused: bool) {
let scheme = colors();
let mut lines: Vec<Line> = vec![];
let filter = &ctx.side_by_side.filter;
if let Some(result) = ctx.diff_result {
lines.push(Line::from(vec![
Span::styled("Added: ", Style::default().fg(scheme.added)),
Span::styled(
format!("{}", result.summary.components_added),
Style::default().fg(scheme.added).bold(),
),
Span::styled(" │ ", Style::default().fg(scheme.muted)),
Span::styled("Modified: ", Style::default().fg(scheme.modified)),
Span::styled(
format!("{}", result.summary.components_modified),
Style::default().fg(scheme.modified).bold(),
),
]));
lines.push(Line::raw(""));
if filter.show_added && !result.components.added.is_empty() {
lines.push(Line::styled(
"─── Added ───",
Style::default().fg(scheme.added).bold(),
));
for comp in &result.components.added {
let version = comp.new_version.as_deref().unwrap_or("");
lines.push(Line::from(vec![
Span::styled("+ ", Style::default().fg(scheme.added).bold()),
Span::styled(&comp.name, Style::default().fg(scheme.added)),
Span::styled(format!(" {version}"), Style::default().fg(scheme.muted)),
]));
}
lines.push(Line::raw(""));
}
if filter.show_modified && !result.components.modified.is_empty() {
lines.push(Line::styled(
"─── Modified (new) ───",
Style::default().fg(scheme.modified).bold(),
));
for comp in &result.components.modified {
let old_version = comp.old_version.as_deref().unwrap_or("");
let new_version = comp.new_version.as_deref().unwrap_or("");
let (_, new_spans) = compute_inline_diff(old_version, new_version);
let mut line_spans = vec![
Span::styled("~ ", Style::default().fg(scheme.modified)),
Span::styled(&comp.name, Style::default().fg(scheme.modified)),
Span::styled(" ", Style::default()),
];
for span in new_spans {
let style = match span.style {
DiffSpanStyle::Unchanged => Style::default().fg(scheme.muted),
DiffSpanStyle::Removed => Style::default().fg(scheme.removed),
DiffSpanStyle::Added => Style::default()
.fg(scheme.added)
.add_modifier(Modifier::BOLD),
};
line_spans.push(Span::styled(span.text, style));
}
lines.push(Line::from(line_spans));
}
}
if !result.vulnerabilities.introduced.is_empty() {
lines.push(Line::raw(""));
lines.push(Line::styled(
"─── New Vulns ───",
Style::default().fg(scheme.removed).bold(),
));
for vuln in result.vulnerabilities.introduced.iter().take(10) {
let severity_style = Style::default().fg(scheme.severity_color(&vuln.severity));
lines.push(Line::from(vec![
Span::styled("! ", Style::default().fg(scheme.removed).bold()),
Span::styled(&vuln.id, severity_style.bold()),
Span::styled(
format!(" [{}]", vuln.severity),
Style::default().fg(scheme.muted),
),
]));
}
}
}
let scroll = ctx.side_by_side.right_scroll;
let border_style = if focused {
Style::default().fg(scheme.added).bold()
} else {
Style::default().fg(scheme.muted)
};
let title_suffix = if focused { " ◄" } else { "" };
let panel = Paragraph::new(lines)
.block(
Block::default()
.title(format!(" {name} (New){title_suffix} "))
.title_style(Style::default().fg(scheme.added).bold())
.borders(Borders::ALL)
.border_style(border_style),
)
.scroll((scroll as u16, 0));
frame.render_widget(panel, area);
}
fn render_divider(frame: &mut Frame, area: Rect, focus_right: bool) {
let scheme = colors();
let height = area.height;
let mut lines: Vec<Line> = Vec::with_capacity(height as usize);
for i in 0..height {
let (char, style) = if i == 0 || i == height - 1 {
("│", Style::default().fg(scheme.muted))
} else if i == height / 2 {
if focus_right {
("►", Style::default().fg(scheme.added).bold())
} else {
("◄", Style::default().fg(scheme.removed).bold())
}
} else {
("│", Style::default().fg(scheme.muted))
};
lines.push(Line::styled(char, style));
}
let divider = Paragraph::new(lines).alignment(Alignment::Center);
frame.render_widget(divider, area);
}
fn render_with_search_input(frame: &mut Frame, area: Rect, ctx: &RenderContext) {
let scheme = colors();
match ctx.side_by_side.alignment_mode {
AlignmentMode::Grouped => render_grouped_mode(frame, area, ctx),
AlignmentMode::Aligned => render_aligned_mode(frame, area, ctx),
AlignmentMode::Unified => render_unified_mode(frame, area, ctx),
}
let search_area = Rect {
x: area.x + 2,
y: area.y + area.height - 3,
width: area.width.saturating_sub(4).min(60),
height: 3,
};
frame.render_widget(Clear, search_area);
let query = ctx.side_by_side.search_query.as_deref().unwrap_or("");
let match_info = if !ctx.side_by_side.search_matches.is_empty() {
format!(" ({} matches)", ctx.side_by_side.search_matches.len())
} else if !query.is_empty() {
" (no matches)".to_string()
} else {
String::new()
};
let search_text = format!("/{query}{match_info}");
let search_input = Paragraph::new(search_text)
.style(Style::default().fg(scheme.text))
.block(
Block::default()
.title(" Search ")
.title_style(Style::default().fg(scheme.accent).bold())
.borders(Borders::ALL)
.border_style(Style::default().fg(scheme.accent)),
);
frame.render_widget(search_input, search_area);
}
fn render_with_detail_modal(frame: &mut Frame, area: Rect, ctx: &RenderContext) {
let scheme = colors();
match ctx.side_by_side.alignment_mode {
AlignmentMode::Grouped => render_grouped_mode(frame, area, ctx),
AlignmentMode::Aligned => render_aligned_mode(frame, area, ctx),
AlignmentMode::Unified => render_unified_mode(frame, area, ctx),
}
let modal_width = (f32::from(area.width) * 0.8) as u16;
let modal_height = (f32::from(area.height) * 0.7) as u16;
let modal_x = area.x + (area.width - modal_width) / 2;
let modal_y = area.y + (area.height - modal_height) / 2;
let modal_area = Rect {
x: modal_x,
y: modal_y,
width: modal_width,
height: modal_height,
};
frame.render_widget(Clear, modal_area);
let mut content_lines: Vec<Line> = vec![];
let rows = build_aligned_rows(ctx);
if let Some(row) = rows.get(ctx.side_by_side.selected_row) {
let component_name = row
.left_name
.as_ref()
.or(row.right_name.as_ref())
.cloned()
.unwrap_or_default();
let old_ver = row
.left_version
.clone()
.unwrap_or_else(|| "(none)".to_string());
let new_ver = row
.right_version
.clone()
.unwrap_or_else(|| "(none)".to_string());
let change_type = row.change_type;
let component_id = row.component_id.clone();
content_lines.push(Line::from(vec![
Span::styled("Component: ", Style::default().fg(scheme.text_muted)),
Span::styled(component_name, Style::default().fg(scheme.text).bold()),
]));
content_lines.push(Line::raw(""));
let change_label = match change_type {
ChangeType::Added => ("ADDED", scheme.added),
ChangeType::Removed => ("REMOVED", scheme.removed),
ChangeType::Modified => ("MODIFIED", scheme.modified),
ChangeType::Unchanged => ("UNCHANGED", scheme.text_muted),
};
content_lines.push(Line::from(vec![
Span::styled("Status: ", Style::default().fg(scheme.text_muted)),
Span::styled(change_label.0, Style::default().fg(change_label.1).bold()),
]));
content_lines.push(Line::raw(""));
content_lines.push(Line::styled(
"─── Version Comparison ───",
Style::default().fg(scheme.accent),
));
content_lines.push(Line::from(vec![
Span::styled("Old: ", Style::default().fg(scheme.removed)),
Span::styled(old_ver.clone(), Style::default().fg(scheme.text)),
]));
content_lines.push(Line::from(vec![
Span::styled("New: ", Style::default().fg(scheme.added)),
Span::styled(new_ver.clone(), Style::default().fg(scheme.text)),
]));
if change_type == ChangeType::Modified {
content_lines.push(Line::raw(""));
content_lines.push(Line::styled(
"─── Inline Diff ───",
Style::default().fg(scheme.accent),
));
let (old_spans, new_spans) = compute_inline_diff(&old_ver, &new_ver);
let mut old_line = vec![Span::styled("Old: ", Style::default().fg(scheme.removed))];
for span in old_spans {
let style = match span.style {
DiffSpanStyle::Unchanged => Style::default().fg(scheme.text),
DiffSpanStyle::Removed => Style::default()
.fg(scheme.removed)
.bg(scheme.error_bg)
.bold(),
DiffSpanStyle::Added => Style::default().fg(scheme.added),
};
old_line.push(Span::styled(span.text, style));
}
content_lines.push(Line::from(old_line));
let mut new_line = vec![Span::styled("New: ", Style::default().fg(scheme.added))];
for span in new_spans {
let style = match span.style {
DiffSpanStyle::Unchanged => Style::default().fg(scheme.text),
DiffSpanStyle::Removed => Style::default().fg(scheme.removed),
DiffSpanStyle::Added => Style::default()
.fg(scheme.added)
.bg(scheme.success_bg)
.bold(),
};
new_line.push(Span::styled(span.text, style));
}
content_lines.push(Line::from(new_line));
}
if let Some(result) = ctx.diff_result {
let comp_id = component_id.as_deref().unwrap_or("");
let related_vulns: Vec<_> = result
.vulnerabilities
.introduced
.iter()
.chain(result.vulnerabilities.resolved.iter())
.filter(|v| v.component_id == comp_id) .collect();
if !related_vulns.is_empty() {
content_lines.push(Line::raw(""));
content_lines.push(Line::styled(
"─── Related Vulnerabilities ───",
Style::default().fg(scheme.accent),
));
for vuln in related_vulns.iter().take(5) {
let severity_color = scheme.severity_color(&vuln.severity);
content_lines.push(Line::from(vec![
Span::styled(vuln.id.clone(), Style::default().fg(severity_color).bold()),
Span::styled(
format!(" [{}]", vuln.severity),
Style::default().fg(scheme.text_muted),
),
]));
}
}
}
}
content_lines.push(Line::raw(""));
content_lines.push(Line::styled(
"Press Esc or Enter to close",
Style::default().fg(scheme.text_muted),
));
let modal = Paragraph::new(content_lines)
.wrap(Wrap { trim: false })
.block(
Block::default()
.title(" Component Details ")
.title_style(Style::default().fg(scheme.accent).bold())
.borders(Borders::ALL)
.border_style(Style::default().fg(scheme.accent)),
);
frame.render_widget(modal, modal_area);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_compute_inline_diff_same() {
let (old, new) = compute_inline_diff("1.2.3", "1.2.3");
assert_eq!(old.len(), 1);
assert_eq!(old[0].style, DiffSpanStyle::Unchanged);
assert_eq!(new.len(), 1);
assert_eq!(new[0].style, DiffSpanStyle::Unchanged);
}
#[test]
fn test_compute_inline_diff_version_change() {
let (old, new) = compute_inline_diff("1.2.3", "1.2.4");
assert!(old.iter().any(|s| s.style == DiffSpanStyle::Removed));
assert!(new.iter().any(|s| s.style == DiffSpanStyle::Added));
}
#[test]
fn test_highlight_search_matches() {
let spans = highlight_search_matches("lodash", "das", Color::White, Color::Yellow);
assert!(spans.len() >= 2); }
#[test]
fn test_highlight_search_no_match() {
let spans = highlight_search_matches("lodash", "xyz", Color::White, Color::Yellow);
assert_eq!(spans.len(), 1);
}
}