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
+185 -28
View File
@@ -135,6 +135,13 @@ pub enum Focus {
After,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ViewMode {
Split,
Unified,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum PaneLayout {
Split,
@@ -179,6 +186,8 @@ pub struct DiffState {
pub catalog: Option<Catalog>,
pub phase: Phase,
pub word_wrap: bool,
pub view_mode: ViewMode,
pub split_axis: crate::panes::SplitAxis,
pub before: Option<Revision>,
pub after: Option<Revision>,
pub category: usize,
@@ -198,6 +207,7 @@ pub struct DiffState {
pub left_after: usize,
pub rows: Vec<AlignedRow>,
pub display_rows: Vec<DisplayRow>,
display_sides: Vec<Option<Focus>>,
excerpt_ranges: Vec<(usize, usize)>,
pub live_buffer: Option<usize>,
pub live_revision: Option<u64>,
@@ -220,6 +230,8 @@ impl DiffState {
catalog: None,
phase: Phase::Before,
word_wrap: false,
view_mode: ViewMode::Split,
split_axis: crate::panes::SplitAxis::Vertical,
before: None,
after: None,
category: 0,
@@ -239,6 +251,7 @@ impl DiffState {
left_after: 0,
rows: Vec::new(),
display_rows: Vec::new(),
display_sides: Vec::new(),
excerpt_ranges: Vec::new(),
live_buffer: None,
live_revision: None,
@@ -902,9 +915,40 @@ impl DiffState {
}
pub(crate) fn source_display_row(&self, focus: Focus, source_row: usize) -> Option<usize> {
self.display_rows.iter().position(|display| {
matches!(*display, DisplayRow::Line(index) if self.source_row(focus, index) == Some(source_row))
})
(0..self.display_rows.len())
.position(|display| self.display_source_row(focus, display) == Some(source_row))
}
pub(crate) fn display_focus(&self, display: usize) -> Option<Focus> {
self.display_sides.get(display).copied().flatten()
}
pub(crate) fn display_source_row(&self, focus: Focus, display: usize) -> Option<usize> {
if self
.display_focus(display)
.is_some_and(|side| side != focus)
{
return None;
}
match *self.display_rows.get(display)? {
DisplayRow::Line(index) => self.source_row(focus, index),
DisplayRow::Fold { .. } => None,
}
}
pub(crate) fn set_view_mode(&mut self, view_mode: ViewMode) {
if self.view_mode == view_mode {
return;
}
let anchor = self.display_rows.get(self.top).copied();
let side = self.display_focus(self.top).or_else(|| {
[self.focus, Focus::After, Focus::Before]
.into_iter()
.find(|&focus| self.display_source_row(focus, self.top).is_some())
});
self.view_mode = view_mode;
self.rebuild_display_rows();
self.restore_top(anchor, side);
}
pub fn after_display_row(&self, source_row: usize) -> Option<usize> {
@@ -951,9 +995,10 @@ impl DiffState {
})?;
self.display_rows
.iter()
.filter_map(|display| match *display {
.enumerate()
.filter_map(|(display, row)| match *row {
DisplayRow::Line(index) => self
.source_row(to, index)
.display_source_row(to, display)
.map(|row| (index.abs_diff(aligned), row)),
DisplayRow::Fold { .. } => None,
})
@@ -975,7 +1020,7 @@ impl DiffState {
previous
}
fn restore_top(&mut self, anchor: Option<DisplayRow>) {
fn restore_top(&mut self, anchor: Option<DisplayRow>, side: Option<Focus>) {
if let Some(anchor) = anchor {
let index = match anchor {
DisplayRow::Line(index) => index,
@@ -984,8 +1029,15 @@ impl DiffState {
self.top = self
.display_rows
.iter()
.position(|row| match *row {
DisplayRow::Line(row) => row == index,
.enumerate()
.position(|(display, row)| match *row {
DisplayRow::Line(row) => {
row == index
&& side.is_none_or(|side| {
self.display_focus(display)
.is_none_or(|focus| focus == side)
})
}
DisplayRow::Fold { start, end } => start <= index && index < end,
})
.unwrap_or(0);
@@ -1044,20 +1096,19 @@ impl DiffState {
after_visible.extend(row.after.map(map_after));
}
}
let top_before = self
let top_fold = self
.display_rows
.get(self.top)
.and_then(|display| match *display {
DisplayRow::Line(index) => self.rows[index].before,
DisplayRow::Fold { start, .. } => self.rows[start].before,
DisplayRow::Fold { start, .. } => Some(start),
DisplayRow::Line(_) => None,
});
let top_before = self
.display_source_row(Focus::Before, self.top)
.or_else(|| top_fold.and_then(|index| self.rows[index].before));
let top_after = self
.display_rows
.get(self.top)
.and_then(|display| match *display {
DisplayRow::Line(index) => self.rows[index].after,
DisplayRow::Fold { start, .. } => self.rows[start].after,
})
.display_source_row(Focus::After, self.top)
.or_else(|| top_fold.and_then(|index| self.rows[index].after))
.map(map_after);
let was_changed = file_is_changed(file);
let file = &mut self.comparison.as_mut().unwrap().files[self.file];
@@ -1102,7 +1153,7 @@ impl DiffState {
} else if let Some(source) = top_before
&& let Some(index) = self.rows.iter().position(|row| row.before == Some(source))
{
self.restore_top(Some(DisplayRow::Line(index)));
self.restore_top(Some(DisplayRow::Line(index)), Some(Focus::Before));
}
if was_changed != changed {
self.refresh_files();
@@ -1134,6 +1185,7 @@ impl DiffState {
self.normalize_focus();
self.rows.clear();
self.display_rows.clear();
self.display_sides.clear();
self.excerpt_ranges.clear();
if reset {
self.top = 0;
@@ -1263,6 +1315,7 @@ impl DiffState {
}
self.excerpt_ranges.truncate(count);
self.display_rows.clear();
self.display_sides.clear();
if self.excerpt_ranges.is_empty() {
return;
}
@@ -1273,9 +1326,34 @@ impl DiffState {
start: end,
end: start,
});
if self.view_mode == ViewMode::Unified {
self.display_sides.push(None);
}
}
let mut index = start;
while index < next_end {
if self.view_mode == ViewMode::Split || self.rows[index].kind == RowKind::Equal {
self.display_rows.push(DisplayRow::Line(index));
if self.view_mode == ViewMode::Unified {
self.display_sides.push(None);
}
index += 1;
continue;
}
let run_start = index;
while index < next_end && self.rows[index].kind != RowKind::Equal {
index += 1;
}
// Unified hunks show all removals before all additions, without alignment holes.
for side in [Focus::Before, Focus::After] {
for aligned in run_start..index {
if self.source_row(side, aligned).is_some() {
self.display_rows.push(DisplayRow::Line(aligned));
self.display_sides.push(Some(side));
}
}
}
}
self.display_rows
.extend((start..next_end).map(DisplayRow::Line));
end = next_end;
}
if end < self.rows.len() {
@@ -1283,6 +1361,9 @@ impl DiffState {
start: end,
end: self.rows.len(),
});
if self.view_mode == ViewMode::Unified {
self.display_sides.push(None);
}
}
}
@@ -1304,6 +1385,7 @@ impl DiffState {
let Some(top) = self.display_rows.get(self.top).copied() else {
return;
};
let top_side = self.display_focus(self.top);
let index = match target {
DisplayRow::Line(row) => self
.excerpt_ranges
@@ -1337,14 +1419,7 @@ impl DiffState {
DisplayRow::Fold { start, .. } => start,
};
self.rebuild_display_rows();
self.top = self
.display_rows
.iter()
.position(|row| match *row {
DisplayRow::Line(index) => index == anchor,
DisplayRow::Fold { start, end } => start <= anchor && anchor < end,
})
.unwrap_or(0);
self.restore_top(Some(DisplayRow::Line(anchor)), top_side);
}
}
@@ -2804,6 +2879,88 @@ mod tests {
state
}
#[test]
fn unified_groups_changed_runs_and_roundtrips_side_specific_source_rows() {
let mut state = live_state(
["head", "old one", "old two", "tail"]
.map(str::to_owned)
.to_vec(),
["head", "new one", "new two", "extra", "tail"]
.map(str::to_owned)
.to_vec(),
false,
);
assert_eq!(state.view_mode, ViewMode::Split);
assert_eq!(state.split_axis, crate::panes::SplitAxis::Vertical);
state.top = state.after_display_row(2).unwrap();
let excerpts = state.excerpt_ranges.clone();
state.set_view_mode(ViewMode::Unified);
let rows: Vec<_> = (0..state.display_rows.len())
.map(|display| {
(
state.display_focus(display),
state.display_source_row(Focus::Before, display),
state.display_source_row(Focus::After, display),
)
})
.collect();
assert_eq!(
rows,
[
(None, Some(0), Some(0)),
(Some(Focus::Before), Some(1), None),
(Some(Focus::Before), Some(2), None),
(Some(Focus::After), None, Some(1)),
(Some(Focus::After), None, Some(2)),
(Some(Focus::After), None, Some(3)),
(None, Some(3), Some(4)),
]
);
assert_eq!(state.top, state.after_display_row(2).unwrap());
assert_eq!(
state.corresponding_row(Focus::Before, Focus::After, 2),
Some(2)
);
assert!(state.after_editable());
for focus in [Focus::Before, Focus::After] {
for source in state.source_rows(focus) {
let display = state.source_display_row(focus, source).unwrap();
assert_eq!(state.display_source_row(focus, display), Some(source));
}
}
state.set_view_mode(ViewMode::Split);
assert_eq!(state.excerpt_ranges, excerpts);
assert_eq!(state.focus, Focus::After);
assert_eq!(state.top, state.after_display_row(2).unwrap());
assert_eq!(state.display_rows.len(), 5);
}
#[test]
fn unified_live_reflow_preserves_removed_top_and_expanded_source_excerpts() {
let base: Vec<_> = (0..40).map(|row| format!("line {row}")).collect();
let mut lines = base.clone();
lines[20] = "new line".into();
let mut state = live_state(base, lines.clone(), false);
state.set_context_options(1, 3);
state.refresh_after(&lines, 1);
state.set_view_mode(ViewMode::Unified);
state.top = state.source_display_row(Focus::Before, 20).unwrap();
state.expand_source_excerpt(Focus::After, false, 20);
assert_eq!(state.display_source_row(Focus::Before, state.top), Some(20));
assert!(state.source_display_row(Focus::After, 16).is_some());
lines.insert(0, "inserted".into());
state.refresh_after(&lines, 2);
assert_eq!(state.display_focus(state.top), Some(Focus::Before));
assert_eq!(state.display_source_row(Focus::Before, state.top), Some(20));
assert!(state.source_display_row(Focus::After, 17).is_some());
state.reveal_source_row(Focus::After, 35);
let visible: Vec<_> = state.source_rows(Focus::After).collect();
state.set_view_mode(ViewMode::Split);
state.set_view_mode(ViewMode::Unified);
assert_eq!(state.source_rows(Focus::After).collect::<Vec<_>>(), visible);
assert_eq!(state.display_source_row(Focus::After, state.top), Some(35));
}
#[test]
fn character_ranges_follow_insertions_deletions_unicode_and_live_edits() {
for (before, after, expected) in [