feat: add configurable diff modes and split orientation

This commit is contained in:
2026-10-12 23:43:15 +02:00
parent c35abe0f03
commit d1855dbdb7
14 changed files with 847 additions and 169 deletions
+276 -71
View File
@@ -34,7 +34,9 @@ use unicode_width::UnicodeWidthStr;
use crate::{
chrome::{ChromeLine, PopupAnchor, SegmentConfig},
diff::{ChangedFile, DiffState, DisplayRow, FileStatus, Focus, PaneLayout, Phase, RowKind},
diff::{
ChangedFile, DiffState, DisplayRow, FileStatus, Focus, PaneLayout, Phase, RowKind, ViewMode,
},
editor::{
Completion, Cursor, Editor, Hover, Input, Mode, Palette, Selection, StartupDiagnostic,
},
@@ -2041,6 +2043,9 @@ struct DiffGeometry {
viewport: Option<(u64, usize, usize, usize, usize)>,
text_widths: [u16; 2],
wrapping: bool,
view_mode: ViewMode,
split_axis: SplitAxis,
caret_row: Option<usize>,
}
fn diff_panel(
@@ -2270,14 +2275,7 @@ fn paint_diff_editor(
let cursor_column = display_column(&editor.lines()[source_cursor.row], source_cursor.column);
let mut cursor = None;
for (screen, display, segments) in diff_screen_rows(diff, top, area.height, wrapped) {
let DisplayRow::Line(aligned) = diff.display_rows[display] else {
continue;
};
let Some(source) = (match focus {
Focus::Before => diff.rows[aligned].before,
Focus::After => diff.rows[aligned].after,
Focus::Files => None,
}) else {
let Some(source) = diff.display_source_row(focus, display) else {
continue;
};
let mut line_area = Rect::new(area.x, area.y + screen as u16, area.width, 1);
@@ -2524,6 +2522,9 @@ fn draw_diff(
viewport: None,
text_widths: [0; 2],
wrapping: diff.word_wrap,
view_mode: diff.view_mode,
split_axis: diff.split_axis,
caret_row: None,
});
if let Some(editor) = editor {
draw_diff_popups(frame, editor, area, None, panels);
@@ -2572,10 +2573,15 @@ fn draw_diff(
(workspace, None)
};
let pane_layout = diff.pane_layout();
let [before_area, after_area] = match pane_layout {
PaneLayout::Split => split_rect(pane_rect(panes), SplitAxis::Vertical).map(terminal_rect),
PaneLayout::Before | PaneLayout::Empty => [panes, Rect::default()],
PaneLayout::After => [Rect::default(), panes],
let unified = diff.view_mode == ViewMode::Unified;
let [before_area, after_area] = if unified {
[panes, panes]
} else {
match pane_layout {
PaneLayout::Split => split_rect(pane_rect(panes), diff.split_axis).map(terminal_rect),
PaneLayout::Before | PaneLayout::Empty => [panes, Rect::default()],
PaneLayout::After => [Rect::default(), panes],
}
};
let mut document_caret = None;
let mut top = diff.top;
@@ -2586,7 +2592,9 @@ fn draw_diff(
.previous_diff
.as_ref()
.filter(|previous| {
previous.wrapping != wrapping
(previous.wrapping != wrapping
|| previous.view_mode != diff.view_mode
|| previous.split_axis != diff.split_axis)
&& previous.viewport.is_some_and(|(id, file, _, _, _)| {
Some(id) == diff.comparison_id && file == diff.file
})
@@ -2594,6 +2602,9 @@ fn draw_diff(
.and_then(|previous| {
let editor = editor?;
let focus = editor.diff_buffer_focus()?;
if previous.view_mode != diff.view_mode || previous.split_axis != diff.split_axis {
return previous.caret_row;
}
let cursor = editor.cursor();
if previous.wrapping {
let file = selected?;
@@ -2635,7 +2646,7 @@ fn draw_diff(
Focus::After => (after_area, &mut after_left),
Focus::Files => unreachable!("native diff buffers belong to a text pane"),
};
let height = usize::from(pane.height.saturating_sub(2));
let height = usize::from(diff_inner(pane).height);
if let Some(row) = diff.source_display_row(focus, editor.cursor().row) {
if let Some(anchor) = toggle_row {
top = row.saturating_sub(anchor.min(height.saturating_sub(1)));
@@ -2651,14 +2662,10 @@ fn draw_diff(
Focus::After => file.after.len(),
Focus::Files => unreachable!("native buffer belongs to a text pane"),
});
let digits = source_len.max(1).ilog10() as usize + 1;
let content_width = usize::from(pane.width.saturating_sub(2));
let gutter = if content_width > digits + 2 {
digits + 2
} else {
0
};
let width = content_width.saturating_sub(gutter);
let width = selected.map_or_else(
|| diff_text_width(pane, source_len),
|file| diff_pane_text_width(diff, file, pane, focus),
);
let column = display_column(&editor.lines()[editor.cursor().row], editor.cursor().column);
if column < *left {
*left = column;
@@ -2721,15 +2728,18 @@ fn draw_diff(
if pane_layout == PaneLayout::Empty {
diff_pane_panel(frame, diff, Some(file), before_area, true);
} else {
if before_area != Rect::default() {
if before_area != Rect::default() && (!unified || diff.focus == Focus::Before) {
let text_area = draw_diff_pane(
frame,
diff,
file,
before_area,
true,
&syntax.before,
editor.map_or(&[], |editor| editor.diff_semantic_tokens(Focus::Before)),
[&syntax.before, &syntax.after],
[
editor.map_or(&[], |editor| editor.diff_semantic_tokens(Focus::Before)),
editor.map_or(&[], |editor| editor.diff_semantic_tokens(Focus::After)),
],
(
top,
&mut before_left,
@@ -2754,15 +2764,18 @@ fn draw_diff(
.map(|position| (text_area, position));
}
}
if after_area != Rect::default() {
if after_area != Rect::default() && (!unified || diff.focus != Focus::Before) {
let text_area = draw_diff_pane(
frame,
diff,
file,
after_area,
false,
&syntax.after,
editor.map_or(&[], |editor| editor.diff_semantic_tokens(Focus::After)),
[&syntax.before, &syntax.after],
[
editor.map_or(&[], |editor| editor.diff_semantic_tokens(Focus::Before)),
editor.map_or(&[], |editor| editor.diff_semantic_tokens(Focus::After)),
],
(
top,
&mut after_left,
@@ -2804,14 +2817,32 @@ fn draw_diff(
panels.diff = Some(DiffGeometry {
regions: [
(navigator.unwrap_or_default(), Focus::Files),
(before_area, Focus::Before),
(after_area, Focus::After),
(
if unified && diff.focus != Focus::Before {
Rect::default()
} else {
before_area
},
Focus::Before,
),
(
if unified && diff.focus == Focus::Before {
Rect::default()
} else {
after_area
},
Focus::After,
),
],
viewport: diff
.comparison_id
.map(|id| (id, diff.file, top, before_left, after_left)),
text_widths,
wrapping,
view_mode: diff.view_mode,
split_axis: diff.split_axis,
caret_row: document_caret
.map(|(text_area, caret)| usize::from(caret.y.saturating_sub(text_area.y))),
});
if let Some(editor) = editor {
draw_diff_popups(frame, editor, area, document_caret, panels);
@@ -2840,7 +2871,17 @@ fn diff_pane_panel(
let title = file.map_or_else(
|| " Diff ".into(),
|file| {
let label = if before { "Before" } else { "After" };
let label = if diff.view_mode == ViewMode::Unified {
if before {
"Unified · Before"
} else {
"Unified · After"
}
} else if before {
"Before"
} else {
"After"
};
let path = if before {
file.old_path.as_ref().unwrap_or(&file.path)
} else {
@@ -2857,7 +2898,7 @@ fn diff_pane_panel(
.comparison
.as_ref()
.map_or_else(String::new, |comparison| {
if diff.pane_layout() == PaneLayout::Split {
if diff.view_mode == ViewMode::Split && diff.pane_layout() == PaneLayout::Split {
format!(
" {} ",
display_text(if before {
@@ -2890,8 +2931,8 @@ fn draw_diff_pane(
file: &ChangedFile,
area: Rect,
before: bool,
syntax: &SyntaxCache,
semantic: &[SemanticToken],
syntax: [&SyntaxCache; 2],
semantic: [&[SemanticToken]; 2],
viewport: (usize, &mut usize, Option<u16>),
wrapped: Option<&[WrappedDiffRow]>,
colors: DiffColors,
@@ -2915,14 +2956,9 @@ fn draw_diff_pane(
);
return content;
}
let source = if before { &file.before } else { &file.after };
let unified = diff.view_mode == ViewMode::Unified;
let (top, left, previous_width) = viewport;
let digits = source.len().max(1).ilog10() as usize + 1;
let gutter = if usize::from(content.width) > digits + 2 {
digits + 2
} else {
0
};
let gutter = diff_gutter(diff, file, usize::from(content.width), focus);
let width = usize::from(content.width).saturating_sub(gutter);
if wrapped.is_none() && previous_width.is_some_and(|previous| usize::from(previous) != width) {
*left = diff.bound_horizontal(focus, *left, width);
@@ -2946,7 +2982,16 @@ fn draw_diff_pane(
}
};
let row = &diff.rows[aligned_row];
let index = if before { row.before } else { row.after };
let before = if unified {
diff.display_focus(display_row).unwrap_or(focus) == Focus::Before
} else {
before
};
let source = if before { &file.before } else { &file.after };
let index = diff.display_source_row(
if before { Focus::Before } else { Focus::After },
display_row,
);
let Some(index) = index else {
let style = Style::default().fg(Color::Rgb(72, 72, 72)).bg(Color::Reset);
let buffer = frame.buffer_mut();
@@ -3013,14 +3058,47 @@ fn draw_diff_pane(
} else {
Color::DarkGray
};
let label = if unified {
let old = diff
.display_source_row(Focus::Before, display_row)
.map(|row| row + 1);
let new = diff
.display_source_row(Focus::After, display_row)
.map(|row| row + 1);
let old_digits = file.before.len().max(1).ilog10() as usize + 1;
let new_digits = file.after.len().max(1).ilog10() as usize + 1;
match (old, new) {
(Some(old), Some(new)) => {
format!("{old:>old_digits$} {new:>new_digits$}{marker} ")
}
(Some(old), None) => format!("{old:>old_digits$} {:>new_digits$}{marker} ", ""),
(None, Some(new)) => format!("{:>old_digits$} {new:>new_digits$}{marker} ", ""),
(None, None) => unreachable!("displayed diff line has a source"),
}
} else {
let digits = source.len().max(1).ilog10() as usize + 1;
format!("{:>digits$}{marker} ", index + 1)
};
frame.render_widget(
Paragraph::new(format!("{:>digits$}{marker} ", index + 1))
.style(Style::default().fg(color).bg(background)),
Paragraph::new(label).style(Style::default().fg(color).bg(background)),
Rect::new(content.x, row_area.y, gutter as u16, 1),
);
}
paint_syntax_line(frame, source_text, syntax.line(index), text_area, left);
paint_semantic_line(frame, source_text, semantic, index, text_area, left);
paint_syntax_line(
frame,
source_text,
syntax[usize::from(!before)].line(index),
text_area,
left,
);
paint_semantic_line(
frame,
source_text,
semantic[usize::from(!before)],
index,
text_area,
left,
);
if !matches!(file.status, FileStatus::Added | FileStatus::Removed) {
let style = Style::default().bg(colors.character[usize::from(!before)]);
for change in &row.changes {
@@ -3122,6 +3200,7 @@ struct WrappedDiffRow {
#[derive(Default)]
struct DiffWrapViewport {
key: Option<(u64, usize, Option<u64>)>,
view_mode: Option<ViewMode>,
revision: u64,
before: crate::wrap::WrapCache,
after: crate::wrap::WrapCache,
@@ -3144,6 +3223,22 @@ fn diff_text_width(area: Rect, source_len: usize) -> usize {
width.saturating_sub(if width > digits + 2 { digits + 2 } else { 0 })
}
fn diff_gutter(diff: &DiffState, file: &ChangedFile, width: usize, focus: Focus) -> usize {
let old = file.before.len().max(1).ilog10() as usize + 1;
let new = file.after.len().max(1).ilog10() as usize + 1;
let gutter = if diff.view_mode == ViewMode::Unified {
old + new + 3
} else {
(if focus == Focus::Before { old } else { new }) + 2
};
if width > gutter { gutter } else { 0 }
}
fn diff_pane_text_width(diff: &DiffState, file: &ChangedFile, area: Rect, focus: Focus) -> usize {
let width = usize::from(diff_inner(area).width);
width.saturating_sub(diff_gutter(diff, file, width, focus))
}
impl DiffWrapViewport {
fn row(
&mut self,
@@ -3153,9 +3248,12 @@ impl DiffWrapViewport {
) -> WrappedDiffRow {
let mut segments = [None; 2];
let mut final_segments = [false; 2];
if let Some(DisplayRow::Line(index)) = diff.display_rows.get(position.display) {
let aligned = &diff.rows[*index];
for (side, source) in [aligned.before, aligned.after].into_iter().enumerate() {
if matches!(
diff.display_rows.get(position.display),
Some(DisplayRow::Line(_))
) {
for (side, focus) in [Focus::Before, Focus::After].into_iter().enumerate() {
let source = diff.display_source_row(focus, position.display);
if let Some(source) = source.filter(|_| self.widths[side] > 0) {
let (cache, lines) = if side == 0 {
(&mut self.before, &file.before)
@@ -3177,12 +3275,12 @@ impl DiffWrapViewport {
}
fn height(&mut self, diff: &DiffState, file: &ChangedFile, display: usize) -> usize {
let Some(DisplayRow::Line(index)) = diff.display_rows.get(display) else {
if !matches!(diff.display_rows.get(display), Some(DisplayRow::Line(_))) {
return 1;
};
let aligned = &diff.rows[*index];
}
let mut height = 1;
for (side, source) in [aligned.before, aligned.after].into_iter().enumerate() {
for (side, focus) in [Focus::Before, Focus::After].into_iter().enumerate() {
let source = diff.display_source_row(focus, display);
if let Some(source) = source.filter(|_| self.widths[side] > 0) {
let (cache, lines) = if side == 0 {
(&mut self.before, &file.before)
@@ -3273,12 +3371,14 @@ impl DiffWrapViewport {
let key = Some((id, diff.file, diff.live_revision));
if self.key.map(|(id, file, _)| (id, file)) != Some((id, diff.file))
|| self.top.display != diff.top
|| self.view_mode != Some(diff.view_mode)
{
self.top = DiffWrapPosition {
display: diff.top,
continuation: 0,
};
}
self.view_mode = Some(diff.view_mode);
if self.key != key {
self.key = key;
self.revision = self
@@ -3287,8 +3387,8 @@ impl DiffWrapViewport {
.expect("wrap revisions exhausted");
}
self.widths = [
diff_text_width(areas[0], file.before.len()),
diff_text_width(areas[1], file.after.len()),
diff_pane_text_width(diff, file, areas[0], Focus::Before),
diff_pane_text_width(diff, file, areas[1], Focus::After),
];
self.before.prepare((diff.file, id), self.widths[0]);
self.after
@@ -3301,8 +3401,11 @@ impl DiffWrapViewport {
.top
.continuation
.min(self.height(diff, file, self.top.display) - 1);
let height = usize::from(diff_inner(areas[0]).height.max(diff_inner(areas[1]).height));
self.fill(diff, file, height);
let active = usize::from(diff.focus == Focus::After || areas[0].height == 0);
let height = usize::from(diff_inner(areas[active]).height);
let paint_height =
usize::from(diff_inner(areas[0]).height.max(diff_inner(areas[1]).height));
self.fill(diff, file, paint_height);
if let Some((focus, cursor)) = caret
&& let Some(position) = self.caret_position(diff, file, focus, cursor)
{
@@ -3311,16 +3414,21 @@ impl DiffWrapViewport {
for _ in 0..row.min(height.saturating_sub(1)) {
self.top = self.step(diff, file, self.top, false);
}
self.fill(diff, file, height);
self.fill(diff, file, paint_height);
} else if position < self.top {
self.top = position;
self.fill(diff, file, height);
} else if !self.rows.iter().any(|row| row.position == position) {
self.fill(diff, file, paint_height);
} else if !self
.rows
.iter()
.take(height)
.any(|row| row.position == position)
{
self.top = position;
for _ in 1..height {
self.top = self.step(diff, file, self.top, false);
}
self.fill(diff, file, height);
self.fill(diff, file, paint_height);
}
}
}
@@ -3433,15 +3541,9 @@ fn diff_visible_rows(
let count = wrapped.map_or(usize::from(height), |rows| {
rows.last().map_or(0, |row| row.position.display - top + 1)
});
for row in diff.display_rows.iter().skip(top).take(count) {
let DisplayRow::Line(index) = *row else {
continue;
};
let source = if before {
diff.rows[index].before
} else {
diff.rows[index].after
};
for display in top..top.saturating_add(count).min(diff.display_rows.len()) {
let source =
diff.display_source_row(if before { Focus::Before } else { Focus::After }, display);
if let Some(source) = source {
if let Some(last) = ranges.last_mut()
&& last.end == source
@@ -8068,6 +8170,109 @@ mod tests {
}
}
#[test]
fn horizontal_diff_split_keeps_after_caret_in_smaller_bottom_pane() {
let before = (0..35)
.map(|row| format!("old{row}"))
.collect::<Vec<_>>()
.join("\n");
let after = before.replace("old", "new");
let mut editor = readonly_diff_editor(syntax_diff_file("axis.txt", &before, &after));
editor.handle(Input::Char('\t'));
editor.handle(Input::Char('\t'));
for _ in 0..20 {
editor.handle(Input::Down);
}
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 17)).unwrap();
let mut views = BufferViewports::default();
render_native_diff(&mut terminal, &mut views, &mut editor);
editor.execute("diff.orientation.toggle");
let caret = render_native_diff(&mut terminal, &mut views, &mut editor);
let geometry = views.panels.diff.as_ref().unwrap();
assert_eq!(geometry.regions[1].0.x, geometry.regions[2].0.x);
assert!(geometry.regions[1].0.bottom() <= geometry.regions[2].0.y);
assert!(diff_inner(geometry.regions[2].0).contains(caret));
editor.execute("word_wrap.toggle");
let caret = render_native_diff(&mut terminal, &mut views, &mut editor);
assert!(diff_inner(views.panels.diff.as_ref().unwrap().regions[2].0).contains(caret));
}
#[test]
fn unified_diff_renders_ordered_coordinates_and_switches_logical_focus() {
let mut file = syntax_diff_file("unified.txt", "same\nold\nend", "same\nnew\nend");
file.hunks = crate::diff::line_hunks(&file.before, false, &file.after, false);
let mut editor = readonly_diff_editor(file);
editor.execute("diff.view.toggle");
editor.handle(Input::Char('\t'));
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 20)).unwrap();
let mut views = BufferViewports::default();
render_native_diff(&mut terminal, &mut views, &mut editor);
let geometry = views.panels.diff.as_ref().unwrap();
assert_eq!(geometry.regions[2].0, Rect::default());
let area = diff_inner(geometry.regions[1].0);
let buffer = terminal.backend().buffer();
let lines = (area.y..area.bottom())
.map(|y| {
(area.x..area.right())
.map(|x| buffer[(x, y)].symbol())
.collect::<String>()
})
.collect::<Vec<_>>();
let tokens: Vec<Vec<&str>> = lines[..4]
.iter()
.map(|line| line.split_whitespace().collect())
.collect();
assert_eq!(
tokens,
[
vec!["1", "1", "same"],
vec!["2", "-", "old"],
vec!["2+", "new"],
vec!["3", "3", "end"]
]
);
assert!(!lines.iter().any(|line| line.contains('╱')));
editor.handle(Input::Char('\t'));
render_native_diff(&mut terminal, &mut views, &mut editor);
let geometry = views.panels.diff.as_ref().unwrap();
assert_eq!(geometry.regions[1].0, Rect::default());
assert_eq!(diff_inner(geometry.regions[2].0), area);
}
#[test]
fn unified_wrapping_uses_only_the_display_rows_source_side() {
let before = format!("{}\nend", "old ".repeat(40));
let mut file = syntax_diff_file("wrap.txt", &before, "new\nend");
file.hunks = crate::diff::line_hunks(&file.before, false, &file.after, false);
let mut editor = readonly_diff_editor(file);
editor.execute("diff.view.toggle");
editor.execute("word_wrap.toggle");
editor.handle(Input::Char('\t'));
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 30)).unwrap();
let mut views = BufferViewports::default();
render_native_diff(&mut terminal, &mut views, &mut editor);
let wrap = views.panels.diff_wrap.as_ref().unwrap();
let removed = wrap
.rows
.iter()
.filter(|row| row.position.display == 0)
.collect::<Vec<_>>();
assert!(removed.len() > 1);
assert!(
removed
.iter()
.all(|row| row.segments[0].is_some() && row.segments[1].is_none())
);
let added = wrap
.rows
.iter()
.filter(|row| row.position.display == 1)
.collect::<Vec<_>>();
assert_eq!(added.len(), 1);
assert!(added[0].segments[0].is_none());
assert!(added[0].segments[1].is_some());
}
#[test]
fn wrapped_diff_aligns_continuations_and_preserves_native_selection_and_caret() {
let before = format!("// {}BEFORE_END\nsentinel", "old ".repeat(70));