feat: show base-relative changes in conflict panes

This commit is contained in:
2026-10-24 23:55:12 +02:00
parent c88582f2ba
commit c1bff63111
9 changed files with 509 additions and 322 deletions
+276 -84
View File
@@ -356,6 +356,9 @@ pub(crate) struct ColumnLine<'a> {
pub side: Focus,
pub source_row: usize,
pub numbers: [Option<usize>; 2],
/// Change polarity is independent of the native source/pane.
pub polarity: Focus,
pub native: bool,
pub changed: bool,
pub emphasize: bool,
pub newline: bool,
@@ -409,6 +412,46 @@ impl<'a> ColumnView<'a> {
DisplayRow::Line(index) => index,
};
let row = self.diff.rows.get(index)?;
let side = self.diff.display_focus(display).unwrap_or(self.info.focus);
if let Some(conflict) = &self.diff.conflict {
let Some(line) = &conflict.rows[index][usize::from(side == Focus::After)] else {
return Some(ColumnRow::Gap);
};
let source = if line.removed {
&conflict.base
} else if side == Focus::Before {
&self.file.before
} else {
&self.file.after
};
return Some(ColumnRow::Text(ColumnLine {
text: source.get(line.source).map(String::as_str).or(Some("")),
side,
source_row: line.source,
numbers: if line.removed {
[None; 2]
} else {
[row.before, row.after]
},
polarity: if line.removed {
Focus::Before
} else {
Focus::After
},
native: !line.removed,
changed: line.changed,
emphasize: true,
newline: line.source + 1 < source.len()
|| if line.removed {
conflict.base.last().is_some_and(String::is_empty)
} else if side == Focus::Before {
self.file.before_eof
} else {
self.file.after_eof
},
changes: Cow::Borrowed(&line.changes),
}));
}
let side = if self.info.unified {
self.diff.display_focus(display).unwrap_or(self.info.focus)
} else {
@@ -436,6 +479,8 @@ impl<'a> ColumnView<'a> {
self.diff.display_source_row(Focus::Before, display),
self.diff.display_source_row(Focus::After, display),
],
polarity: side,
native: true,
changed: row.kind == RowKind::Changed
|| (before && row.kind == RowKind::Removed)
|| (!before && row.kind == RowKind::Added),
@@ -459,6 +504,35 @@ impl<'a> ColumnView<'a> {
}))
}
/// Wrapping includes display-only removals without computing character diffs.
/// Removed base rows occupy a disjoint cache-key space from native rows.
pub fn text_row(&self, display: usize) -> Option<(usize, &'a str)> {
let DisplayRow::Line(index) = *self.diff.display_rows.get(display)? else {
return None;
};
let side = self.info.focus;
if self
.diff
.display_focus(display)
.is_some_and(|focus| focus != side)
{
return None;
}
if let Some(conflict) = &self.diff.conflict {
let line = conflict.rows[index][usize::from(side == Focus::After)].as_ref()?;
if line.removed {
return Some((usize::MAX - line.source, &conflict.base[line.source]));
}
}
let source = self.diff.display_source_row(side, display)?;
let lines = if side == Focus::Before {
&self.file.before
} else {
&self.file.after
};
Some((source, lines.get(source).map_or("", String::as_str)))
}
/// Caret mapping stays on this view's native side, even in unified display.
pub fn native_row(&self, display: usize) -> Option<usize> {
self.diff.display_source_row(self.info.focus, display)
@@ -489,9 +563,105 @@ impl<'a> ColumnView<'a> {
}
}
/// Base-relative candidate patches with display-only removed base lines.
#[derive(Debug)]
struct ConflictChanges {
base: Vec<String>,
rows: Vec<[Option<ConflictLine>; 2]>,
base_rows: Vec<Option<usize>>,
}
#[derive(Debug)]
struct ConflictLine {
source: usize,
removed: bool,
changed: bool,
changes: Vec<[std::ops::Range<usize>; 2]>,
}
impl ConflictChanges {
fn new(base: &[String], candidates: [&[String]; 2]) -> Self {
let mut pairs = candidates.map(|candidate| {
let mut diff = DiffState::new();
diff.set_resolver_text(base, candidate, None);
diff
});
for pair in &mut pairs {
if pair.lazy_changes {
for row in &mut pair.rows {
if row.kind == RowKind::Changed {
// Large inputs keep the existing whole-line fallback instead of
// eagerly running a character diff for every changed row.
row.changes = vec![[0..usize::MAX, 0..usize::MAX]];
}
}
}
}
let [left, right] = pairs.map(|pair| pair.rows.into_iter().peekable());
let (mut left, mut right) = (left, right);
let mut result = Self {
base: base.to_vec(),
rows: Vec::new(),
base_rows: Vec::new(),
};
while left.peek().is_some() || right.peek().is_some() {
let insertion = left.peek().is_some_and(|row| row.before.is_none())
|| right.peek().is_some_and(|row| row.before.is_none());
let rows = [&mut left, &mut right].map(|rows| {
if rows
.peek()
.is_some_and(|row| !insertion || row.before.is_none())
{
rows.next()
} else {
None
}
});
let base_row = rows.iter().flatten().find_map(|row| row.before);
let removal =
base_row.is_some() && rows.iter().flatten().any(|row| row.kind != RowKind::Equal);
let line = |row: &AlignedRow, removed: bool| {
let source = if removed { row.before } else { row.after }?;
let side = usize::from(!removed);
Some(ConflictLine {
source,
removed,
changed: row.kind != RowKind::Equal,
changes: row
.changes
.iter()
.map(|change| [change[side].clone(), change[side].clone()])
.collect(),
})
};
if removal {
result.rows.push(std::array::from_fn(|side| {
let row = rows[side].as_ref()?;
line(row, row.kind != RowKind::Equal)
}));
result.base_rows.push(base_row);
}
let additions = std::array::from_fn(|side| {
let row = rows[side].as_ref()?;
if removal && row.kind == RowKind::Equal {
None
} else {
line(row, false)
}
});
if additions.iter().any(Option::is_some) {
result.rows.push(additions);
result.base_rows.push(if removal { None } else { base_row });
}
}
result
}
}
/// Immutable left/base/right conflict History, aligned at shared base rows.
pub(crate) struct HistoryComparison {
columns: [DiffState; 3],
candidates: DiffState,
base: DiffState,
}
/// A read-only projection for the existing diff painter.
@@ -499,7 +669,6 @@ pub(crate) struct HistoryColumn<'a> {
pub diff: &'a DiffState,
pub file: &'a ChangedFile,
pub focus: Focus,
pub lines: &'a [String],
}
impl<'a> HistoryColumn<'a> {
@@ -514,92 +683,54 @@ impl<'a> HistoryColumn<'a> {
impl HistoryComparison {
pub fn new(sources: [&str; 3]) -> Self {
let [left, base, right] =
let [left, base_lines, right] =
sources.map(|text| text.split('\n').map(str::to_owned).collect::<Vec<_>>());
let mut columns: [DiffState; 3] = std::array::from_fn(|_| DiffState::new());
columns[0].set_resolver_text(&base, &left);
columns[1].set_resolver_text(&base, &base);
columns[2].set_resolver_text(&base, &right);
let mut left = std::mem::take(&mut columns[0].rows).into_iter().peekable();
let mut right = std::mem::take(&mut columns[2].rows).into_iter().peekable();
columns[1].rows.clear();
// Stitch the existing pairwise alignments at their shared base rows.
while left.peek().is_some() || right.peek().is_some() {
let insertion = left.peek().is_some_and(|row| row.before.is_none())
|| right.peek().is_some_and(|row| row.before.is_none());
let take_left = left
.peek()
.is_some_and(|row| !insertion || row.before.is_none());
let take_right = right
.peek()
.is_some_and(|row| !insertion || row.before.is_none());
let empty = || AlignedRow {
before: None,
after: None,
kind: RowKind::Equal,
changes: Vec::new(),
};
let left_row = if take_left {
left.next().unwrap()
} else {
empty()
};
let right_row = if take_right {
right.next().unwrap()
} else {
empty()
};
let base = left_row.before.or(right_row.before);
let changed = left_row.kind != RowKind::Equal || right_row.kind != RowKind::Equal;
let changes = left_row
.changes
.iter()
.chain(&right_row.changes)
.map(|change| [change[0].clone(), change[0].clone()])
.collect();
columns[1].rows.push(AlignedRow {
before: base,
after: base,
kind: if changed {
RowKind::Changed
} else {
RowKind::Equal
},
changes,
});
columns[0].rows.push(left_row);
columns[2].rows.push(right_row);
}
for column in &mut columns {
let mut candidates = DiffState::new();
candidates.set_resolver_text(&left, &right, Some(&base_lines));
let mut base = DiffState::new();
base.set_resolver_text(&base_lines, &base_lines, None);
let conflict = candidates.conflict.as_ref().unwrap();
base.rows = conflict
.base_rows
.iter()
.zip(&candidates.rows)
.zip(&conflict.rows)
.map(|((&source, row), lines)| AlignedRow {
before: source,
after: source,
kind: row.kind,
changes: lines
.iter()
.flatten()
.filter(|line| line.removed)
.flat_map(|line| line.changes.iter().cloned())
.collect(),
})
.collect();
for column in [&mut candidates, &mut base] {
column.display_rows = (0..column.rows.len()).map(DisplayRow::Line).collect();
column.rebuild_source_ranges();
}
Self { columns }
Self { candidates, base }
}
pub fn row_count(&self) -> usize {
self.columns[0].rows.len()
self.candidates.rows.len()
}
pub fn column(&self, index: usize) -> HistoryColumn<'_> {
let diff = &self.columns[index];
let diff = if index == 1 {
&self.base
} else {
&self.candidates
};
let file = &diff.comparison.as_ref().unwrap().files[0];
let focus = if index == 1 {
Focus::Before
} else {
let focus = if index == 2 {
Focus::After
};
let lines = if focus == Focus::Before {
&file.before
} else {
&file.after
Focus::Before
};
HistoryColumn {
diff,
file,
focus,
lines,
}
HistoryColumn { diff, file, focus }
}
}
@@ -660,6 +791,7 @@ pub struct DiffState {
lazy_changes: bool,
/// Native empty terminal rows explicitly supplied by resolver text.
terminal_rows: [Option<usize>; 2],
conflict: Option<ConflictChanges>,
excerpt_ranges: Vec<(usize, usize)>,
pub live_buffer: Option<usize>,
pub live_revision: Option<u64>,
@@ -723,6 +855,7 @@ impl DiffState {
display_rows: Vec::new(),
display_sides: Vec::new(),
source_ranges: Default::default(),
conflict: None,
lazy_changes: false,
terminal_rows: [None; 2],
excerpt_ranges: Vec::new(),
@@ -786,7 +919,12 @@ impl DiffState {
}
}
pub(crate) fn set_resolver_text(&mut self, draft: &[String], alternative: &[String]) {
pub(crate) fn set_resolver_text(
&mut self,
draft: &[String],
alternative: &[String],
base: Option<&[String]>,
) {
fn content(lines: &[String]) -> (&[String], bool) {
let eof = lines.len() > 1 && lines.last().is_some_and(String::is_empty);
let content = if eof {
@@ -809,6 +947,11 @@ impl DiffState {
let after_terminal = (alternative.len() > after.len()).then_some(after.len());
if self.phase == Phase::View
&& self.terminal_rows == [before_terminal, after_terminal]
&& self
.conflict
.as_ref()
.map(|conflict| conflict.base.as_slice())
== base
&& self.comparison.as_ref().is_some_and(|comparison| {
let file = &comparison.files[self.file];
file.before == before
@@ -836,15 +979,59 @@ impl DiffState {
file.before_eof = before_eof;
file.after_eof = after_eof;
file.hunks = line_hunks(before, before_eof, after, after_eof);
self.align_file();
if base.is_none() {
self.align_file();
if before_terminal.is_some() || after_terminal.is_some() {
self.rows.push(AlignedRow {
before: before_terminal,
after: after_terminal,
kind: RowKind::Equal,
changes: Vec::new(),
});
self.reset_excerpts();
}
}
self.terminal_rows = [before_terminal, after_terminal];
if before_terminal.is_some() || after_terminal.is_some() {
self.rows.push(AlignedRow {
before: before_terminal,
after: after_terminal,
kind: RowKind::Equal,
changes: Vec::new(),
});
if let Some(base) = base {
let conflict = ConflictChanges::new(base, [draft, alternative]);
self.rows = conflict
.rows
.iter()
.map(|lines| {
let native = |side: usize| {
lines[side]
.as_ref()
.filter(|line| !line.removed)
.map(|line| line.source)
};
AlignedRow {
before: native(0),
after: native(1),
kind: if lines.iter().flatten().any(|line| line.changed) {
RowKind::Changed
} else {
RowKind::Equal
},
changes: Vec::new(),
}
})
.collect();
let base_width = base.iter().map(|line| line_width(line)).max().unwrap_or(0);
self.max_before = draft
.iter()
.map(|line| line_width(line))
.max()
.unwrap_or(0)
.max(base_width);
self.max_after = alternative
.iter()
.map(|line| line_width(line))
.max()
.unwrap_or(0)
.max(base_width);
self.lazy_changes = false;
self.after_widths.clear();
self.conflict = Some(conflict);
self.reset_excerpts();
}
if self.comparison.as_ref().unwrap().files[self.file]
@@ -2677,6 +2864,7 @@ impl DiffState {
fn rebuild_file(&mut self, reset: bool) {
self.normalize_focus();
self.rows.clear();
self.conflict = None;
self.terminal_rows = [None; 2];
self.display_rows.clear();
self.display_sides.clear();
@@ -2857,7 +3045,11 @@ impl DiffState {
// 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() {
if self.source_row(side, aligned).is_some()
|| self.conflict.as_ref().is_some_and(|conflict| {
conflict.rows[aligned][usize::from(side == Focus::After)].is_some()
})
{
self.display_rows.push(DisplayRow::Line(aligned));
self.display_sides.push(Some(side));
}
+27 -4
View File
@@ -1694,7 +1694,7 @@ pub struct ResolverFile {
pub seed_source: usize,
projection: Option<crate::resolve::Projection>,
diff: DiffState,
comparison_key: Option<(u64, usize)>,
comparison_key: Option<(u64, usize, Option<usize>)>,
}
impl ResolverFile {
@@ -3088,11 +3088,34 @@ impl Editor {
let Some(buffer) = self.buffer(draft_buffer) else {
return;
};
let key = (buffer.text_revision, source);
let base_index = resolver.selected_base;
let key = (buffer.text_revision, source, base_index);
let changed = file.comparison_key != Some(key);
let first_comparison = file.diff.comparison_id.is_none();
if changed {
if file.comparison_key.is_none_or(|(_, index)| index != source) {
let base = match base_index {
Some(index) => match &file.projection {
Some(projection) => projection.alternative(index),
None => file
.sources
.get(index)
.map(|source| source.text.as_str())
.ok_or_else(|| "No such conflict base".into()),
},
None => Ok(""),
};
let base = match base {
Ok(text) => split_lines(text),
Err(error) => {
self.resolvers.get_mut(&self.tab_id).unwrap().files[file_index].error =
Some(error);
return;
}
};
if file
.comparison_key
.is_none_or(|(_, index, _)| index != source)
{
let alternative = match &file.projection {
Some(projection) => projection.alternative(source),
None => Ok(file
@@ -3141,7 +3164,7 @@ impl Editor {
.set_context_options(self.diff_context_options.0, self.diff_context_options.1);
let previous_id = file.diff.comparison_id;
file.diff
.set_resolver_text(&draft.lines, &alternative.lines);
.set_resolver_text(&draft.lines, &alternative.lines, Some(&base));
if file.diff.comparison_id != previous_id {
self.next_diff_request = self
.next_diff_request
+55 -51
View File
@@ -392,14 +392,9 @@ impl HistoryDiff {
for column in 0..3 {
if self.wrapping[column]
&& self.widths[column] > 0
&& let Some(source) = self.comparison.column(column).source_row(row)
&& let Some((key, text)) = self.comparison.column(column).view().text_row(row)
{
let lines = self.comparison.column(column).lines;
height = height.max(
self.wrap[column]
.segments(source, lines.get(source).map_or("", String::as_str))
.len(),
);
height = height.max(self.wrap[column].segments(key, text).len());
}
}
height
@@ -432,10 +427,9 @@ impl HistoryDiff {
let mut final_segments = [false; 2];
let projection = self.comparison.column(column);
let side = usize::from(projection.focus == Focus::After);
if let Some(source) = projection.source_row(self.top.display) {
let text = projection.lines.get(source).map_or("", String::as_str);
if let Some((key, text)) = projection.view().text_row(self.top.display) {
if self.wrapping[column] {
let parts = self.wrap[column].segments(source, text);
let parts = self.wrap[column].segments(key, text);
segments[side] = parts.get(self.top.continuation).copied();
final_segments[side] = self.top.continuation + 1 == parts.len();
} else if self.top.continuation == 0 {
@@ -4682,6 +4676,7 @@ fn paint_diff_content<'a>(
continue;
};
let before = line.side == Focus::Before;
let removed = line.polarity == Focus::Before;
let index = line.source_row;
let segment = segments.and_then(|segments| segments[usize::from(!before)]);
let left = segment.map_or(left, |segment| segment.start);
@@ -4694,7 +4689,7 @@ fn paint_diff_content<'a>(
let text_area = Rect::new(content.x + gutter as u16, row_area.y, paint_width as u16, 1);
let changed = line.changed;
let background = if changed && line.emphasize {
colors.row[usize::from(!before)]
colors.row[usize::from(!removed)]
} else {
Color::Reset
};
@@ -4711,12 +4706,12 @@ fn paint_diff_content<'a>(
);
if gutter > 0 && wrapped.is_none_or(|rows| rows[screen].position.continuation == 0) {
let marker = if changed {
if before { '-' } else { '+' }
if removed { '-' } else { '+' }
} else {
' '
};
let color = if changed {
if before { Color::Red } else { Color::Green }
if removed { Color::Red } else { Color::Green }
} else {
Color::DarkGray
};
@@ -4732,35 +4727,41 @@ fn paint_diff_content<'a>(
}
(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"),
(None, None) => format!("{:>old_digits$} {:>new_digits$}{marker} ", "", ""),
}
} else {
let digits = view.info.source_extents[usize::from(!before)]
.max(1)
.ilog10() as usize
+ 1;
format!("{:>digits$}{marker} ", index + 1)
if line.native {
format!("{:>digits$}{marker} ", index + 1)
} else {
format!("{:>digits$}{marker} ", "")
}
};
frame.render_widget(
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[usize::from(!before)].line(index),
text_area,
left,
);
paint_semantic_line(
frame,
source_text,
semantic[usize::from(!before)],
index,
text_area,
left,
);
if line.native {
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 let Some(characters) = whitespace_characters {
paint_whitespace(
frame,
@@ -4769,13 +4770,17 @@ fn paint_diff_content<'a>(
left,
characters,
whitespace_scope,
syntax[usize::from(!before)].string_spans(index),
if line.native {
syntax[usize::from(!before)].string_spans(index)
} else {
&[]
},
line.newline,
true,
);
}
if line.emphasize {
let style = Style::default().bg(colors.character[usize::from(!before)]);
let style = Style::default().bg(colors.character[usize::from(!removed)]);
for range in line.changes() {
if !range.is_empty() {
paint_grapheme_range(frame, text_area, left, source_text, range.clone(), style);
@@ -5080,7 +5085,7 @@ impl DiffWrapViewport {
fn row(
&mut self,
diff: &DiffState,
file: &ChangedFile,
_file: &ChangedFile,
position: DiffWrapPosition,
) -> WrappedDiffRow {
let mut segments = [None; 2];
@@ -5099,15 +5104,16 @@ impl DiffWrapViewport {
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)
let view = diff.column_view(focus).unwrap();
if let Some((key, text)) = view.text_row(position.display)
&& self.widths[side] > 0
{
let cache = if side == 0 {
&mut self.before
} else {
(&mut self.after, &file.after)
&mut self.after
};
let text = lines.get(source).map_or("", String::as_str);
let parts = cache.segments(source, text);
let parts = cache.segments(key, text);
segments[side] = parts.get(position.continuation).copied();
final_segments[side] = position.continuation + 1 == parts.len();
}
@@ -5122,7 +5128,7 @@ impl DiffWrapViewport {
}
}
fn height(&mut self, diff: &DiffState, file: &ChangedFile, display: usize) -> usize {
fn height(&mut self, diff: &DiffState, _file: &ChangedFile, display: usize) -> usize {
if !diff.word_wrap {
return 1;
}
@@ -5131,18 +5137,16 @@ impl DiffWrapViewport {
}
let mut height = 1;
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)
let view = diff.column_view(focus).unwrap();
if let Some((key, text)) = view.text_row(display)
&& self.widths[side] > 0
{
let cache = if side == 0 {
&mut self.before
} else {
(&mut self.after, &file.after)
&mut self.after
};
height = height.max(
cache
.segments(source, lines.get(source).map_or("", String::as_str))
.len(),
);
height = height.max(cache.segments(key, text).len());
}
}
height