use ratatui::{
Frame,
layout::Rect,
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph, Wrap},
};
use unicode_width::UnicodeWidthStr;
use crate::app::{App, ExpandDirection, FocusedPanel, GAP_EXPAND_BATCH, GapId, InputMode};
use crate::theme::Theme;
use crate::ui::app_layout::{
apply_horizontal_scroll, comment_type_presentation, cursor_indicator, cursor_indicator_spaced,
diff_stat_title, expand_tabs, render_collapsed_placeholder, render_expander_line,
render_hidden_lines, render_orphans_section, render_session_orphans,
scroll_comment_input_into_view, truncate_or_pad, truncate_or_pad_spans,
};
use crate::ui::{comment_panel, styles};
use travelagent_core::model::{LineOrigin, LineRange, LineSide};
use travelagent_core::vcs::git::calculate_gap;
type SideBySideCursorInfo = (usize, u16, usize, usize);
struct SideBySideContext<'a> {
app: &'a App,
theme: &'a Theme,
content_width: usize,
current_line_idx: usize,
comment_input_mode: bool,
comment_line: Option<(u32, LineSide)>,
comment_type: travelagent_core::model::CommentType,
comment_buffer: &'a str,
comment_cursor: usize,
comment_line_range: Option<LineRange>,
editing_comment_id: Option<&'a str>,
supports_keyboard_enhancement: bool,
}
fn add_comments_to_line(
line_num: u32,
line_comments: &std::collections::HashMap<u32, Vec<travelagent_core::model::Comment>>,
side: LineSide,
ctx: &SideBySideContext,
mut line_idx: usize,
lines: &mut Vec<Line>,
row_origins: &mut Vec<SbsRowBg>,
) -> (usize, Option<SideBySideCursorInfo>) {
let is_line_comment_mode = ctx.comment_input_mode && ctx.comment_line == Some((line_num, side));
let mut cursor_info_out: Option<SideBySideCursorInfo> = None;
if let Some(comments) = line_comments.get(&line_num) {
for comment in comments {
let comment_side = comment.side.unwrap_or(LineSide::New);
if (side == LineSide::Old && comment_side == LineSide::Old)
|| (side == LineSide::New && comment_side != LineSide::Old)
{
let is_being_edited =
is_line_comment_mode && ctx.editing_comment_id == Some(comment.id.as_str());
if is_being_edited {
let line_range = ctx
.comment_line_range
.or_else(|| Some(LineRange::single(line_num)));
let (input_lines, cursor_info) = comment_panel::format_comment_input_lines(
ctx.theme,
comment_type_presentation(ctx.app, &ctx.comment_type),
ctx.comment_buffer,
ctx.comment_cursor,
line_range,
true,
ctx.supports_keyboard_enhancement,
);
let box_end = line_idx + input_lines.len().saturating_sub(1);
cursor_info_out = Some((
line_idx + cursor_info.line_offset,
1 + cursor_info.column,
line_idx,
box_end,
));
for mut input_line in input_lines {
let indicator = cursor_indicator(line_idx, ctx.current_line_idx);
input_line.spans.insert(
0,
Span::styled(
indicator,
styles::current_line_indicator_style(ctx.theme),
),
);
lines.push(input_line);
row_origins.push(SbsRowBg::none());
line_idx += 1;
}
} else {
let line_range = comment
.line_range
.or_else(|| Some(LineRange::single(line_num)));
let comment_lines = comment_panel::format_comment_lines(
ctx.theme,
comment_type_presentation(ctx.app, &comment.comment_type),
&comment.content,
line_range,
);
for mut comment_line in comment_lines {
let indicator = cursor_indicator(line_idx, ctx.current_line_idx);
comment_line.spans.insert(
0,
Span::styled(
indicator,
styles::current_line_indicator_style(ctx.theme),
),
);
lines.push(comment_line);
row_origins.push(SbsRowBg::none());
line_idx += 1;
}
}
}
}
}
if is_line_comment_mode && ctx.editing_comment_id.is_none() {
let line_range = ctx
.comment_line_range
.or_else(|| Some(LineRange::single(line_num)));
let (input_lines, cursor_info) = comment_panel::format_comment_input_lines(
ctx.theme,
comment_type_presentation(ctx.app, &ctx.comment_type),
ctx.comment_buffer,
ctx.comment_cursor,
line_range,
false,
ctx.supports_keyboard_enhancement,
);
let box_end = line_idx + input_lines.len().saturating_sub(1);
cursor_info_out = Some((
line_idx + cursor_info.line_offset,
1 + cursor_info.column,
line_idx,
box_end,
));
for mut input_line in input_lines {
let indicator = cursor_indicator(line_idx, ctx.current_line_idx);
input_line.spans.insert(
0,
Span::styled(indicator, styles::current_line_indicator_style(ctx.theme)),
);
lines.push(input_line);
row_origins.push(SbsRowBg::none());
line_idx += 1;
}
}
(line_idx, cursor_info_out)
}
fn render_sbs_expanded_context_line(
lines: &mut Vec<Line<'_>>,
row_origins: &mut Vec<SbsRowBg>,
line_idx: &mut usize,
current_line_idx: usize,
expanded_line: &travelagent_core::model::DiffLine,
content_width: usize,
theme: &Theme,
) {
let indicator = cursor_indicator(*line_idx, current_line_idx);
let line_num = expanded_line
.new_lineno
.map_or_else(|| " ".to_string(), |n| format!("{n:>4} "));
let line_spans = vec![
Span::styled(indicator, styles::current_line_indicator_style(theme)),
Span::styled(line_num.clone(), styles::gutter_expanded_style(theme)),
Span::styled(" ", styles::expanded_context_style(theme)),
Span::styled(
truncate_or_pad(&expanded_line.content, content_width),
styles::expanded_context_style(theme),
),
Span::styled(" │ ", styles::dim_style(theme)),
Span::styled(line_num, styles::gutter_expanded_style(theme)),
Span::styled(" ", styles::expanded_context_style(theme)),
Span::styled(
truncate_or_pad(&expanded_line.content, content_width),
styles::expanded_context_style(theme),
),
];
lines.push(Line::from(line_spans));
row_origins.push(SbsRowBg::none());
*line_idx += 1;
}
pub(super) fn render_side_by_side_diff(frame: &mut Frame, app: &mut App, area: Rect) {
let focused = app.nav.focused_panel == FocusedPanel::Diff;
let title = if app.is_cursor_in_overview() || app.current_file_path().is_none() {
" Diff (Side-by-Side) \u{2014} Overview ".to_string()
} else {
format!(
" Diff (Side-by-Side) \u{2014} {} ",
app.current_file_path().unwrap().display()
)
};
let block = Block::default()
.title(title)
.title_top(diff_stat_title(app).right_aligned())
.borders(Borders::ALL)
.style(styles::panel_style(&app.theme))
.border_style(styles::border_style(&app.theme, focused));
let inner = block.inner(area);
frame.render_widget(block, area);
app.diff_state.viewport_height = inner.height as usize;
let available_width = inner.width.saturating_sub(16) as usize;
let content_width = available_width / 2;
let comment_input_mode = app.nav.input_mode == InputMode::Comment
&& !app.comment.is_file_level
&& !app.comment.is_review_level;
let ctx = SideBySideContext {
app,
theme: &app.theme,
content_width,
current_line_idx: app.diff_state.cursor_line,
comment_input_mode,
comment_line: app.comment.line,
comment_type: app.comment.comment_type.clone(),
comment_buffer: &app.comment.buffer,
comment_cursor: app.comment.cursor,
comment_line_range: app.comment.line_range.map(|(r, _)| r),
editing_comment_id: app.comment.editing_id.as_deref(),
supports_keyboard_enhancement: app.supports_keyboard_enhancement,
};
let mut lines: Vec<Line> = Vec::new();
let mut row_origins: Vec<SbsRowBg> = Vec::new();
let mut line_idx: usize = 0;
let mut comment_cursor_logical_line: Option<usize> = None;
let mut comment_cursor_column: u16 = 0;
let mut comment_input_box_range: Option<(usize, usize)> = None;
let is_review_comment_mode =
app.nav.input_mode == InputMode::Comment && app.comment.is_review_level;
let general_indicator = cursor_indicator_spaced(line_idx, ctx.current_line_idx);
lines.push(Line::from(vec![
Span::styled(
general_indicator,
styles::current_line_indicator_style(&app.theme),
),
Span::styled(
"═══ Review Comments ",
styles::file_header_style(&app.theme),
),
Span::styled("═".repeat(40), styles::file_header_style(&app.theme)),
]));
row_origins.push(SbsRowBg::none());
line_idx += 1;
for comment in &app.engine.session().review_comments {
let is_being_edited =
app.comment.editing_id.as_ref() == Some(&comment.id) && is_review_comment_mode;
if is_being_edited {
let (input_lines, cursor_info) = comment_panel::format_comment_input_lines(
&app.theme,
comment_type_presentation(app, &app.comment.comment_type),
&app.comment.buffer,
app.comment.cursor,
None,
true,
app.supports_keyboard_enhancement,
);
comment_cursor_logical_line = Some(line_idx + cursor_info.line_offset);
comment_cursor_column = 1 + cursor_info.column;
comment_input_box_range =
Some((line_idx, line_idx + input_lines.len().saturating_sub(1)));
for mut input_line in input_lines {
let indicator = cursor_indicator(line_idx, ctx.current_line_idx);
input_line.spans.insert(
0,
Span::styled(indicator, styles::current_line_indicator_style(&app.theme)),
);
lines.push(input_line);
row_origins.push(SbsRowBg::none());
line_idx += 1;
}
} else {
let comment_lines = comment_panel::format_comment_lines(
&app.theme,
comment_type_presentation(app, &comment.comment_type),
&comment.content,
None,
);
for mut comment_line in comment_lines {
let indicator = cursor_indicator(line_idx, ctx.current_line_idx);
comment_line.spans.insert(
0,
Span::styled(indicator, styles::current_line_indicator_style(&app.theme)),
);
lines.push(comment_line);
row_origins.push(SbsRowBg::none());
line_idx += 1;
}
}
}
if is_review_comment_mode && app.comment.editing_id.is_none() {
let (input_lines, cursor_info) = comment_panel::format_comment_input_lines(
&app.theme,
comment_type_presentation(app, &app.comment.comment_type),
&app.comment.buffer,
app.comment.cursor,
None,
false,
app.supports_keyboard_enhancement,
);
comment_cursor_logical_line = Some(line_idx + cursor_info.line_offset);
comment_cursor_column = 1 + cursor_info.column;
comment_input_box_range = Some((line_idx, line_idx + input_lines.len().saturating_sub(1)));
for mut input_line in input_lines {
let indicator = cursor_indicator(line_idx, ctx.current_line_idx);
input_line.spans.insert(
0,
Span::styled(indicator, styles::current_line_indicator_style(&app.theme)),
);
lines.push(input_line);
row_origins.push(SbsRowBg::none());
line_idx += 1;
}
}
{
let before = lines.len();
render_session_orphans(&mut lines, &mut line_idx, ctx.current_line_idx, app);
let added = lines.len() - before;
for _ in 0..added {
row_origins.push(SbsRowBg::none());
}
}
for (file_idx, file) in app.diff_files.iter().enumerate() {
let path = file.display_path_lossy();
let status = file.status.as_char();
let is_reviewed = app.engine.session().is_file_reviewed(path);
let file_risk_band = if app.risk_border_colors {
Some(travelagent_core::risk::RiskBand::for_score(
travelagent_core::risk::score_file(path.as_path(), &file.hunks, &app.risk_config),
))
} else {
None
};
if let Some(review) = app.engine.session().files.get(path.as_path())
&& !review.orphaned_comments.is_empty()
{
let label = format!(
"── Orphaned comments ({}) — {} ──",
review.orphaned_comments.len(),
path.display()
);
let orphans = review.orphaned_comments.clone();
let before = lines.len();
render_orphans_section(
&mut lines,
&mut line_idx,
ctx.current_line_idx,
app,
label,
&orphans,
);
let added = lines.len() - before;
for _ in 0..added {
row_origins.push(SbsRowBg::none());
}
}
let indicator = cursor_indicator_spaced(line_idx, ctx.current_line_idx);
let review_mark = if is_reviewed { "✓ " } else { "" };
let header_text = if file.is_commit_message {
format!("═══ {review_mark}Commit Message ")
} else {
format!("═══ {}{} [{}] ", review_mark, path.display(), status)
};
let file_header_style = styles::file_header_style_with_risk(&app.theme, file_risk_band);
lines.push(Line::from(vec![
Span::styled(indicator, styles::current_line_indicator_style(&app.theme)),
Span::styled(header_text, file_header_style),
Span::styled("═".repeat(40), file_header_style),
]));
row_origins.push(SbsRowBg::none());
line_idx += 1;
if is_reviewed {
continue;
}
if app.is_file_collapsed(file_idx) {
render_collapsed_placeholder(
&mut lines,
&mut line_idx,
ctx.current_line_idx,
app,
file,
);
row_origins.push(SbsRowBg::none());
let indicator = cursor_indicator(line_idx, ctx.current_line_idx);
lines.push(Line::from(Span::styled(
indicator,
styles::current_line_indicator_style(&app.theme),
)));
row_origins.push(SbsRowBg::none());
line_idx += 1;
continue;
}
let is_file_comment_mode = app.nav.input_mode == InputMode::Comment
&& app.comment.is_file_level
&& file_idx == app.diff_state.current_file_idx;
if let Some(review) = app.engine.session().files.get(path) {
for comment in &review.file_comments {
let is_being_edited =
app.comment.editing_id.as_ref() == Some(&comment.id) && is_file_comment_mode;
if is_being_edited {
let (input_lines, cursor_info) = comment_panel::format_comment_input_lines(
&app.theme,
comment_type_presentation(app, &app.comment.comment_type),
&app.comment.buffer,
app.comment.cursor,
None,
true,
app.supports_keyboard_enhancement,
);
comment_cursor_logical_line = Some(line_idx + cursor_info.line_offset);
comment_cursor_column = 1 + cursor_info.column;
comment_input_box_range =
Some((line_idx, line_idx + input_lines.len().saturating_sub(1)));
for mut input_line in input_lines {
let indicator = cursor_indicator(line_idx, ctx.current_line_idx);
input_line.spans.insert(
0,
Span::styled(
indicator,
styles::current_line_indicator_style(&app.theme),
),
);
lines.push(input_line);
row_origins.push(SbsRowBg::none());
line_idx += 1;
}
} else {
let comment_lines = comment_panel::format_comment_lines(
&app.theme,
comment_type_presentation(app, &comment.comment_type),
&comment.content,
None,
);
for mut comment_line in comment_lines {
let indicator = cursor_indicator(line_idx, ctx.current_line_idx);
comment_line.spans.insert(
0,
Span::styled(
indicator,
styles::current_line_indicator_style(&app.theme),
),
);
lines.push(comment_line);
row_origins.push(SbsRowBg::none());
line_idx += 1;
}
}
}
}
if is_file_comment_mode && app.comment.editing_id.is_none() {
let (input_lines, cursor_info) = comment_panel::format_comment_input_lines(
&app.theme,
comment_type_presentation(app, &app.comment.comment_type),
&app.comment.buffer,
app.comment.cursor,
None,
false,
app.supports_keyboard_enhancement,
);
comment_cursor_logical_line = Some(line_idx + cursor_info.line_offset);
comment_cursor_column = 1 + cursor_info.column;
comment_input_box_range =
Some((line_idx, line_idx + input_lines.len().saturating_sub(1)));
for mut input_line in input_lines {
let indicator = cursor_indicator(line_idx, ctx.current_line_idx);
input_line.spans.insert(
0,
Span::styled(indicator, styles::current_line_indicator_style(&app.theme)),
);
lines.push(input_line);
row_origins.push(SbsRowBg::none());
line_idx += 1;
}
}
if file.is_too_large {
let indicator = cursor_indicator_spaced(line_idx, ctx.current_line_idx);
lines.push(Line::from(vec![
Span::styled(indicator, styles::current_line_indicator_style(&app.theme)),
Span::styled("(file too large to display)", styles::dim_style(&app.theme)),
]));
row_origins.push(SbsRowBg::none());
line_idx += 1;
} else if file.is_binary {
let indicator = cursor_indicator_spaced(line_idx, ctx.current_line_idx);
lines.push(Line::from(vec![
Span::styled(indicator, styles::current_line_indicator_style(&app.theme)),
Span::styled("(binary file)", styles::dim_style(&app.theme)),
]));
row_origins.push(SbsRowBg::none());
line_idx += 1;
} else if file.hunks.is_empty() {
let indicator = cursor_indicator_spaced(line_idx, ctx.current_line_idx);
lines.push(Line::from(vec![
Span::styled(indicator, styles::current_line_indicator_style(&app.theme)),
Span::styled("(no changes)", styles::dim_style(&app.theme)),
]));
row_origins.push(SbsRowBg::none());
line_idx += 1;
} else {
let line_comments = app.engine.line_comments_for(path);
for (hunk_idx, hunk) in file.hunks.iter().enumerate() {
let prev_hunk = if hunk_idx > 0 {
file.hunks.get(hunk_idx - 1)
} else {
None
};
let gap = calculate_gap(
prev_hunk.map(|h| (&h.new_start, &h.new_count)),
hunk.new_start,
);
let gap_id = GapId { file_idx, hunk_idx };
if gap > 0 {
let top_lines = app.gaps.expanded_top.get(&gap_id);
let bot_lines = app.gaps.expanded_bottom.get(&gap_id);
let top_len = top_lines.map_or(0, std::vec::Vec::len);
let bot_len = bot_lines.map_or(0, std::vec::Vec::len);
let remaining = (gap as usize).saturating_sub(top_len + bot_len);
let is_top_of_file = hunk_idx == 0;
if let Some(top) = top_lines {
for expanded_line in top {
render_sbs_expanded_context_line(
&mut lines,
&mut row_origins,
&mut line_idx,
ctx.current_line_idx,
expanded_line,
ctx.content_width,
&app.theme,
);
}
}
if remaining > 0 {
if is_top_of_file {
if remaining > GAP_EXPAND_BATCH {
render_hidden_lines(
&mut lines,
&mut line_idx,
ctx.current_line_idx,
remaining,
&app.theme,
);
row_origins.push(SbsRowBg::none());
}
render_expander_line(
&mut lines,
&mut line_idx,
ctx.current_line_idx,
ExpandDirection::Up,
remaining,
&app.theme,
);
row_origins.push(SbsRowBg::none());
} else if remaining >= GAP_EXPAND_BATCH {
render_expander_line(
&mut lines,
&mut line_idx,
ctx.current_line_idx,
ExpandDirection::Down,
remaining,
&app.theme,
);
row_origins.push(SbsRowBg::none());
render_hidden_lines(
&mut lines,
&mut line_idx,
ctx.current_line_idx,
remaining,
&app.theme,
);
row_origins.push(SbsRowBg::none());
render_expander_line(
&mut lines,
&mut line_idx,
ctx.current_line_idx,
ExpandDirection::Up,
remaining,
&app.theme,
);
row_origins.push(SbsRowBg::none());
} else {
render_expander_line(
&mut lines,
&mut line_idx,
ctx.current_line_idx,
ExpandDirection::Both,
remaining,
&app.theme,
);
row_origins.push(SbsRowBg::none());
}
}
if let Some(bot) = bot_lines {
for expanded_line in bot {
render_sbs_expanded_context_line(
&mut lines,
&mut row_origins,
&mut line_idx,
ctx.current_line_idx,
expanded_line,
ctx.content_width,
&app.theme,
);
}
}
}
let hunk_risk_band = if app.risk_border_colors {
Some(travelagent_core::risk::RiskBand::for_score(
travelagent_core::risk::score_hunk(path.as_path(), hunk, &app.risk_config),
))
} else {
None
};
let indicator = cursor_indicator_spaced(line_idx, ctx.current_line_idx);
lines.push(Line::from(vec![
Span::styled(indicator, styles::current_line_indicator_style(&app.theme)),
Span::styled(
hunk.header.clone(),
styles::diff_hunk_header_style_with_risk(&app.theme, hunk_risk_band),
),
]));
row_origins.push(SbsRowBg::none());
line_idx += 1;
let (new_line_idx, cursor_info) = render_hunk_lines_side_by_side(
&hunk.lines,
&line_comments,
&ctx,
line_idx,
&mut lines,
&mut row_origins,
);
line_idx = new_line_idx;
if let Some((line, col, box_start, box_end)) = cursor_info {
comment_cursor_logical_line = Some(line);
comment_cursor_column = col;
comment_input_box_range = Some((box_start, box_end));
}
}
}
let indicator = cursor_indicator(line_idx, ctx.current_line_idx);
lines.push(Line::from(Span::styled(
indicator,
styles::current_line_indicator_style(&app.theme),
)));
row_origins.push(SbsRowBg::none());
line_idx += 1;
}
scroll_comment_input_into_view(
&mut app.diff_state.scroll_offset,
comment_input_box_range,
comment_cursor_logical_line,
inner.height as usize,
lines.len(),
);
debug_assert_eq!(
lines.len(),
row_origins.len(),
"row_origins must stay in lock-step with lines"
);
let visible_lines_unscrolled: Vec<Line> = lines
.into_iter()
.skip(app.diff_state.scroll_offset)
.take(inner.height as usize)
.collect();
let visible_row_origins: Vec<SbsRowBg> = row_origins
.into_iter()
.skip(app.diff_state.scroll_offset)
.take(inner.height as usize)
.collect();
let line_widths: Vec<usize> = visible_lines_unscrolled
.iter()
.map(|line| {
line.spans
.iter()
.map(|span| span.content.width())
.sum::<usize>()
})
.collect();
let max_content_width = line_widths.iter().copied().max().unwrap_or(0);
app.diff_state.viewport_width = inner.width as usize;
app.diff_state.max_content_width = max_content_width;
let viewport_width = inner.width as usize;
let viewport_height = inner.height as usize;
app.diff_state.visible_line_count = if app.diff_state.wrap_lines && viewport_width > 0 {
let mut visual_rows_used = 0;
let mut logical_lines_visible = 0;
for &width in &line_widths {
let rows_for_line = if width == 0 {
1
} else {
width.div_ceil(viewport_width)
};
if visual_rows_used + rows_for_line > viewport_height {
break;
}
visual_rows_used += rows_for_line;
logical_lines_visible += 1;
}
logical_lines_visible.max(1)
} else {
viewport_height
};
let max_scroll_x = max_content_width.saturating_sub(inner.width as usize);
if app.diff_state.scroll_x > max_scroll_x {
app.diff_state.scroll_x = max_scroll_x;
}
if app.diff_state.wrap_lines {
app.diff_state.scroll_x = 0;
}
let scroll_x = app.diff_state.scroll_x;
let visible_lines: Vec<Line> = if app.diff_state.wrap_lines {
visible_lines_unscrolled
} else {
visible_lines_unscrolled
.into_iter()
.map(|line| apply_horizontal_scroll(line, scroll_x))
.collect()
};
paint_side_by_side_diff_row_backgrounds(
frame,
inner,
&visible_row_origins,
&line_widths,
app.diff_state.wrap_lines,
inner.width as usize,
&app.theme,
content_width,
);
let mut diff = Paragraph::new(visible_lines).style(Style::default().fg(app.theme.fg_primary));
if app.diff_state.wrap_lines {
diff = diff.wrap(Wrap { trim: false });
}
frame.render_widget(diff, inner);
if let Some(cursor_logical_line) = comment_cursor_logical_line {
let scroll_offset = app.diff_state.scroll_offset;
let visible_lines_count = app.diff_state.visible_line_count.max(1);
if cursor_logical_line >= scroll_offset
&& cursor_logical_line < scroll_offset + visible_lines_count
{
let logical_offset = cursor_logical_line - scroll_offset;
let mut visual_row: u16 = 0;
let viewport_width = inner.width as usize;
if app.diff_state.wrap_lines && viewport_width > 0 {
for i in 0..logical_offset {
if i < line_widths.len() {
let width = line_widths[i];
let rows = if width == 0 {
1
} else {
width.div_ceil(viewport_width)
};
visual_row += rows as u16;
} else {
visual_row += 1;
}
}
} else {
visual_row = logical_offset as u16;
}
let screen_col = inner.x + comment_cursor_column;
let screen_row_abs = inner.y + visual_row;
app.comment.cursor_screen_pos = Some((screen_col, screen_row_abs));
}
}
}
fn render_hunk_lines_side_by_side(
hunk_lines: &[travelagent_core::model::DiffLine],
line_comments: &std::collections::HashMap<u32, Vec<travelagent_core::model::Comment>>,
ctx: &SideBySideContext,
mut line_idx: usize,
lines: &mut Vec<Line>,
row_origins: &mut Vec<SbsRowBg>,
) -> (usize, Option<SideBySideCursorInfo>) {
let mut i = 0;
let mut cursor_info_out: Option<SideBySideCursorInfo> = None;
while i < hunk_lines.len() {
let diff_line = &hunk_lines[i];
match diff_line.origin {
LineOrigin::Context => {
let (new_line_idx, cursor_info) = render_context_line_side_by_side(
diff_line,
line_comments,
ctx,
line_idx,
lines,
row_origins,
);
line_idx = new_line_idx;
if cursor_info.is_some() {
cursor_info_out = cursor_info;
}
i += 1;
}
LineOrigin::Deletion => {
let (new_line_idx, lines_processed, cursor_info) =
render_deletion_addition_pair_side_by_side(
hunk_lines,
i,
line_comments,
ctx,
line_idx,
lines,
row_origins,
);
line_idx = new_line_idx;
if cursor_info.is_some() {
cursor_info_out = cursor_info;
}
i = lines_processed;
}
LineOrigin::Addition => {
let (new_line_idx, cursor_info) = render_standalone_addition_side_by_side(
diff_line,
line_comments,
ctx,
line_idx,
lines,
row_origins,
);
line_idx = new_line_idx;
if cursor_info.is_some() {
cursor_info_out = cursor_info;
}
i += 1;
}
}
}
(line_idx, cursor_info_out)
}
fn render_context_line_side_by_side(
diff_line: &travelagent_core::model::DiffLine,
line_comments: &std::collections::HashMap<u32, Vec<travelagent_core::model::Comment>>,
ctx: &SideBySideContext,
mut line_idx: usize,
lines: &mut Vec<Line>,
row_origins: &mut Vec<SbsRowBg>,
) -> (usize, Option<SideBySideCursorInfo>) {
let line_num = diff_line
.old_lineno
.or(diff_line.new_lineno)
.map_or_else(|| " ".to_string(), |n| format!("{n:>4}"));
let is_in_visual_selection = diff_line
.new_lineno
.is_some_and(|ln| ctx.app.is_line_in_visual_selection(ln, LineSide::New))
|| diff_line
.old_lineno
.is_some_and(|ln| ctx.app.is_line_in_visual_selection(ln, LineSide::Old));
let visual_patch = styles::visual_selection_style(ctx.theme);
let patch_if_selected = |style: Style| {
if is_in_visual_selection {
style.patch(visual_patch)
} else {
style
}
};
let indicator = cursor_indicator(line_idx, ctx.current_line_idx);
let mut spans = vec![
Span::styled(indicator, styles::current_line_indicator_style(ctx.theme)),
Span::styled(
format!("{line_num} "),
patch_if_selected(styles::gutter_style(ctx.theme)),
),
Span::styled(
" ".to_string(),
patch_if_selected(styles::diff_context_style(ctx.theme)),
),
];
if let Some(ref highlighted) = diff_line.highlighted_spans {
let content_spans = truncate_or_pad_spans(
highlighted,
ctx.content_width,
patch_if_selected(styles::diff_context_style(ctx.theme)),
);
if is_in_visual_selection {
for span in content_spans {
let styled = span.style.patch(visual_patch);
spans.push(Span::styled(span.content, styled));
}
} else {
spans.extend(content_spans);
}
} else {
let expanded = expand_tabs(&diff_line.content, ctx.app.tab_width);
let content = truncate_or_pad(&expanded, ctx.content_width);
spans.push(Span::styled(
content,
patch_if_selected(styles::diff_context_style(ctx.theme)),
));
}
spans.push(Span::styled(" │ ", styles::dim_style(ctx.theme)));
spans.push(Span::styled(
format!("{line_num} "),
patch_if_selected(styles::gutter_style(ctx.theme)),
));
spans.push(Span::styled(
" ".to_string(),
patch_if_selected(styles::diff_context_style(ctx.theme)),
));
if let Some(ref highlighted) = diff_line.highlighted_spans {
let content_spans = truncate_or_pad_spans(
highlighted,
ctx.content_width,
patch_if_selected(styles::diff_context_style(ctx.theme)),
);
if is_in_visual_selection {
for span in content_spans {
let styled = span.style.patch(visual_patch);
spans.push(Span::styled(span.content, styled));
}
} else {
spans.extend(content_spans);
}
} else {
let expanded = expand_tabs(&diff_line.content, ctx.app.tab_width);
let content = truncate_or_pad(&expanded, ctx.content_width);
spans.push(Span::styled(
content,
patch_if_selected(styles::diff_context_style(ctx.theme)),
));
}
lines.push(Line::from(spans));
row_origins.push(SbsRowBg::none());
line_idx += 1;
let mut cursor_info_out: Option<SideBySideCursorInfo> = None;
if let Some(old_ln) = diff_line.old_lineno {
let (old_line_idx, cursor_info) = add_comments_to_line(
old_ln,
line_comments,
LineSide::Old,
ctx,
line_idx,
lines,
row_origins,
);
line_idx = old_line_idx;
if cursor_info.is_some() {
cursor_info_out = cursor_info;
}
}
if let Some(new_ln) = diff_line.new_lineno {
let (new_line_idx, cursor_info) = add_comments_to_line(
new_ln,
line_comments,
LineSide::New,
ctx,
line_idx,
lines,
row_origins,
);
line_idx = new_line_idx;
if cursor_info.is_some() {
cursor_info_out = cursor_info;
}
}
(line_idx, cursor_info_out)
}
fn render_deletion_addition_pair_side_by_side(
hunk_lines: &[travelagent_core::model::DiffLine],
start_idx: usize,
line_comments: &std::collections::HashMap<u32, Vec<travelagent_core::model::Comment>>,
ctx: &SideBySideContext,
mut line_idx: usize,
lines: &mut Vec<Line>,
row_origins: &mut Vec<SbsRowBg>,
) -> (usize, usize, Option<SideBySideCursorInfo>) {
let mut del_end = start_idx + 1;
while del_end < hunk_lines.len() && hunk_lines[del_end].origin == LineOrigin::Deletion {
del_end += 1;
}
let add_start = del_end;
let mut add_end = add_start;
while add_end < hunk_lines.len() && hunk_lines[add_end].origin == LineOrigin::Addition {
add_end += 1;
}
let del_count = del_end - start_idx;
let add_count = add_end - add_start;
let max_lines = del_count.max(add_count);
let mut cursor_info_out: Option<SideBySideCursorInfo> = None;
let pair_word_diff = ctx.app.word_diff_enabled && del_count == add_count && del_count > 0;
for offset in 0..max_lines {
let indicator = cursor_indicator(line_idx, ctx.current_line_idx);
let mut spans = vec![Span::styled(
indicator,
styles::current_line_indicator_style(ctx.theme),
)];
let left_origin = if offset < del_count {
let del_line = &hunk_lines[start_idx + offset];
let is_in_visual_selection = del_line
.old_lineno
.is_some_and(|ln| ctx.app.is_line_in_visual_selection(ln, LineSide::Old));
let partner = if pair_word_diff {
Some(hunk_lines[add_start + offset].content.as_str())
} else {
None
};
add_deletion_spans(
ctx.theme,
&mut spans,
del_line,
ctx.content_width,
is_in_visual_selection,
partner,
ctx.app.tab_width,
);
Some(LineOrigin::Deletion)
} else {
add_empty_column_spans(&mut spans, ctx.content_width);
None
};
spans.push(Span::styled(" │ ", styles::dim_style(ctx.theme)));
let right_origin = if offset < add_count {
let add_line = &hunk_lines[add_start + offset];
let is_in_visual_selection = add_line
.new_lineno
.is_some_and(|ln| ctx.app.is_line_in_visual_selection(ln, LineSide::New));
let partner = if pair_word_diff {
Some(hunk_lines[start_idx + offset].content.as_str())
} else {
None
};
add_addition_spans(
ctx.theme,
&mut spans,
add_line,
ctx.content_width,
is_in_visual_selection,
partner,
ctx.app.tab_width,
);
Some(LineOrigin::Addition)
} else {
add_empty_column_spans(&mut spans, ctx.content_width);
None
};
lines.push(Line::from(spans));
row_origins.push(SbsRowBg::pair(left_origin, right_origin));
line_idx += 1;
if offset < del_count {
let del_line = &hunk_lines[start_idx + offset];
if let Some(old_ln) = del_line.old_lineno {
let (new_line_idx, cursor_info) = add_comments_to_line(
old_ln,
line_comments,
LineSide::Old,
ctx,
line_idx,
lines,
row_origins,
);
line_idx = new_line_idx;
if cursor_info.is_some() {
cursor_info_out = cursor_info;
}
}
}
if offset < add_count {
let add_line = &hunk_lines[add_start + offset];
if let Some(new_ln) = add_line.new_lineno {
let (new_line_idx, cursor_info) = add_comments_to_line(
new_ln,
line_comments,
LineSide::New,
ctx,
line_idx,
lines,
row_origins,
);
line_idx = new_line_idx;
if cursor_info.is_some() {
cursor_info_out = cursor_info;
}
}
}
}
(line_idx, add_end, cursor_info_out)
}
fn render_standalone_addition_side_by_side(
diff_line: &travelagent_core::model::DiffLine,
line_comments: &std::collections::HashMap<u32, Vec<travelagent_core::model::Comment>>,
ctx: &SideBySideContext,
mut line_idx: usize,
lines: &mut Vec<Line>,
row_origins: &mut Vec<SbsRowBg>,
) -> (usize, Option<SideBySideCursorInfo>) {
let indicator = cursor_indicator(line_idx, ctx.current_line_idx);
let is_in_visual_selection = diff_line
.new_lineno
.is_some_and(|ln| ctx.app.is_line_in_visual_selection(ln, LineSide::New));
let mut spans = vec![Span::styled(
indicator,
styles::current_line_indicator_style(ctx.theme),
)];
add_empty_column_spans(&mut spans, ctx.content_width);
spans.push(Span::styled(" │ ", styles::dim_style(ctx.theme)));
add_addition_spans(
ctx.theme,
&mut spans,
diff_line,
ctx.content_width,
is_in_visual_selection,
None,
ctx.app.tab_width,
);
lines.push(Line::from(spans));
row_origins.push(SbsRowBg::pair(None, Some(LineOrigin::Addition)));
line_idx += 1;
let mut cursor_info_out: Option<SideBySideCursorInfo> = None;
if let Some(new_ln) = diff_line.new_lineno {
let (new_line_idx, cursor_info) = add_comments_to_line(
new_ln,
line_comments,
LineSide::New,
ctx,
line_idx,
lines,
row_origins,
);
line_idx = new_line_idx;
cursor_info_out = cursor_info;
}
(line_idx, cursor_info_out)
}
fn add_deletion_spans(
theme: &Theme,
spans: &mut Vec<Span>,
diff_line: &travelagent_core::model::DiffLine,
content_width: usize,
is_in_visual_selection: bool,
word_diff_partner: Option<&str>,
tab_width: usize,
) {
let line_num = diff_line
.old_lineno
.map_or_else(|| " ".to_string(), |n| format!("{n:>4}"));
let visual_patch = styles::visual_selection_style(theme);
let patch_if_selected = |style: Style| {
if is_in_visual_selection {
style.patch(visual_patch)
} else {
style
}
};
spans.push(Span::styled(
format!("{line_num} "),
patch_if_selected(styles::gutter_style(theme)),
));
spans.push(Span::styled(
"-".to_string(),
patch_if_selected(styles::diff_del_style(theme)),
));
if let Some(ref highlighted) = diff_line.highlighted_spans {
let syntax_pad_style = Style::default().fg(theme.diff_del).bg(theme.syntax_del_bg);
if let Some(partner) = word_diff_partner {
let (old_tokens, _) =
travelagent_core::diff::highlight_line_pair(&diff_line.content, partner);
let content_spans = super::word_diff_overlay::truncate_or_pad_overlay_spans(
highlighted,
&old_tokens,
content_width,
syntax_pad_style,
theme.syntax_del_bg,
is_in_visual_selection,
visual_patch,
);
spans.extend(content_spans);
} else {
let content_spans = truncate_or_pad_spans(
highlighted,
content_width,
patch_if_selected(syntax_pad_style),
);
if is_in_visual_selection {
for span in content_spans {
let styled = span.style.patch(visual_patch);
spans.push(Span::styled(span.content, styled));
}
} else {
spans.extend(content_spans);
}
}
} else if let Some(partner) = word_diff_partner {
let (old_tokens, _) =
travelagent_core::diff::highlight_line_pair(&diff_line.content, partner);
let base = patch_if_selected(styles::diff_del_style(theme));
push_word_diff_tokens(
spans,
old_tokens,
base,
theme.syntax_del_bg,
is_in_visual_selection,
content_width,
visual_patch,
);
} else {
let expanded = expand_tabs(&diff_line.content, tab_width);
let content = truncate_or_pad(&expanded, content_width);
spans.push(Span::styled(
content,
patch_if_selected(styles::diff_del_style(theme)),
));
}
}
fn add_addition_spans(
theme: &Theme,
spans: &mut Vec<Span>,
diff_line: &travelagent_core::model::DiffLine,
content_width: usize,
is_in_visual_selection: bool,
word_diff_partner: Option<&str>,
tab_width: usize,
) {
let line_num = diff_line
.new_lineno
.map_or_else(|| " ".to_string(), |n| format!("{n:>4}"));
let visual_patch = styles::visual_selection_style(theme);
let patch_if_selected = |style: Style| {
if is_in_visual_selection {
style.patch(visual_patch)
} else {
style
}
};
spans.push(Span::styled(
format!("{line_num} "),
patch_if_selected(styles::gutter_style(theme)),
));
spans.push(Span::styled(
"+".to_string(),
patch_if_selected(styles::diff_add_style(theme)),
));
if let Some(ref highlighted) = diff_line.highlighted_spans {
let syntax_pad_style = Style::default().fg(theme.diff_add).bg(theme.syntax_add_bg);
if let Some(partner) = word_diff_partner {
let (_, new_tokens) =
travelagent_core::diff::highlight_line_pair(partner, &diff_line.content);
let content_spans = super::word_diff_overlay::truncate_or_pad_overlay_spans(
highlighted,
&new_tokens,
content_width,
syntax_pad_style,
theme.syntax_add_bg,
is_in_visual_selection,
visual_patch,
);
spans.extend(content_spans);
} else {
let content_spans = truncate_or_pad_spans(
highlighted,
content_width,
patch_if_selected(syntax_pad_style),
);
if is_in_visual_selection {
for span in content_spans {
let styled = span.style.patch(visual_patch);
spans.push(Span::styled(span.content, styled));
}
} else {
spans.extend(content_spans);
}
}
} else if let Some(partner) = word_diff_partner {
let (_, new_tokens) =
travelagent_core::diff::highlight_line_pair(partner, &diff_line.content);
let base = patch_if_selected(styles::diff_add_style(theme));
push_word_diff_tokens(
spans,
new_tokens,
base,
theme.syntax_add_bg,
is_in_visual_selection,
content_width,
visual_patch,
);
} else {
let expanded = expand_tabs(&diff_line.content, tab_width);
let content = truncate_or_pad(&expanded, content_width);
spans.push(Span::styled(
content,
patch_if_selected(styles::diff_add_style(theme)),
));
}
}
fn push_word_diff_tokens(
spans: &mut Vec<Span>,
tokens: Vec<travelagent_core::diff::Token>,
base: Style,
emphasis_bg: ratatui::style::Color,
is_in_visual_selection: bool,
content_width: usize,
visual_patch: Style,
) {
let mut remaining = content_width;
for token in tokens {
if remaining == 0 {
break;
}
let text_width = token.text.width();
let (text, width) = if text_width <= remaining {
(token.text, text_width)
} else {
let mut truncated = String::new();
let mut used = 0;
for ch in token.text.chars() {
let cw = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
if used + cw > remaining {
break;
}
truncated.push(ch);
used += cw;
}
(truncated, used)
};
let mut tok_style = base;
if token.highlight {
tok_style = tok_style.bg(emphasis_bg).add_modifier(Modifier::BOLD);
}
if is_in_visual_selection {
tok_style = tok_style.patch(visual_patch);
}
spans.push(Span::styled(text, tok_style));
remaining -= width;
}
if remaining > 0 {
let mut pad_style = base;
if is_in_visual_selection {
pad_style = pad_style.patch(visual_patch);
}
spans.push(Span::styled(" ".repeat(remaining), pad_style));
}
}
fn add_empty_column_spans(spans: &mut Vec<Span>, content_width: usize) {
spans.push(Span::styled(
" ".repeat(5 + 1 + content_width),
Style::default(),
));
}
#[derive(Clone, Copy, Default)]
struct SbsRowBg {
left: Option<LineOrigin>,
right: Option<LineOrigin>,
}
impl SbsRowBg {
const fn none() -> Self {
Self {
left: None,
right: None,
}
}
const fn pair(left: Option<LineOrigin>, right: Option<LineOrigin>) -> Self {
Self { left, right }
}
}
fn side_by_side_line_bg_style(row: SbsRowBg, theme: &Theme) -> (Option<Style>, Option<Style>) {
fn style_for(origin: Option<LineOrigin>, theme: &Theme) -> Option<Style> {
match origin? {
LineOrigin::Addition => Some(Style::default().bg(theme.diff_add_bg)),
LineOrigin::Deletion => Some(Style::default().bg(theme.diff_del_bg)),
LineOrigin::Context => None,
}
}
(style_for(row.left, theme), style_for(row.right, theme))
}
#[allow(clippy::too_many_arguments)]
fn paint_side_by_side_diff_row_backgrounds(
frame: &mut Frame,
inner: Rect,
origins: &[SbsRowBg],
line_widths: &[usize],
wrap_lines: bool,
viewport_width: usize,
theme: &Theme,
content_width: usize,
) {
let mut visual_row: usize = 0;
let left_half_width = 1 + 5 + 1 + content_width as u16;
let separator_width: u16 = 3;
let right_start_offset = left_half_width + separator_width;
for (idx, row) in origins.iter().enumerate() {
if visual_row >= inner.height as usize {
break;
}
let rows_for_line = if wrap_lines && viewport_width > 0 {
let width = line_widths.get(idx).copied().unwrap_or(0);
if width == 0 {
1
} else {
width.div_ceil(viewport_width)
}
} else {
1
};
let (left_style, right_style) = side_by_side_line_bg_style(*row, theme);
if left_style.is_none() && right_style.is_none() {
visual_row += rows_for_line;
continue;
}
for _ in 0..rows_for_line {
if visual_row >= inner.height as usize {
break;
}
let y = inner.y + visual_row as u16;
if let Some(style) = left_style {
let width = left_half_width.min(inner.width);
if width > 0 {
let row_rect = Rect {
x: inner.x,
y,
width,
height: 1,
};
frame.buffer_mut().set_style(row_rect, style);
}
}
if let Some(style) = right_style
&& right_start_offset < inner.width
{
let width = inner.width - right_start_offset;
let row_rect = Rect {
x: inner.x + right_start_offset,
y,
width,
height: 1,
};
frame.buffer_mut().set_style(row_rect, style);
}
visual_row += 1;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::theme::Theme;
use ratatui::text::Span;
use travelagent_core::model::LineOrigin;
#[test]
fn add_deletion_spans_applies_visual_selection_style_when_selected() {
let theme = Theme::default();
let diff_line = travelagent_core::model::DiffLine {
origin: LineOrigin::Deletion,
content: "removed code".to_string(),
old_lineno: Some(42),
new_lineno: None,
highlighted_spans: None,
};
let mut spans: Vec<Span> = Vec::new();
add_deletion_spans(&theme, &mut spans, &diff_line, 20, true, None, 4);
assert!(!spans.is_empty());
for span in &spans {
assert_eq!(
span.style.bg,
Some(theme.bg_highlight),
"span {:?} should carry visual-selection bg",
span.content
);
}
}
#[test]
fn add_deletion_spans_skips_visual_selection_style_when_unselected() {
let theme = Theme::default();
let diff_line = travelagent_core::model::DiffLine {
origin: LineOrigin::Deletion,
content: "removed code".to_string(),
old_lineno: Some(42),
new_lineno: None,
highlighted_spans: None,
};
let mut spans: Vec<Span> = Vec::new();
add_deletion_spans(&theme, &mut spans, &diff_line, 20, false, None, 4);
for span in &spans {
assert_ne!(
span.style.bg,
Some(theme.bg_highlight),
"span {:?} should not carry visual-selection bg",
span.content
);
}
}
#[test]
fn add_addition_spans_applies_visual_selection_style_when_selected() {
let theme = Theme::default();
let diff_line = travelagent_core::model::DiffLine {
origin: LineOrigin::Addition,
content: "new code".to_string(),
old_lineno: None,
new_lineno: Some(10),
highlighted_spans: None,
};
let mut spans: Vec<Span> = Vec::new();
add_addition_spans(&theme, &mut spans, &diff_line, 20, true, None, 4);
assert!(!spans.is_empty());
for span in &spans {
assert_eq!(
span.style.bg,
Some(theme.bg_highlight),
"span {:?} should carry visual-selection bg",
span.content
);
}
}
#[test]
fn add_addition_spans_skips_visual_selection_style_when_unselected() {
let theme = Theme::default();
let diff_line = travelagent_core::model::DiffLine {
origin: LineOrigin::Addition,
content: "new code".to_string(),
old_lineno: None,
new_lineno: Some(10),
highlighted_spans: None,
};
let mut spans: Vec<Span> = Vec::new();
add_addition_spans(&theme, &mut spans, &diff_line, 20, false, None, 4);
for span in &spans {
assert_ne!(
span.style.bg,
Some(theme.bg_highlight),
"span {:?} should not carry visual-selection bg",
span.content
);
}
}
#[test]
fn add_deletion_spans_propagates_highlight_through_syntax_spans() {
let theme = Theme::default();
let highlighter = travelagent_core::syntax::SyntaxHighlighter::default();
let lines = vec!["let removed = 1;".to_string()];
let highlighted = highlighter
.highlight_file_lines(std::path::Path::new("test.rs"), &lines)
.unwrap();
let syntax_spans = highlighted[0].as_ref().unwrap().clone();
let diff_line = travelagent_core::model::DiffLine {
origin: LineOrigin::Deletion,
content: "let removed = 1;".to_string(),
old_lineno: Some(7),
new_lineno: None,
highlighted_spans: Some(syntax_spans),
};
let mut spans: Vec<Span> = Vec::new();
add_deletion_spans(&theme, &mut spans, &diff_line, 40, true, None, 4);
assert!(
spans.len() > 2,
"expected multiple syntax spans to be emitted"
);
for span in &spans {
assert_eq!(
span.style.bg,
Some(theme.bg_highlight),
"syntax span {:?} should carry visual-selection bg",
span.content
);
}
}
#[test]
fn should_pad_highlighted_spans_to_exact_width() {
let highlighter = travelagent_core::syntax::SyntaxHighlighter::default();
let lines = vec!["let x = 1;".to_string()];
let highlighted = highlighter
.highlight_file_lines(std::path::Path::new("test.rs"), &lines)
.unwrap();
let spans = highlighted[0].as_ref().unwrap();
let width = 80;
let result = truncate_or_pad_spans(spans, width, Style::default());
let total_chars: usize = result.iter().map(|s| s.content.chars().count()).sum();
assert_eq!(
total_chars, width,
"padded spans should have exactly {width} chars, got {total_chars}"
);
}
#[test]
fn word_diff_paired_single_word_change_highlights_changed_token_each_side() {
let theme = Theme::default();
let del_line = travelagent_core::model::DiffLine {
origin: LineOrigin::Deletion,
content: "let x = 1;".to_string(),
old_lineno: Some(1),
new_lineno: None,
highlighted_spans: None,
};
let add_line = travelagent_core::model::DiffLine {
origin: LineOrigin::Addition,
content: "let y = 1;".to_string(),
old_lineno: None,
new_lineno: Some(1),
highlighted_spans: None,
};
let mut del_spans: Vec<Span> = Vec::new();
add_deletion_spans(
&theme,
&mut del_spans,
&del_line,
40,
false,
Some(add_line.content.as_str()),
4,
);
let mut add_spans: Vec<Span> = Vec::new();
add_addition_spans(
&theme,
&mut add_spans,
&add_line,
40,
false,
Some(del_line.content.as_str()),
4,
);
let del_emph: Vec<&Span> = del_spans
.iter()
.filter(|s| {
s.style.bg == Some(theme.syntax_del_bg)
&& s.style.add_modifier.contains(Modifier::BOLD)
})
.collect();
assert_eq!(
del_emph.len(),
1,
"expected exactly one emphasised token on deletion side, got {del_spans:?}"
);
assert_eq!(del_emph[0].content.as_ref(), "x");
let add_emph: Vec<&Span> = add_spans
.iter()
.filter(|s| {
s.style.bg == Some(theme.syntax_add_bg)
&& s.style.add_modifier.contains(Modifier::BOLD)
})
.collect();
assert_eq!(
add_emph.len(),
1,
"expected exactly one emphasised token on addition side, got {add_spans:?}"
);
assert_eq!(add_emph[0].content.as_ref(), "y");
}
#[test]
fn word_diff_disabled_emits_single_style_line() {
let theme = Theme::default();
let del_line = travelagent_core::model::DiffLine {
origin: LineOrigin::Deletion,
content: "let x = 1;".to_string(),
old_lineno: Some(1),
new_lineno: None,
highlighted_spans: None,
};
let mut spans: Vec<Span> = Vec::new();
add_deletion_spans(&theme, &mut spans, &del_line, 40, false, None, 4);
for span in &spans {
assert_ne!(
span.style.bg,
Some(theme.syntax_del_bg),
"legacy path should never emit syntax_del_bg on a token: {:?}",
span.content
);
assert!(
!span.style.add_modifier.contains(Modifier::BOLD),
"legacy path should never emit Modifier::BOLD: {:?}",
span.content
);
}
assert_eq!(spans.len(), 3, "expected 3 spans on the legacy path");
let content = spans[2].content.as_ref();
assert_eq!(
content.chars().count(),
40,
"legacy content span must be padded to exactly the content_width"
);
assert!(content.starts_with("let x = 1;"));
}
#[test]
fn word_diff_overlays_emphasis_on_syntax_highlighted_lines() {
let theme = Theme::default();
let highlighter = travelagent_core::syntax::SyntaxHighlighter::default();
let lines = vec!["let removed = 1;".to_string()];
let highlighted = highlighter
.highlight_file_lines(std::path::Path::new("test.rs"), &lines)
.unwrap();
let syntax_spans = highlighted[0].as_ref().unwrap().clone();
let del_line = travelagent_core::model::DiffLine {
origin: LineOrigin::Deletion,
content: "let removed = 1;".to_string(),
old_lineno: Some(7),
new_lineno: None,
highlighted_spans: Some(syntax_spans),
};
let mut spans: Vec<Span> = Vec::new();
add_deletion_spans(
&theme,
&mut spans,
&del_line,
40,
false,
Some("let added = 1;"),
4,
);
let emphasised: Vec<&Span> = spans
.iter()
.filter(|s| {
s.style.bg == Some(theme.syntax_del_bg)
&& s.style.add_modifier.contains(Modifier::BOLD)
})
.collect();
assert!(
!emphasised.is_empty(),
"expected at least one overlay-emphasised span on syntax-highlighted line"
);
let emphasised_text: String = emphasised.iter().map(|s| s.content.as_ref()).collect();
assert!(
emphasised_text.contains("removed"),
"expected 'removed' to appear in emphasised spans, got {emphasised_text:?}"
);
}
#[test]
fn word_diff_overlay_still_skipped_when_no_partner() {
let theme = Theme::default();
let highlighter = travelagent_core::syntax::SyntaxHighlighter::default();
let lines = vec!["let removed = 1;".to_string()];
let highlighted = highlighter
.highlight_file_lines(std::path::Path::new("test.rs"), &lines)
.unwrap();
let syntax_spans = highlighted[0].as_ref().unwrap().clone();
let del_line = travelagent_core::model::DiffLine {
origin: LineOrigin::Deletion,
content: "let removed = 1;".to_string(),
old_lineno: Some(7),
new_lineno: None,
highlighted_spans: Some(syntax_spans),
};
let mut spans: Vec<Span> = Vec::new();
add_deletion_spans(&theme, &mut spans, &del_line, 40, false, None, 4);
for span in &spans {
assert!(
!span.style.add_modifier.contains(Modifier::BOLD),
"no BOLD expected on no-partner syntax line: {:?}",
span.content
);
}
}
}