use ratatui::{
Frame,
layout::Rect,
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
};
use unicode_width::UnicodeWidthStr;
use crate::app::{
App, DiffSource, ExpandDirection, FocusedPanel, GAP_EXPAND_BATCH, GapId, InputMode,
};
use crate::model::{DiffLine, FileStatus, LineOrigin, LineRange, LineSide};
use crate::theme::Theme;
use crate::ui::comment_panel;
use crate::ui::diff_view::{
apply_horizontal_scroll, comment_type_presentation, cursor_indicator, cursor_indicator_spaced,
diff_stat_title, hunk_header_text_and_style, paint_cursor_line_highlight,
paint_visual_selection_overlay, populate_row_to_annotation, render_expander_line,
render_hidden_lines, scroll_comment_input_into_view,
};
use crate::ui::styles;
use crate::ui::text_utils::{truncate_or_pad, truncate_or_pad_spans, wrap_spans};
use crate::vcs::git::calculate_gap;
#[derive(Clone, Default)]
struct SbsRowMeta {
left_content: Vec<Span<'static>>,
right_content: Vec<Span<'static>>,
left_prefix: Vec<Span<'static>>,
right_prefix: Vec<Span<'static>>,
left_pad_style: Style,
right_pad_style: Style,
}
fn content_spans_for_diff_line(
theme: &Theme,
dl: &DiffLine,
origin: LineOrigin,
) -> Vec<Span<'static>> {
let base = match origin {
LineOrigin::Context => styles::diff_context_style(theme),
LineOrigin::Addition => styles::diff_add_style(theme),
LineOrigin::Deletion => styles::diff_del_style(theme),
};
if let Some(ref h) = dl.highlighted_spans {
h.iter().map(|(s, t)| Span::styled(t.clone(), *s)).collect()
} else {
vec![Span::styled(dl.content.clone(), base)]
}
}
fn column_pad_style(theme: &Theme, dl: &DiffLine, origin: LineOrigin) -> Style {
match origin {
LineOrigin::Context => styles::diff_context_style(theme),
LineOrigin::Addition => {
if dl.highlighted_spans.is_some() {
Style::default().fg(theme.diff_add).bg(theme.syntax_add_bg)
} else {
styles::diff_add_style(theme)
}
}
LineOrigin::Deletion => {
if dl.highlighted_spans.is_some() {
Style::default().fg(theme.diff_del).bg(theme.syntax_del_bg)
} else {
styles::diff_del_style(theme)
}
}
}
}
fn pad_spans_to_width(
mut spans: Vec<Span<'static>>,
width: usize,
pad_style: Style,
) -> Vec<Span<'static>> {
let cur: usize = spans.iter().map(|s| s.content.width()).sum();
if cur < width {
spans.push(Span::styled(" ".repeat(width - cur), pad_style));
}
spans
}
struct SideSpec {
lineno: Option<u32>,
marker: &'static str,
marker_style: Style,
}
fn sbs_row_prefixes(
theme: &Theme,
indicator: &'static str,
left: SideSpec,
right: SideSpec,
lw: usize,
) -> (Vec<Span<'static>>, Vec<Span<'static>>) {
let dim = styles::dim_style(theme);
let old_num = left
.lineno
.map(|n| format!("{n:>lw$}"))
.unwrap_or_else(|| " ".repeat(lw));
let new_num = right
.lineno
.map(|n| format!("{n:>lw$}"))
.unwrap_or_else(|| " ".repeat(lw));
let left_prefix = vec![
Span::styled(indicator, styles::current_line_indicator_style(theme)),
Span::styled(format!("{old_num} "), dim),
Span::styled(left.marker.to_string(), left.marker_style),
];
let right_prefix = vec![
Span::styled(" │ ", dim),
Span::styled(format!("{new_num} "), dim),
Span::styled(right.marker.to_string(), right.marker_style),
];
(left_prefix, right_prefix)
}
fn sbs_blank_prefixes(theme: &Theme, lw: usize) -> (Vec<Span<'static>>, Vec<Span<'static>>) {
let dim = styles::dim_style(theme);
let left = vec![Span::styled(" ".repeat(lw + 3), Style::default())];
let right = vec![
Span::styled(" │ ", dim),
Span::styled(" ".repeat(lw + 2), Style::default()),
];
(left, right)
}
type SideBySideCursorInfo = (usize, u16, usize, usize, usize);
struct SideBySideContext<'a> {
app: &'a App,
theme: &'a Theme,
content_width: usize,
panel_width: usize,
current_line_idx: usize,
lineno_width: usize,
comment_input_mode: bool,
comment_line: Option<(u32, LineSide)>,
comment_type: crate::model::CommentType,
comment_buffer: &'a str,
comment_cursor: usize,
comment_line_range: Option<LineRange>,
editing_comment_id: Option<&'a str>,
current_file_idx: usize,
comment_bars: std::cell::RefCell<Vec<crate::ui::diff_view::CommentBarAnchor>>,
sbs_meta: std::cell::RefCell<std::collections::HashMap<usize, SbsRowMeta>>,
visible_start: usize,
visible_end: usize,
}
impl SideBySideContext<'_> {
fn is_visible(&self, line_idx: usize) -> bool {
line_idx >= self.visible_start && line_idx < self.visible_end
}
fn display_lineno(&self, source_line: Option<u32>, line_idx: usize) -> Option<u32> {
source_line.map(|line| {
if self.app.relative_line_numbers {
line_idx.abs_diff(self.current_line_idx) as u32
} else {
line
}
})
}
}
pub(super) fn render_side_by_side_diff(frame: &mut Frame, app: &mut App, area: Rect) {
let focused = app.focused_panel == FocusedPanel::Diff;
let title = crate::ui::diff_view::diff_title(app, area.width);
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;
app.diff_inner_area = Some(inner);
app.comment_input_annotation_offset = None;
let lw = app.lineno_width();
let available_width = inner.width.saturating_sub(crate::app::sbs_overhead(lw)) as usize;
let content_width = available_width / 2;
let comment_input_mode = app.input_mode == InputMode::Comment
&& !app.comment_is_file_level
&& !app.comment_is_review_level;
let (visible_start, visible_end) = crate::ui::diff_view::diff_visible_range(app, inner);
let ctx = SideBySideContext {
app,
theme: &app.theme,
content_width,
panel_width: inner.width as usize,
current_line_idx: app.diff_state.cursor_line,
lineno_width: lw,
comment_input_mode,
comment_line: app.comment_line,
comment_type: app.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.editing_comment_id.as_deref(),
current_file_idx: app.diff_state.current_file_idx,
comment_bars: std::cell::RefCell::new(Vec::new()),
sbs_meta: std::cell::RefCell::new(std::collections::HashMap::new()),
visible_start,
visible_end,
};
let mut lines: Vec<Line> = 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 mut annotation_offset: Option<(usize, usize, usize)> = None;
let is_review_comment_mode =
app.input_mode == InputMode::Comment && app.comment_is_review_level;
crate::ui::pr_info_panel::append_pr_info_section(
app,
&mut lines,
&mut line_idx,
ctx.current_line_idx,
);
if !app.is_single_file_view {
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(
crate::ui::diff_view::REVIEW_COMMENTS_HEADER_PREFIX,
styles::file_header_style(&app.theme),
),
Span::styled(
crate::ui::diff_view::HEADER_RULE,
styles::file_header_style(&app.theme),
),
]));
line_idx += 1;
}
for summary in &app.forge_review_summaries {
let summary_lines = comment_panel::format_remote_review_summary_lines(
&app.theme,
summary,
app.forge_kind(),
);
for mut summary_line in summary_lines {
let indicator = cursor_indicator(line_idx, ctx.current_line_idx);
summary_line.spans.insert(
0,
Span::styled(indicator, styles::current_line_indicator_style(&app.theme)),
);
lines.push(summary_line);
line_idx += 1;
}
}
for comment in &app.session.review_comments {
let is_being_edited =
app.editing_comment_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_type),
&app.comment_buffer,
app.comment_cursor,
None,
true,
ctx.panel_width.saturating_sub(1),
app.comment_vim_mode_label()
.as_ref()
.map(|(t, w)| (t.as_str(), *w)),
);
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)));
let annotations_replaced = App::comment_display_lines(comment, inner.width as usize);
annotation_offset = Some((line_idx, input_lines.len(), annotations_replaced));
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);
line_idx += 1;
}
} else {
let comment_lines = comment_panel::format_comment_lines(
&app.theme,
comment_type_presentation(app, &comment.comment_type),
&comment.content,
None,
ctx.panel_width.saturating_sub(1),
(comment.author != app.username).then_some(comment.author.as_str()),
);
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);
line_idx += 1;
}
}
}
{
use crate::forge::remote_comments::{PrCommentsVisibility, RemoteCommentSide};
let _ = RemoteCommentSide::Right; let visibility = app.session.remote_comments_visibility;
if !matches!(visibility, PrCommentsVisibility::Hide) {
for thread in &app.forge_review_threads {
if thread.line.is_some() {
continue; }
let Some(muted) = visibility.render_decision(thread) else {
continue;
};
let thread_lines = comment_panel::format_remote_thread_lines(
&app.theme,
thread,
muted,
app.forge_kind(),
);
for mut comment_line in thread_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);
line_idx += 1;
}
}
}
}
if is_review_comment_mode && app.editing_comment_id.is_none() {
let (input_lines, cursor_info) = comment_panel::format_comment_input_lines(
&app.theme,
comment_type_presentation(app, &app.comment_type),
&app.comment_buffer,
app.comment_cursor,
None,
false,
ctx.panel_width.saturating_sub(1),
app.comment_vim_mode_label()
.as_ref()
.map(|(t, w)| (t.as_str(), *w)),
);
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)));
annotation_offset = Some((line_idx, input_lines.len(), 0));
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);
line_idx += 1;
}
}
crate::ui::pr_info_panel::append_issue_comments_section(
app,
&mut lines,
&mut line_idx,
ctx.current_line_idx,
ctx.panel_width.saturating_sub(1),
);
for (file_idx, file) in app.diff_files.iter().enumerate() {
if app.is_single_file_view && file_idx != app.diff_state.current_file_idx {
continue;
}
if !app.file_passes_filter(file) {
continue;
}
let path = file.display_path();
let is_reviewed = app.session.is_file_reviewed(path);
if !app.is_single_file_view {
let indicator = cursor_indicator_spaced(line_idx, ctx.current_line_idx);
let header_text = crate::ui::diff_view::file_header_prefix_text(app, file);
lines.push(Line::from(vec![
Span::styled(indicator, styles::current_line_indicator_style(&app.theme)),
Span::styled(header_text, styles::file_header_style(&app.theme)),
Span::styled(
crate::ui::diff_view::HEADER_RULE,
styles::file_header_style(&app.theme),
),
]));
line_idx += 1;
}
if is_reviewed && !app.is_single_file_view {
continue;
}
if is_reviewed && app.is_single_file_view {
let indicator = cursor_indicator(line_idx, ctx.current_line_idx);
lines.push(Line::from(vec![
Span::styled(indicator, styles::current_line_indicator_style(&app.theme)),
Span::styled(
crate::ui::diff_view::REVIEWED_BANNER_TEXT,
Style::default()
.fg(app.theme.fg_secondary)
.add_modifier(Modifier::DIM),
),
]));
line_idx += 1;
}
let is_file_comment_mode = app.input_mode == InputMode::Comment
&& app.comment_is_file_level
&& file_idx == app.diff_state.current_file_idx;
if let Some(review) = app.session.files.get(path) {
for comment in &review.file_comments {
if !app.comment_visible(comment) {
continue;
}
let is_being_edited =
app.editing_comment_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_type),
&app.comment_buffer,
app.comment_cursor,
None,
true,
ctx.panel_width.saturating_sub(1),
app.comment_vim_mode_label()
.as_ref()
.map(|(t, w)| (t.as_str(), *w)),
);
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)));
let annotations_replaced =
App::comment_display_lines(comment, inner.width as usize);
annotation_offset = Some((line_idx, input_lines.len(), annotations_replaced));
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);
line_idx += 1;
}
} else {
let comment_lines = comment_panel::format_comment_lines(
&app.theme,
comment_type_presentation(app, &comment.comment_type),
&comment.content,
None,
ctx.panel_width.saturating_sub(1),
(comment.author != app.username).then_some(comment.author.as_str()),
);
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);
line_idx += 1;
}
}
}
}
if is_file_comment_mode && app.editing_comment_id.is_none() {
let (input_lines, cursor_info) = comment_panel::format_comment_input_lines(
&app.theme,
comment_type_presentation(app, &app.comment_type),
&app.comment_buffer,
app.comment_cursor,
None,
false,
ctx.panel_width.saturating_sub(1),
app.comment_vim_mode_label()
.as_ref()
.map(|(t, w)| (t.as_str(), *w)),
);
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)));
annotation_offset = Some((line_idx, input_lines.len(), 0));
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);
line_idx += 1;
}
}
if file.is_too_large || file.is_binary || 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(
crate::ui::diff_view::binary_or_empty_label(file),
styles::dim_style(&app.theme),
),
]));
line_idx += 1;
} else {
let line_comments = app
.session
.files
.get(path)
.map(|r| &r.line_comments)
.unwrap_or(&crate::ui::diff_view::EMPTY_LINE_COMMENTS);
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 && app.should_render_gap_before_hunk(file_idx, hunk_idx) {
let top_lines = app.expanded_top.get(&gap_id);
let bot_lines = app.expanded_bottom.get(&gap_id);
let top_len = top_lines.map_or(0, |v| v.len());
let bot_len = bot_lines.map_or(0, |v| v.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 {
if !ctx.is_visible(line_idx) {
lines.push(Line::default());
line_idx += 1;
continue;
}
render_sbs_expanded_context_line(
&mut lines,
&mut line_idx,
expanded_line,
&ctx,
);
}
}
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,
);
}
render_expander_line(
&mut lines,
&mut line_idx,
ctx.current_line_idx,
ExpandDirection::Up,
remaining,
&app.theme,
);
} else if remaining >= GAP_EXPAND_BATCH {
render_expander_line(
&mut lines,
&mut line_idx,
ctx.current_line_idx,
ExpandDirection::Down,
remaining,
&app.theme,
);
render_hidden_lines(
&mut lines,
&mut line_idx,
ctx.current_line_idx,
remaining,
&app.theme,
);
render_expander_line(
&mut lines,
&mut line_idx,
ctx.current_line_idx,
ExpandDirection::Up,
remaining,
&app.theme,
);
} else {
render_expander_line(
&mut lines,
&mut line_idx,
ctx.current_line_idx,
ExpandDirection::Both,
remaining,
&app.theme,
);
}
}
if let Some(bot) = bot_lines {
for expanded_line in bot {
if !ctx.is_visible(line_idx) {
lines.push(Line::default());
line_idx += 1;
continue;
}
render_sbs_expanded_context_line(
&mut lines,
&mut line_idx,
expanded_line,
&ctx,
);
}
}
}
let is_hunk_reviewed = app.is_hunk_reviewed(file_idx, hunk_idx);
let (hunk_header_text, hunk_header_style) =
hunk_header_text_and_style(&app.theme, hunk, is_hunk_reviewed);
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_text, hunk_header_style),
]));
line_idx += 1;
if is_hunk_reviewed {
continue;
}
let (new_line_idx, cursor_info) = render_hunk_lines_side_by_side(
&hunk.lines,
line_comments,
&ctx,
file_idx,
line_idx,
&mut lines,
);
line_idx = new_line_idx;
if let Some((line, col, box_start, box_end, annotations_replaced)) = cursor_info {
comment_cursor_logical_line = Some(line);
comment_cursor_column = col;
comment_input_box_range = Some((box_start, box_end));
let box_len = box_end - box_start + 1;
annotation_offset = Some((box_start, box_len, annotations_replaced));
}
}
}
if file.status != FileStatus::Deleted
&& matches!(
app.diff_source,
DiffSource::WorkingTree
| DiffSource::Unstaged
| DiffSource::StagedAndUnstaged
| DiffSource::StagedUnstagedAndCommits(_)
| DiffSource::CommitRange(_)
| DiffSource::PullRequest(_)
)
&& let Some(last_hunk) = file.hunks.last()
{
let eof_start = last_hunk.new_start + last_hunk.new_count;
if let Some(&total) = app.file_line_count_cache.get(&file_idx)
&& eof_start <= total
{
let gap = (total - eof_start + 1) as usize;
let eof_gap_id = GapId {
file_idx,
hunk_idx: file.hunks.len(),
};
let top_lines = app.expanded_top.get(&eof_gap_id);
let bot_lines = app.expanded_bottom.get(&eof_gap_id);
let top_len = top_lines.map_or(0, |v| v.len());
let bot_len = bot_lines.map_or(0, |v| v.len());
let remaining = gap.saturating_sub(top_len + bot_len);
if let Some(top) = top_lines {
for expanded_line in top {
render_sbs_expanded_context_line(
&mut lines,
&mut line_idx,
expanded_line,
&ctx,
);
}
}
if remaining > 0 {
render_expander_line(
&mut lines,
&mut line_idx,
ctx.current_line_idx,
ExpandDirection::Down,
remaining,
&app.theme,
);
if remaining > GAP_EXPAND_BATCH {
render_hidden_lines(
&mut lines,
&mut line_idx,
ctx.current_line_idx,
remaining,
&app.theme,
);
}
}
if let Some(bot) = bot_lines {
for expanded_line in bot {
render_sbs_expanded_context_line(
&mut lines,
&mut line_idx,
expanded_line,
&ctx,
);
}
}
}
}
let indicator = cursor_indicator(line_idx, ctx.current_line_idx);
lines.push(Line::from(Span::styled(
indicator,
styles::current_line_indicator_style(&app.theme),
)));
line_idx += 1;
}
let comment_bars = {
let mut bars = ctx.comment_bars.borrow_mut();
std::mem::take(&mut *bars)
};
let sbs_meta = {
let mut m = ctx.sbs_meta.borrow_mut();
std::mem::take(&mut *m)
};
drop(ctx);
app.comment_input_annotation_offset = annotation_offset;
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(),
);
let visible_lines_unscrolled: Vec<Line> = lines
.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.sync_viewport_width(inner.width as usize);
app.diff_state.max_content_width = max_content_width;
let scroll_offset = app.diff_state.scroll_offset;
let wrap = app.diff_state.wrap_lines;
let viewport_width = inner.width as usize;
let visible_lines_unscrolled_for_overlay = visible_lines_unscrolled.clone();
let (row_heights, wrapped_lines): (Vec<usize>, Option<Vec<Line>>) = if wrap && content_width > 0
{
let mut heights = Vec::with_capacity(visible_lines_unscrolled_for_overlay.len());
let mut out: Vec<Line> = Vec::new();
let (left_prefix_blank, right_prefix_blank) = sbs_blank_prefixes(&app.theme, lw);
for (i, line) in visible_lines_unscrolled_for_overlay.iter().enumerate() {
let logical_idx = scroll_offset + i;
match sbs_meta.get(&logical_idx) {
Some(m) => {
let left_rows = if m.left_content.is_empty() {
vec![Vec::new()]
} else {
wrap_spans(&m.left_content, content_width)
};
let right_rows = if m.right_content.is_empty() {
vec![Vec::new()]
} else {
wrap_spans(&m.right_content, content_width)
};
let n = left_rows.len().max(right_rows.len()).max(1);
heights.push(n);
let empty_row: Vec<Span> = Vec::new();
for k in 0..n {
let left_content_row = left_rows.get(k).unwrap_or(&empty_row).clone();
let right_content_row = right_rows.get(k).unwrap_or(&empty_row).clone();
let left_padded =
pad_spans_to_width(left_content_row, content_width, m.left_pad_style);
let right_padded =
pad_spans_to_width(right_content_row, content_width, m.right_pad_style);
let (left_prefix, right_prefix) = if k == 0 {
(m.left_prefix.clone(), m.right_prefix.clone())
} else {
(left_prefix_blank.clone(), right_prefix_blank.clone())
};
let mut spans = left_prefix;
spans.extend(left_padded);
spans.extend(right_prefix);
spans.extend(right_padded);
out.push(Line::from(spans));
}
}
None => {
let rows = wrap_spans(&line.spans, viewport_width);
heights.push(rows.len());
out.extend(rows.into_iter().map(Line::from));
}
}
}
(heights, Some(out))
} else {
(vec![1; visible_lines_unscrolled_for_overlay.len()], None)
};
app.diff_state.visible_line_count = populate_row_to_annotation(
&mut app.diff_row_to_annotation,
&row_heights,
viewport_width,
inner.height as usize,
wrap,
scroll_offset,
);
let max_scroll_x = max_content_width.saturating_sub(viewport_width);
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> = match wrapped_lines {
Some(out) => out,
None => visible_lines_unscrolled
.into_iter()
.map(|line| apply_horizontal_scroll(line, scroll_x))
.collect(),
};
let overlay_ctx = crate::ui::diff_view::DiffOverlayPaint {
inner,
visible_lines_unscrolled: &visible_lines_unscrolled_for_overlay,
line_widths: &line_widths,
row_heights: &row_heights,
wrap_lines: app.diff_state.wrap_lines,
viewport_width: inner.width as usize,
scroll_x,
scroll_offset: app.diff_state.scroll_offset,
theme: &app.theme,
comment_bars: &comment_bars,
};
crate::ui::diff_view::paint_section_highlight(frame, &overlay_ctx);
let diff = Paragraph::new(visible_lines).style(styles::panel_style(&app.theme));
frame.render_widget(diff, inner);
paint_cursor_line_highlight(
frame,
inner,
&visible_lines_unscrolled_for_overlay,
&row_heights,
app,
);
if let Some(sel) = app.visual_selection {
paint_visual_selection_overlay(frame, inner, app, sel, &app.theme);
}
crate::ui::diff_view::paint_file_header_fill(frame, &overlay_ctx);
crate::ui::diff_view::paint_comment_box_bar(frame, &overlay_ctx);
crate::ui::diff_view::paint_comment_box_right_border(frame, &overlay_ctx);
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 {
visual_row += row_heights.get(i).copied().unwrap_or(1) as u16;
}
} 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_sbs_expanded_context_line(
lines: &mut Vec<Line<'_>>,
line_idx: &mut usize,
expanded_line: &crate::model::DiffLine,
ctx: &SideBySideContext,
) {
let theme = ctx.theme;
let lw = ctx.lineno_width;
let content_width = ctx.content_width;
let indicator = cursor_indicator(*line_idx, ctx.current_line_idx);
let old_line_num = ctx
.display_lineno(expanded_line.old_lineno, *line_idx)
.map(|n| format!("{n:>lw$} "))
.unwrap_or_else(|| " ".repeat(lw + 1));
let new_line_num = ctx
.display_lineno(expanded_line.new_lineno, *line_idx)
.map(|n| format!("{n:>lw$} "))
.unwrap_or_else(|| " ".repeat(lw + 1));
let ec_style = styles::expanded_context_style(theme);
let line_spans = vec![
Span::styled(indicator, styles::current_line_indicator_style(theme)),
Span::styled(old_line_num.clone(), ec_style),
Span::styled(" ", ec_style),
Span::styled(
truncate_or_pad(&expanded_line.content, content_width),
ec_style,
),
Span::styled(" │ ", styles::dim_style(theme)),
Span::styled(new_line_num.clone(), ec_style),
Span::styled(" ", ec_style),
Span::styled(
truncate_or_pad(&expanded_line.content, content_width),
ec_style,
),
];
lines.push(Line::from(line_spans));
let dim = styles::dim_style(theme);
let left_prefix = vec![
Span::styled(indicator, styles::current_line_indicator_style(theme)),
Span::styled(old_line_num, ec_style),
Span::styled(" ", ec_style),
];
let right_prefix = vec![
Span::styled(" │ ", dim),
Span::styled(new_line_num, ec_style),
Span::styled(" ", ec_style),
];
let content = vec![Span::styled(expanded_line.content.clone(), ec_style)];
ctx.sbs_meta.borrow_mut().insert(
*line_idx,
SbsRowMeta {
left_content: content.clone(),
right_content: content,
left_prefix,
right_prefix,
left_pad_style: ec_style,
right_pad_style: ec_style,
},
);
*line_idx += 1;
}
fn render_hunk_lines_side_by_side(
hunk_lines: &[crate::model::DiffLine],
line_comments: &std::collections::HashMap<u32, Vec<crate::model::Comment>>,
ctx: &SideBySideContext,
file_idx: usize,
mut line_idx: usize,
lines: &mut Vec<Line>,
) -> (usize, Option<SideBySideCursorInfo>) {
let mut i = 0;
let mut cursor_info_out: Option<SideBySideCursorInfo> = None;
let is_commit_msg = ctx
.app
.diff_files
.get(file_idx)
.is_some_and(|f| f.is_commit_message);
while i < hunk_lines.len() {
let diff_line = &hunk_lines[i];
match diff_line.origin {
LineOrigin::Context if is_commit_msg => {
let (new_line_idx, cursor_info) = render_commit_message_line_side_by_side(
diff_line,
line_comments,
ctx,
file_idx,
line_idx,
lines,
);
line_idx = new_line_idx;
if cursor_info.is_some() {
cursor_info_out = cursor_info;
}
i += 1;
}
LineOrigin::Context => {
let (new_line_idx, cursor_info) = render_context_line_side_by_side(
diff_line,
line_comments,
ctx,
file_idx,
line_idx,
lines,
);
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,
file_idx,
line_idx,
lines,
);
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,
file_idx,
line_idx,
lines,
);
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: &crate::model::DiffLine,
line_comments: &std::collections::HashMap<u32, Vec<crate::model::Comment>>,
ctx: &SideBySideContext,
file_idx: usize,
mut line_idx: usize,
lines: &mut Vec<Line>,
) -> (usize, Option<SideBySideCursorInfo>) {
if ctx.is_visible(line_idx) {
let w = ctx.lineno_width;
let old_line_num = ctx
.display_lineno(diff_line.old_lineno, line_idx)
.map(|n| format!("{n:>w$}"))
.unwrap_or_else(|| " ".repeat(w));
let new_line_num = ctx
.display_lineno(diff_line.new_lineno, line_idx)
.map(|n| format!("{n:>w$}"))
.unwrap_or_else(|| " ".repeat(w));
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!("{old_line_num} "), styles::dim_style(ctx.theme)),
Span::styled(" ".to_string(), 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,
styles::diff_context_style(ctx.theme),
);
spans.extend(content_spans);
} else {
let content = truncate_or_pad(&diff_line.content, ctx.content_width);
spans.push(Span::styled(content, styles::diff_context_style(ctx.theme)));
}
spans.push(Span::styled(" │ ", styles::dim_style(ctx.theme)));
spans.push(Span::styled(
format!("{new_line_num} "),
styles::dim_style(ctx.theme),
));
spans.push(Span::styled(
" ".to_string(),
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,
styles::diff_context_style(ctx.theme),
);
spans.extend(content_spans);
} else {
let content = truncate_or_pad(&diff_line.content, ctx.content_width);
spans.push(Span::styled(content, styles::diff_context_style(ctx.theme)));
}
lines.push(Line::from(spans));
let content = content_spans_for_diff_line(ctx.theme, diff_line, LineOrigin::Context);
let ctx_style = styles::diff_context_style(ctx.theme);
let (lp, rp) = sbs_row_prefixes(
ctx.theme,
indicator,
SideSpec {
lineno: ctx.display_lineno(diff_line.old_lineno, line_idx),
marker: " ",
marker_style: ctx_style,
},
SideSpec {
lineno: ctx.display_lineno(diff_line.new_lineno, line_idx),
marker: " ",
marker_style: ctx_style,
},
w,
);
ctx.sbs_meta.borrow_mut().insert(
line_idx,
SbsRowMeta {
left_content: content.clone(),
right_content: content,
left_prefix: lp,
right_prefix: rp,
left_pad_style: ctx_style,
right_pad_style: ctx_style,
},
);
} else {
lines.push(Line::default());
}
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,
file_idx,
line_idx,
lines,
);
line_idx = new_line_idx;
cursor_info_out = cursor_info;
if let Some(file) = ctx.app.diff_files.get(file_idx) {
line_idx = add_remote_threads_to_line(
new_ln,
LineSide::New,
ctx,
file.display_path(),
line_idx,
lines,
);
}
}
(line_idx, cursor_info_out)
}
fn render_deletion_addition_pair_side_by_side(
hunk_lines: &[crate::model::DiffLine],
start_idx: usize,
line_comments: &std::collections::HashMap<u32, Vec<crate::model::Comment>>,
ctx: &SideBySideContext,
file_idx: usize,
mut line_idx: usize,
lines: &mut Vec<Line>,
) -> (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;
for offset in 0..max_lines {
let del_opt = (offset < del_count).then(|| &hunk_lines[start_idx + offset]);
let add_opt = (offset < add_count).then(|| &hunk_lines[add_start + offset]);
if ctx.is_visible(line_idx) {
let indicator = cursor_indicator(line_idx, ctx.current_line_idx);
let mut spans = vec![Span::styled(
indicator,
styles::current_line_indicator_style(ctx.theme),
)];
if let Some(del_line) = del_opt {
add_deletion_spans(
ctx.theme,
&mut spans,
del_line,
ctx.content_width,
ctx.lineno_width,
ctx.display_lineno(del_line.old_lineno, line_idx),
);
} else {
add_empty_column_spans(&mut spans, ctx.content_width, ctx.lineno_width);
}
spans.push(Span::styled(" │ ", styles::dim_style(ctx.theme)));
if let Some(add_line) = add_opt {
add_addition_spans(
ctx.theme,
&mut spans,
add_line,
ctx.content_width,
ctx.lineno_width,
ctx.display_lineno(add_line.new_lineno, line_idx),
);
} else {
add_empty_column_spans(&mut spans, ctx.content_width, ctx.lineno_width);
}
lines.push(Line::from(spans));
let w = ctx.lineno_width;
let (left_content, left_pad, left_marker, left_lineno, left_marker_style) =
match del_opt {
Some(dl) => (
content_spans_for_diff_line(ctx.theme, dl, LineOrigin::Deletion),
column_pad_style(ctx.theme, dl, LineOrigin::Deletion),
"▌",
ctx.display_lineno(dl.old_lineno, line_idx),
styles::diff_del_style(ctx.theme),
),
None => (Vec::new(), Style::default(), " ", None, Style::default()),
};
let (right_content, right_pad, right_marker, right_lineno, right_marker_style) =
match add_opt {
Some(al) => (
content_spans_for_diff_line(ctx.theme, al, LineOrigin::Addition),
column_pad_style(ctx.theme, al, LineOrigin::Addition),
"▌",
ctx.display_lineno(al.new_lineno, line_idx),
styles::diff_add_style(ctx.theme),
),
None => (Vec::new(), Style::default(), " ", None, Style::default()),
};
let (lp, rp) = sbs_row_prefixes(
ctx.theme,
indicator,
SideSpec {
lineno: left_lineno,
marker: left_marker,
marker_style: left_marker_style,
},
SideSpec {
lineno: right_lineno,
marker: right_marker,
marker_style: right_marker_style,
},
w,
);
ctx.sbs_meta.borrow_mut().insert(
line_idx,
SbsRowMeta {
left_content,
right_content,
left_prefix: lp,
right_prefix: rp,
left_pad_style: left_pad,
right_pad_style: right_pad,
},
);
} else {
lines.push(Line::default());
}
line_idx += 1;
if let Some(del_line) = del_opt
&& 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,
file_idx,
line_idx,
lines,
);
line_idx = new_line_idx;
if cursor_info.is_some() {
cursor_info_out = cursor_info;
}
if let Some(file) = ctx.app.diff_files.get(file_idx) {
line_idx = add_remote_threads_to_line(
old_ln,
LineSide::Old,
ctx,
file.display_path(),
line_idx,
lines,
);
}
}
if let Some(add_line) = add_opt
&& 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,
file_idx,
line_idx,
lines,
);
line_idx = new_line_idx;
if cursor_info.is_some() {
cursor_info_out = cursor_info;
}
if let Some(file) = ctx.app.diff_files.get(file_idx) {
line_idx = add_remote_threads_to_line(
new_ln,
LineSide::New,
ctx,
file.display_path(),
line_idx,
lines,
);
}
}
}
(line_idx, add_end, cursor_info_out)
}
fn render_standalone_addition_side_by_side(
diff_line: &crate::model::DiffLine,
line_comments: &std::collections::HashMap<u32, Vec<crate::model::Comment>>,
ctx: &SideBySideContext,
file_idx: usize,
mut line_idx: usize,
lines: &mut Vec<Line>,
) -> (usize, Option<SideBySideCursorInfo>) {
if ctx.is_visible(line_idx) {
let indicator = cursor_indicator(line_idx, ctx.current_line_idx);
let mut spans = vec![Span::styled(
indicator,
styles::current_line_indicator_style(ctx.theme),
)];
add_empty_column_spans(&mut spans, ctx.content_width, ctx.lineno_width);
spans.push(Span::styled(" │ ", styles::dim_style(ctx.theme)));
add_addition_spans(
ctx.theme,
&mut spans,
diff_line,
ctx.content_width,
ctx.lineno_width,
ctx.display_lineno(diff_line.new_lineno, line_idx),
);
lines.push(Line::from(spans));
let w = ctx.lineno_width;
let right_content = content_spans_for_diff_line(ctx.theme, diff_line, LineOrigin::Addition);
let right_pad = column_pad_style(ctx.theme, diff_line, LineOrigin::Addition);
let (lp, rp) = sbs_row_prefixes(
ctx.theme,
indicator,
SideSpec {
lineno: None,
marker: " ",
marker_style: Style::default(),
},
SideSpec {
lineno: ctx.display_lineno(diff_line.new_lineno, line_idx),
marker: "▌",
marker_style: styles::diff_add_style(ctx.theme),
},
w,
);
ctx.sbs_meta.borrow_mut().insert(
line_idx,
SbsRowMeta {
left_content: Vec::new(),
right_content,
left_prefix: lp,
right_prefix: rp,
left_pad_style: Style::default(),
right_pad_style: right_pad,
},
);
} else {
lines.push(Line::default());
}
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,
file_idx,
line_idx,
lines,
);
line_idx = new_line_idx;
cursor_info_out = cursor_info;
if let Some(file) = ctx.app.diff_files.get(file_idx) {
line_idx = add_remote_threads_to_line(
new_ln,
LineSide::New,
ctx,
file.display_path(),
line_idx,
lines,
);
}
}
(line_idx, cursor_info_out)
}
fn render_commit_message_line_side_by_side(
diff_line: &crate::model::DiffLine,
line_comments: &std::collections::HashMap<u32, Vec<crate::model::Comment>>,
ctx: &SideBySideContext,
file_idx: usize,
mut line_idx: usize,
lines: &mut Vec<Line>,
) -> (usize, Option<SideBySideCursorInfo>) {
let ctx_style = styles::diff_context_style(ctx.theme);
if ctx.is_visible(line_idx) {
let indicator = cursor_indicator(line_idx, ctx.current_line_idx);
let mut spans = vec![Span::styled(
indicator,
styles::current_line_indicator_style(ctx.theme),
)];
spans.push(Span::styled(" ".to_string(), ctx_style));
spans.push(Span::styled(diff_line.content.clone(), ctx_style));
lines.push(Line::from(spans));
} else {
lines.push(Line::default());
}
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,
file_idx,
line_idx,
lines,
);
line_idx = new_line_idx;
cursor_info_out = cursor_info;
if let Some(file) = ctx.app.diff_files.get(file_idx) {
line_idx = add_remote_threads_to_line(
new_ln,
LineSide::New,
ctx,
file.display_path(),
line_idx,
lines,
);
}
}
(line_idx, cursor_info_out)
}
fn add_deletion_spans(
theme: &Theme,
spans: &mut Vec<Span>,
diff_line: &crate::model::DiffLine,
content_width: usize,
lw: usize,
display_lineno: Option<u32>,
) {
let line_num = display_lineno
.map(|n| format!("{n:>lw$}"))
.unwrap_or_else(|| " ".repeat(lw));
spans.push(Span::styled(
format!("{line_num} "),
styles::dim_style(theme),
));
spans.push(Span::styled("▌".to_string(), 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);
let content_spans = truncate_or_pad_spans(highlighted, content_width, syntax_pad_style);
spans.extend(content_spans);
} else {
let content = truncate_or_pad(&diff_line.content, content_width);
spans.push(Span::styled(content, styles::diff_del_style(theme)));
}
}
fn add_addition_spans(
theme: &Theme,
spans: &mut Vec<Span>,
diff_line: &crate::model::DiffLine,
content_width: usize,
lw: usize,
display_lineno: Option<u32>,
) {
let line_num = display_lineno
.map(|n| format!("{n:>lw$}"))
.unwrap_or_else(|| " ".repeat(lw));
spans.push(Span::styled(
format!("{line_num} "),
styles::dim_style(theme),
));
spans.push(Span::styled("▌".to_string(), 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);
let content_spans = truncate_or_pad_spans(highlighted, content_width, syntax_pad_style);
spans.extend(content_spans);
} else {
let content = truncate_or_pad(&diff_line.content, content_width);
spans.push(Span::styled(content, styles::diff_add_style(theme)));
}
}
fn add_empty_column_spans(spans: &mut Vec<Span>, content_width: usize, lw: usize) {
spans.push(Span::styled(
" ".repeat(lw + 1 + 1 + content_width),
Style::default(),
));
}
fn add_remote_threads_to_line(
line_num: u32,
side: LineSide,
ctx: &SideBySideContext,
file_path: &std::path::Path,
mut line_idx: usize,
lines: &mut Vec<Line>,
) -> usize {
use crate::forge::remote_comments::{PrCommentsVisibility, RemoteCommentSide};
let visibility = ctx.app.session.remote_comments_visibility;
if matches!(visibility, PrCommentsVisibility::Hide) {
return line_idx;
}
let target_path = file_path.to_string_lossy();
for thread in &ctx.app.forge_review_threads {
let Some(muted) = visibility.render_decision(thread) else {
continue;
};
if thread.path != *target_path {
continue;
}
let Some(thread_line) = thread.line else {
continue;
};
if thread_line != line_num {
continue;
}
let matches_side = matches!(
(thread.side, side),
(RemoteCommentSide::Right, LineSide::New) | (RemoteCommentSide::Left, LineSide::Old)
);
if !matches_side {
continue;
}
let thread_lines = comment_panel::format_remote_thread_lines(
ctx.theme,
thread,
muted,
ctx.app.forge_kind(),
);
let box_top_row = line_idx;
for mut comment_line in thread_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);
line_idx += 1;
}
crate::ui::diff_view::push_comment_bar(
&mut ctx.comment_bars.borrow_mut(),
box_top_row,
Some(crate::model::LineRange::single(thread_line)),
);
}
line_idx
}
fn add_comments_to_line(
line_num: u32,
line_comments: &std::collections::HashMap<u32, Vec<crate::model::Comment>>,
side: LineSide,
ctx: &SideBySideContext,
file_idx: usize,
mut line_idx: usize,
lines: &mut Vec<Line>,
) -> (usize, Option<SideBySideCursorInfo>) {
let is_line_comment_mode = ctx.comment_input_mode
&& file_idx == ctx.current_file_idx
&& 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))
&& ctx.app.comment_visible(comment)
{
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.panel_width.saturating_sub(1),
ctx.app
.comment_vim_mode_label()
.as_ref()
.map(|(t, w)| (t.as_str(), *w)),
);
let box_top_row = line_idx;
let box_end = line_idx + input_lines.len().saturating_sub(1);
let annotations_replaced = App::comment_display_lines(comment, ctx.panel_width);
cursor_info_out = Some((
line_idx + cursor_info.line_offset,
1 + cursor_info.column,
line_idx,
box_end,
annotations_replaced,
));
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);
line_idx += 1;
}
crate::ui::diff_view::push_comment_bar(
&mut ctx.comment_bars.borrow_mut(),
box_top_row,
line_range,
);
} 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,
ctx.panel_width.saturating_sub(1),
(comment.author != ctx.app.username).then_some(comment.author.as_str()),
);
let box_top_row = line_idx;
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);
line_idx += 1;
}
crate::ui::diff_view::push_comment_bar(
&mut ctx.comment_bars.borrow_mut(),
box_top_row,
line_range,
);
}
}
}
}
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.panel_width.saturating_sub(1),
ctx.app
.comment_vim_mode_label()
.as_ref()
.map(|(t, w)| (t.as_str(), *w)),
);
let box_top_row = line_idx;
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,
0,
));
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);
line_idx += 1;
}
crate::ui::diff_view::push_comment_bar(
&mut ctx.comment_bars.borrow_mut(),
box_top_row,
line_range,
);
}
(line_idx, cursor_info_out)
}
#[cfg(test)]
mod remote_comments_side_by_side_snapshot_tests {
use crate::app::{App, DiffSource, DiffViewMode, InputMode, PullRequestDiffSource};
use crate::error::Result as TuicrResult;
use crate::error::TuicrError;
use crate::forge::remote_comments::{
PrCommentsVisibility, RemoteCommentSide, RemoteReviewComment, RemoteReviewThread,
};
use crate::forge::traits::{ForgeRepository, PrSessionKey};
use crate::model::{
DiffFile, DiffHunk, DiffLine, FileStatus, LineOrigin, ReviewSession, SessionDiffSource,
};
use crate::syntax::SyntaxHighlighter;
use crate::theme::Theme;
use crate::ui::render;
use crate::vcs::traits::{VcsBackend, VcsChangeStatus, VcsInfo, VcsType};
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use std::path::{Path, PathBuf};
struct SnapshotVcs {
info: VcsInfo,
}
impl VcsBackend for SnapshotVcs {
fn info(&self) -> &VcsInfo {
&self.info
}
fn get_working_tree_diff(
&self,
_highlighter: &SyntaxHighlighter,
) -> TuicrResult<Vec<DiffFile>> {
Err(TuicrError::NoChanges)
}
fn fetch_context_lines(
&self,
_file_path: &Path,
_file_status: FileStatus,
_ref_commit: Option<&str>,
_start_line: u32,
_end_line: u32,
) -> TuicrResult<Vec<DiffLine>> {
Ok(Vec::new())
}
fn get_change_status(&self) -> TuicrResult<VcsChangeStatus> {
Ok(VcsChangeStatus {
staged: false,
unstaged: false,
})
}
fn file_line_count(
&self,
_file_path: &Path,
_file_status: FileStatus,
_ref_commit: Option<&str>,
) -> TuicrResult<u32> {
Ok(0)
}
}
fn repo() -> ForgeRepository {
ForgeRepository::github("github.com", "agavra", "tuicr")
}
fn sample_diff_file() -> DiffFile {
let lines = vec![
DiffLine {
origin: LineOrigin::Context,
content: "first".to_string(),
old_lineno: Some(1),
new_lineno: Some(1),
highlighted_spans: None,
},
DiffLine {
origin: LineOrigin::Addition,
content: "second".to_string(),
old_lineno: None,
new_lineno: Some(2),
highlighted_spans: None,
},
];
let hunk = DiffHunk {
header: "@@ -1,1 +1,2 @@".to_string(),
lines,
old_start: 1,
old_count: 1,
new_start: 1,
new_count: 2,
};
let hunks = vec![hunk];
let content_hash = DiffFile::compute_content_hash(&hunks);
DiffFile {
old_path: Some(PathBuf::from("src/lib.rs")),
new_path: Some(PathBuf::from("src/lib.rs")),
status: FileStatus::Modified,
hunks,
is_binary: false,
is_too_large: false,
is_commit_message: false,
content_hash,
}
}
fn thread() -> RemoteReviewThread {
RemoteReviewThread {
id: "T".to_string(),
path: "src/lib.rs".to_string(),
line: Some(2),
side: RemoteCommentSide::Right,
is_resolved: false,
is_outdated: false,
comments: vec![RemoteReviewComment {
id: "C".to_string(),
author: Some("alice".to_string()),
body: "sbs hello".to_string(),
created_at: None,
in_reply_to: None,
url: "https://example.com".to_string(),
}],
}
}
fn make_pr_app() -> App {
let pr = PullRequestDiffSource {
key: PrSessionKey::new(repo(), 125, "headsha".to_string()),
base_sha: "basesha".to_string(),
title: "test pr".to_string(),
url: "https://example.com".to_string(),
head_ref_name: "feat".to_string(),
base_ref_name: "main".to_string(),
state: "OPEN".to_string(),
closed: false,
merged: false,
};
let vcs_info = VcsInfo {
root_path: PathBuf::from("forge:github.com/agavra/tuicr"),
head_commit: "headsha".to_string(),
branch_name: Some("feat".to_string()),
vcs_type: VcsType::File,
};
let mut session = ReviewSession::new(
vcs_info.root_path.clone(),
"headsha".to_string(),
Some("feat".to_string()),
SessionDiffSource::PullRequest,
);
session.pr_session_key = Some(pr.key.clone());
let mut app = App::build(
Box::new(SnapshotVcs {
info: vcs_info.clone(),
}),
vcs_info,
Theme::dark(),
None,
false,
vec![sample_diff_file()],
session,
DiffSource::PullRequest(Box::new(pr)),
InputMode::Normal,
Vec::new(),
None,
None,
)
.expect("build app");
app.diff_view_mode = DiffViewMode::SideBySide;
app
}
fn draw(app: &mut App) -> Buffer {
let backend = TestBackend::new(160, 30);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| render(frame, app))
.expect("draw frame");
terminal.backend().buffer().clone()
}
fn body_text(buffer: &Buffer) -> String {
(0..buffer.area.height)
.map(|y| {
(0..buffer.area.width)
.map(|x| buffer[(x, y)].symbol().to_string())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn should_render_remote_comment_inline_in_side_by_side_diff() {
let mut app = make_pr_app();
app.forge_review_threads = vec![thread()];
app.rebuild_annotations();
let buffer = draw(&mut app);
let body = body_text(&buffer);
assert!(
body.contains("[github @alice]"),
"expected badge in side-by-side render:\n{body}"
);
}
#[test]
fn should_hide_remote_comments_under_comments_hide_in_side_by_side() {
let mut app = make_pr_app();
app.forge_review_threads = vec![thread()];
app.set_remote_comments_visibility(PrCommentsVisibility::Hide);
let buffer = draw(&mut app);
let body = body_text(&buffer);
assert!(
!body.contains("[github @alice"),
"remote comment leaked under Hide:\n{body}"
);
}
fn diff_file_with_pair(left: &str, right: &str) -> DiffFile {
let lines = vec![
DiffLine {
origin: LineOrigin::Deletion,
content: left.to_string(),
old_lineno: Some(1),
new_lineno: None,
highlighted_spans: None,
},
DiffLine {
origin: LineOrigin::Addition,
content: right.to_string(),
old_lineno: None,
new_lineno: Some(1),
highlighted_spans: None,
},
];
let hunks = vec![DiffHunk {
header: "@@ -1,1 +1,1 @@".to_string(),
lines,
old_start: 1,
old_count: 1,
new_start: 1,
new_count: 1,
}];
let content_hash = DiffFile::compute_content_hash(&hunks);
DiffFile {
old_path: Some(PathBuf::from("src/lib.rs")),
new_path: Some(PathBuf::from("src/lib.rs")),
status: FileStatus::Modified,
hunks,
is_binary: false,
is_too_large: false,
is_commit_message: false,
content_hash,
}
}
fn diff_file_with_standalone_deletion(left: &str) -> DiffFile {
let lines = vec![DiffLine {
origin: LineOrigin::Deletion,
content: left.to_string(),
old_lineno: Some(1),
new_lineno: None,
highlighted_spans: None,
}];
let hunks = vec![DiffHunk {
header: "@@ -1,1 +0,0 @@".to_string(),
lines,
old_start: 1,
old_count: 1,
new_start: 0,
new_count: 0,
}];
let content_hash = DiffFile::compute_content_hash(&hunks);
DiffFile {
old_path: Some(PathBuf::from("src/lib.rs")),
new_path: Some(PathBuf::from("src/lib.rs")),
status: FileStatus::Modified,
hunks,
is_binary: false,
is_too_large: false,
is_commit_message: false,
content_hash,
}
}
fn draw_sbs(app: &mut App, w: u16, h: u16) -> Buffer {
let backend = TestBackend::new(w, h);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| super::render_side_by_side_diff(frame, app, Rect::new(0, 0, w, h)))
.expect("draw sbs");
terminal.backend().buffer().clone()
}
fn char_at(buf: &Buffer, x: u16, y: u16) -> String {
buf[(x, y)].symbol().to_string()
}
#[test]
fn should_wrap_long_line_in_side_by_side_view_when_wrap_enabled() {
let long_left = "L".repeat(200);
let mut app = make_pr_app();
app.diff_files = vec![diff_file_with_pair(&long_left, "short")];
app.set_diff_wrap(true);
app.rebuild_annotations();
let buf = draw_sbs(&mut app, 160, 20);
let mut rows_with_l = 0u16;
for y in 0..buf.area.height {
let row: String = (0..buf.area.width).map(|x| char_at(&buf, x, y)).collect();
if row.contains("LLLLLLLLLL") {
rows_with_l += 1;
}
}
assert!(
rows_with_l >= 2,
"expected long left content to span >=2 visual rows, got {rows_with_l}"
);
}
#[test]
fn should_not_wrap_when_wrap_disabled_in_side_by_side() {
let long_left = "L".repeat(200);
let mut app = make_pr_app();
app.diff_files = vec![diff_file_with_pair(&long_left, "short")];
app.set_diff_wrap(false);
app.rebuild_annotations();
let buf = draw_sbs(&mut app, 160, 20);
let rows_with_l: u16 = (0..buf.area.height)
.filter(|&y| {
(0..buf.area.width)
.map(|x| char_at(&buf, x, y))
.collect::<String>()
.contains("LLLLLLLLLL")
})
.count() as u16;
assert_eq!(
rows_with_l, 1,
"wrap-off should produce exactly one row of L, got {rows_with_l}"
);
}
#[test]
fn should_align_divider_on_wrapped_rows_in_side_by_side() {
let long_left = "L".repeat(200);
let mut app = make_pr_app();
app.diff_files = vec![diff_file_with_pair(&long_left, "short")];
app.set_diff_wrap(true);
app.rebuild_annotations();
let lw = 1usize;
let inner_w = 158usize;
let content_width = (inner_w - crate::app::sbs_overhead(lw) as usize) / 2;
let divider_x_inner = crate::app::sbs_left_gutter(lw) as usize + content_width;
let divider_glyph_x = 1 + divider_x_inner + 1;
let buf = draw_sbs(&mut app, 160, 20);
let mut rows_with_l: Vec<u16> = Vec::new();
for y in 0..buf.area.height {
let row: String = (0..buf.area.width).map(|x| char_at(&buf, x, y)).collect();
if row.contains("LLLLLLLLLL") {
rows_with_l.push(y);
}
}
assert!(
rows_with_l.len() >= 2,
"expected ≥2 wrapped rows, got {}",
rows_with_l.len()
);
for y in &rows_with_l {
let glyph = char_at(&buf, divider_glyph_x as u16, *y);
assert_eq!(
glyph, "│",
"expected │ at col {divider_glyph_x} on row {y}, got {glyph:?}"
);
}
}
#[test]
fn should_pad_shorter_column_on_wrapped_rows_in_side_by_side() {
let long_left = "L".repeat(200);
let mut app = make_pr_app();
app.diff_files = vec![diff_file_with_standalone_deletion(&long_left)];
app.set_diff_wrap(true);
app.rebuild_annotations();
let buf = draw_sbs(&mut app, 160, 20);
let lw = 1usize;
let inner_w = 158usize;
let content_width = (inner_w - crate::app::sbs_overhead(lw) as usize) / 2;
let divider_glyph_x = 1 + crate::app::sbs_left_gutter(lw) as usize + content_width + 1;
let right_content_start = divider_glyph_x + 2 + lw + 1 + 1;
let right_content_end = right_content_start + content_width;
let mut checked = 0;
for y in 0..buf.area.height {
let row: String = (0..buf.area.width).map(|x| char_at(&buf, x, y)).collect();
if !row.contains("LLLLLLLLLL") {
continue;
}
checked += 1;
let right: String = (right_content_start..right_content_end)
.map(|x| char_at(&buf, x as u16, y))
.collect();
assert!(
right.trim().is_empty(),
"right column should be blank on wrapped L row {y}, got {right:?}"
);
}
assert!(
checked >= 2,
"expected ≥2 wrapped rows to check, got {checked}"
);
}
fn commit_message_file(message: &str) -> DiffFile {
let lines: Vec<DiffLine> = message
.lines()
.enumerate()
.map(|(i, line)| DiffLine {
origin: LineOrigin::Context,
content: line.to_string(),
old_lineno: None,
new_lineno: Some(i as u32 + 1),
highlighted_spans: None,
})
.collect();
let new_count = lines.len() as u32;
let hunks = vec![DiffHunk {
header: String::new(),
lines,
old_start: 0,
old_count: 0,
new_start: 1,
new_count,
}];
let content_hash = DiffFile::compute_content_hash(&hunks);
DiffFile {
old_path: None,
new_path: Some(PathBuf::from("Commit Message (abc1234)")),
status: FileStatus::Added,
hunks,
is_binary: false,
is_too_large: false,
is_commit_message: true,
content_hash,
}
}
#[test]
fn should_render_commit_message_full_width_in_side_by_side() {
let mut app = make_pr_app();
app.diff_files = vec![commit_message_file("COMMITMSG summary line")];
app.rebuild_annotations();
let buf = draw_sbs(&mut app, 160, 20);
let mut checked = 0;
for y in 0..buf.area.height {
let row: String = (0..buf.area.width).map(|x| char_at(&buf, x, y)).collect();
let Some(col) = row.find("COMMITMSG") else {
continue;
};
checked += 1;
assert!(
col < 8,
"commit message should start near the left edge, got col {col} on row {y}: {row:?}"
);
assert!(
!row.contains(" │ "),
"commit message row should not have a column divider on row {y}: {row:?}"
);
}
assert_eq!(
checked, 1,
"expected the commit message body to render exactly once, got {checked}"
);
}
}