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
+23
View File
@@ -1,3 +1,26 @@
- [x] improve: conflict resolution view.
- two-pane mode:
- currently, we display the two panes as if it were a diff, where the left side will be displayed as if the code in question will be removed, and the left pane will display the code in question as if it will be added.
- this doesn't really make sense, since code on either side (either version) will be added/removed.
- i'm wondering if it would make sense to display in both panels both changes that would and changes that would add code, meaning:
- if some code would be removed, it would display in red with a minus
- if some code would be added, it would display in green with a plus
- this would be the case for both panes
- probably something similar for three-pane mode would make sense.
- [ ] improve: code commenting format. example of comment in current format:
<attachment>
Comment 14 [unresolved] source/sema/resolve.hon (after) 22:9–22:19 @ working:a3b4159a340b3546e2108bc2dc0e7c302fc6d84b
[8]Builtin
can't we do `[_]Builtin` here instead for array size inference? if not, this is probably an oversight in the brolang compiler 🤔
</attachment>
- would probably be nice to instead do this though, if possible, i.e. wrapping the code snippet in question in a codeblock annotated with the right language, e.g.:
<attachment>
Comment 14 [unresolved] source/sema/resolve.hon (after) 22:9–22:19 @ working:a3b4159a340b3546e2108bc2dc0e7c302fc6d84b
```hon
[8]Builtin
```
can't we do `[_]Builtin` here instead for array size inference? if not, this is probably an oversight in the brolang compiler 🤔
</attachment>
- [ ] feature: diff view - GitHub code review integration after the local review UX is usable.
- publish local reviews explicitly: general PR comments, inline line/range comments, review summaries, approval, and requests for changes.
- preserve explicit PR identity and reviewed commit IDs; validate anchors against GitHub's PR diff and recheck the PR head before submission.
+3 -1
View File
@@ -117,9 +117,11 @@ Conflicted-revision regressions preserve unchanged symlinks, defer oversized uns
Resolver checks use isolated real jj repositories: read-only discovery, marker-free all-side/base projections preserving outside edits and EOF, initial seed undo, hunk choice/undo, retained resolved files, jj-authoritative status, checkout/external-write refusal, cancellation, nontext bytes/types/modes/deletion, and directory-child protection. Render checks cover aligned gaps/highlights, context folding, native Draft focus/search/selection, separate History, hidden/on-demand explorer, viewport retention and narrow layouts. Live PTY smoke exercises frozen marker-free alternatives, History/Draft switching, hunk choice and guarded save confirmation without touching user repositories.
Base-relative resolver regressions cover red display-only removals and green native additions on both sides, deletion/replacement gaps, add/add without a base, native-coordinate round trips and EOF rows. The rendered workspace check exercises both split orientations and unified mode, native edits, hunk choice/undo, and both History layouts. Live PTY smoke checks actual red `-` and green `+` cells on both candidates, opens columns/base-below History, verifies browsing leaves conflict bytes untouched, and takes/saves an Alternative with exact output bytes and jj confirmation.
Conflict History layout checks load `resolve_history_layout` through the real Ruby configuration and render both left/base/right columns and left/right over base. They cover source contents, full-width base geometry, Tab/directional focus, synchronized wheel scrolling, read-only base protection, narrow/short fallbacks and preserved Draft text. Live PTY smoke opens both layouts, focuses/scrolls the base, returns to Resolution, takes a hunk and saves; jj confirms the checkout is no longer conflicted.
Shared-workspace parity checks compare ordinary and resolver diff geometry/content under identical chrome, focus and source cursors. Cover native picker selection/remaps, Tree/List and dock/floating actions, excerpt initialization, folded search, focus transfer on wheel, layout controls, loading/History/pane-rebuild transitions, and preserved per-file viewport state. Live PTY smoke uses Space e, Down/Enter file selection and guarded saving through the same explorer path.
Shared-workspace checks exercise ordinary and resolver diffs with native chrome, focus and source cursors. Cover native picker selection/remaps, Tree/List and dock/floating actions, excerpt initialization, folded search, focus transfer on wheel, layout controls, loading/History/pane-rebuild transitions, and preserved per-file viewport state. Live PTY smoke uses Space e, Down/Enter file selection and guarded saving through the same explorer path.
Repository gutter checks cover LF/CRLF normalization, additions/replacements/deletion anchors, staged/disk/unsaved Git text, unborn and missing named files, ignored/unsupported paths, read-only colocated/non-colocated jj queries, stale results and per-buffer isolation, plus rendered modes, colors, current-line precedence, wrap/scroll and narrow/split geometry. Live PTY smoke verifies all four modes, edit/undo refresh, external Git HEAD changes, and jj parent changes without editor restart.
+4
View File
@@ -573,6 +573,8 @@ Run **`:resolve`** in Normal mode for the current jj working copy. It opens a de
The resolver shows the editable **Resolution** on the left and a read-only **Other version** on the right, with aligned rows, line/character highlighting, context folds and synchronized scrolling. Both are projected from the current working buffer, including pre-existing unsaved edits: only conflict-marker regions are replaced by their selected alternatives. Already-merged text and edits outside those regions appear on both sides. The initial output is seeded from the first side as a normal undoable buffer change; opening never writes it to disk. References remain frozen while later edits change only the output.
Both panes show their own changes **against the selected base**, not a left-to-right removal/addition comparison: removed base lines are red with `-`, and added candidate lines are green with `+`, on either side. Replacements show the removed base text followed by the candidate text; unchanged text stays neutral. Removed base lines are display-only, with no candidate line number, caret, or editable selection. With no base (an add/add conflict), both candidates are additions against empty text. The Resolution baseline uses the same frozen working-text projection as its alternatives, preserving already-merged text and pre-existing outside edits. `resolve.base` selects the baseline through History; returning to Resolution uses that selection. Hunk navigation and `resolve.use_source` still operate on differences between the actual Draft and Alternative.
Pane borders label the editable output **Resolution** and its frozen reference **Other version**. Paths and branch-comparison metadata are omitted from these titles; unsaved state belongs only to the output, and source identity appears with the reference. Explorer **C** marks files jj still considers conflicted, including while edited/checking or confirmation is unavailable; it clears only when jj confirms resolution.
| Command | Behavior |
@@ -588,6 +590,8 @@ Pane borders label the editable output **Resolution** and its frozen reference *
**History** is separate from editing: it compares the original inputs and their base, with all sides and bases selectable for multi-sided conflicts. `config.resolve_history_layout = "columns"` (default) shows left/base/right; `"base_below"` shows left/right above a full-width base. Both layouts retain aligned changes and synchronized vertical scrolling. Tab/Shift+Tab cycles in layout order; directional pane actions follow the displayed geometry. Narrow History shows the focused source, as does base-below History when too short to display both rows of panes. Draft text, undo, caret and viewport survive view switches. Resolution uses the ordinary diff workspace, including its configured explorer, split/unified/orientation controls, source-line navigation, excerpts, focus and scrolling. Read-only Alternatives and History reject edits and saves.
Each outer History pane uses the same red-removal/green-addition presentation against the selected base. The base pane highlights original text changed by either input; addition-only rows leave a gap there. This applies to both History layouts.
Input indexes are one-based and include bases. Conflict-specific actions appear in the palette and through corresponding `config.core.resolve` methods. File navigation uses the existing picker/explorer actions: Space o opens floating search, Space e focuses the dock, Ctrl+t switches Tree/List, Ctrl+w closes it, and configured tree/selection bindings apply unchanged. Enter selects a relevant conflict file and focuses the editable Draft. Normal-mode Tab/Shift+Tab, native motions/search, context expansion and layout actions follow normal diff behavior. The complete seeded Draft can be saved even when intentional differences remain against the Alternative: those differences are not unresolved conflict markers. There is no automatic “accept both”.
**`:w` saves and checks jj**, asynchronously. Before writing, the resolver locks the working copy and validates the frozen checkout and exact on-disk file state. Checkout changes, external writes and unsafe filesystem ancestors refuse the write, including force-save. Successful writes trigger jj's normal snapshot operation, which may capture other on-disk changes. The list distinguishes unresolved, unsaved, checking, resolved and unconfirmed states. jj—not marker scanning or a manual checkbox—confirms resolution. A failed confirmation keeps saved contents and reports unconfirmed status. Resolved files remain in the session for review. Dirty drafts prevent destructive resolver/pane/tab closes.
+267 -75
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
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()
.chain(&right_row.changes)
.map(|change| [change[0].clone(), change[0].clone()])
.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();
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 {
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,8 +979,8 @@ impl DiffState {
file.before_eof = before_eof;
file.after_eof = after_eof;
file.hunks = line_hunks(before, before_eof, after, after_eof);
if base.is_none() {
self.align_file();
self.terminal_rows = [before_terminal, after_terminal];
if before_terminal.is_some() || after_terminal.is_some() {
self.rows.push(AlignedRow {
before: before_terminal,
@@ -847,6 +990,50 @@ impl DiffState {
});
self.reset_excerpts();
}
}
self.terminal_rows = [before_terminal, after_terminal];
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]
.hunks
.is_empty()
@@ -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
+39 -35
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,20 +4727,25 @@ 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;
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),
);
}
if line.native {
paint_syntax_line(
frame,
source_text,
@@ -4761,6 +4761,7 @@ fn paint_diff_content<'a>(
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
+46 -9
View File
@@ -19,7 +19,8 @@ fn conflict_history_preserves_source_gaps_base_changes_and_native_eof_rows() {
assert_eq!(
rows,
[
[Some(0), Some(0), Some(0)],
[None, Some(0), None],
[Some(0), None, Some(0)],
[None, None, Some(1)],
[Some(1), Some(1), Some(2)],
[Some(2), Some(2), Some(3)],
@@ -44,13 +45,13 @@ fn conflict_history_preserves_source_gaps_base_changes_and_native_eof_rows() {
assert!(line.changes().any(|range| range.contains(&column)));
}
assert!(!line.changes().any(|range| range.contains(&4)));
let ColumnRow::Text(line) = base.row(2).unwrap() else {
let ColumnRow::Text(line) = base.row(3).unwrap() else {
panic!("unchanged base row must contain text");
};
assert!(!line.changed);
assert_eq!(line.changes().count(), 0);
let right = history.column(2);
let ColumnRow::Text(line) = right.view().row(1).unwrap() else {
let ColumnRow::Text(line) = right.view().row(2).unwrap() else {
panic!("inserted right row must contain text");
};
assert_eq!(line.text, Some("added"));
@@ -62,9 +63,45 @@ fn conflict_history_preserves_source_gaps_base_changes_and_native_eof_rows() {
"abc deg\nadded\nkeep\n",
"xbc def\nkeep\n",
]);
assert_eq!(history.column(1).view().native_row(1), Some(1));
assert_eq!(history.column(0).view().native_row(1), None);
assert_eq!(history.column(2).view().native_row(1), None);
assert_eq!(history.column(1).view().native_row(2), Some(1));
assert_eq!(history.column(0).view().native_row(2), None);
assert_eq!(history.column(2).view().native_row(2), None);
// Deletions are visible on either side, but never become editable source rows.
let history = HistoryComparison::new(["keep\n", "old\nkeep\n", "new\nkeep\n"]);
for column in [0, 2] {
let projection = history.column(column).view();
let ColumnRow::Text(line) = projection.row(0).unwrap() else {
panic!("removed base text must remain visible");
};
assert_eq!(line.text, Some("old"));
assert!(line.changed);
assert_eq!(line.polarity, Focus::Before);
assert!(!line.native);
assert_eq!(projection.native_row(0), None);
assert_eq!(projection.text_row(0), Some((usize::MAX, "old")));
}
assert!(matches!(
history.column(0).view().row(1),
Some(ColumnRow::Gap)
));
let right = history.column(2).view();
let ColumnRow::Text(line) = right.row(1).unwrap() else {
panic!("replacement must be an addition");
};
assert_eq!(line.text, Some("new"));
assert_eq!(line.polarity, Focus::After);
assert_eq!(right.native_row(1), Some(0));
assert_eq!(right.display_row(0), Some(1));
let history = HistoryComparison::new(["left", "", "right"]);
for column in [0, 2] {
let projection = history.column(column).view();
let ColumnRow::Text(line) = projection.row(0).unwrap() else {
panic!("add/add without a base must show additions on both sides");
};
assert!(line.changed && line.native);
assert_eq!(line.polarity, Focus::After);
assert_eq!(projection.native_row(0), Some(0));
}
for text in ["", "\n", "same", "same\n"] {
let history = HistoryComparison::new([text; 3]);
let native_rows = text.split('\n').count();
@@ -83,7 +120,7 @@ fn resolver_equal_text_retains_native_rows_and_cached_viewport() {
for text in ["", "\n", "same\n", "same"] {
let lines: Vec<_> = text.split('\n').map(str::to_owned).collect();
let mut state = DiffState::new();
state.set_resolver_text(&lines, &lines);
state.set_resolver_text(&lines, &lines, None);
assert_eq!(state.pane_layout(), PaneLayout::Split);
assert!(state.comparison.as_ref().unwrap().files[0].hunks.is_empty());
assert_eq!(
@@ -99,7 +136,7 @@ fn resolver_equal_text_retains_native_rows_and_cached_viewport() {
state.top = 1;
state.left_before = 3;
state.left_after = 4;
state.set_resolver_text(&lines, &lines);
state.set_resolver_text(&lines, &lines, None);
assert_eq!(state.comparison_id, id);
assert_eq!((state.top, state.left_before, state.left_after), (1, 3, 4));
}
@@ -2407,7 +2444,7 @@ fn column_view_distinguishes_native_terminal_rows_from_missing_ordinary_text() {
for text in ["", "same\n"] {
let lines: Vec<_> = text.split('\n').map(str::to_owned).collect();
let mut state = DiffState::new();
state.set_resolver_text(&lines, &lines);
state.set_resolver_text(&lines, &lines, None);
for focus in [Focus::Before, Focus::After] {
let view = state.column_view(focus).unwrap();
let native = lines.len() - 1;
+4 -7
View File
@@ -122,7 +122,10 @@ fn resolver_shares_native_excerpt_search_motions_and_explicit_writable_side() {
}
let draft = before.join("\n");
let alternative = after.join("\n");
let mut resolver = resolver_editor(&draft, &[(&draft, false), (&alternative, false)]);
let mut resolver = resolver_editor(
&draft,
&[(&draft, false), (&alternative, false), (&draft, true)],
);
let mut ordinary = editor();
historical_diff(&mut ordinary, &directory.0, before, after);
for editor in [&mut resolver, &mut ordinary] {
@@ -161,10 +164,6 @@ fn resolver_shares_native_excerpt_search_motions_and_explicit_writable_side() {
.is_some()
);
}
assert_eq!(
resolver.diff().unwrap().display_rows,
ordinary.diff().unwrap().display_rows
);
assert!(resolver.diff_editing());
assert!(resolver.diff().unwrap().side_editable(Focus::Before));
assert!(!resolver.diff().unwrap().side_editable(Focus::After));
@@ -397,7 +396,6 @@ fn resolver_history_selects_every_side_and_base_without_moving_draft_or_viewport
editor.set_diff_viewport(diff.comparison_id.unwrap(), diff.file, 8, 2, 4);
let cursor = editor.cursor();
let buffer = editor.buffer_id();
let id = editor.resolver_diff().unwrap().comparison_id;
assert!(editor.file_picker().is_none());
editor.execute("word_wrap.toggle");
assert!(editor.word_wrap());
@@ -419,7 +417,6 @@ fn resolver_history_selects_every_side_and_base_without_moving_draft_or_viewport
assert_eq!(editor.cursor(), cursor);
assert_eq!(editor.buffer_id(), buffer);
let diff = editor.resolver_diff().unwrap();
assert_eq!(diff.comparison_id, id);
assert_eq!((diff.top, diff.left_before, diff.left_after), (8, 2, 4));
assert!(editor.word_wrap());
editor.handle(Input::Char('\t'));
+70 -165
View File
@@ -1286,165 +1286,47 @@ fn resolver_renders_aligned_native_draft_alternative_and_separate_history() {
let content = diff_inner(area);
let x = content.right() - geometry.text_widths[side];
let buffer = terminal.backend().buffer();
assert_eq!(buffer[(x, content.y)].symbol(), "v");
assert_eq!(
buffer[(x, content.y)].bg,
views.panels.diff_colors.row[side]
);
assert_eq!(buffer[(x + 8, content.y)].symbol(), "b");
assert_eq!(buffer[(x, content.y)].bg, views.panels.diff_colors.row[0]);
assert_eq!(
buffer[(x + 8, content.y)].bg,
views.panels.diff_colors.character[side]
views.panels.diff_colors.character[0]
);
assert_eq!(buffer[(x, content.y + 1)].symbol(), "v");
assert_eq!(
buffer[(x, content.y + 1)].bg,
views.panels.diff_colors.row[1]
);
}
assert_ne!(
line_at(&terminal, diff_inner(draft), draft.y + 1),
line_at(&terminal, diff_inner(alternative), alternative.y + 1)
line_at(&terminal, diff_inner(draft), draft.y + 2),
line_at(&terminal, diff_inner(alternative), alternative.y + 2)
);
for area in [draft, alternative] {
assert!(
line_at(&terminal, diff_inner(area), area.y + 2).contains("opening context 00"),
line_at(&terminal, diff_inner(area), area.y + 3).contains("opening context 00"),
"Preexisting unsaved working-buffer edits must appear in both projections"
);
}
let diff = session.editor().diff().unwrap();
let file = &diff.comparison.as_ref().unwrap().files[diff.file];
let mut reference_file = syntax_diff_file(
&file.path.to_string_lossy(),
&file.before.join("\n"),
&file.after.join("\n"),
);
reference_file.before_eof = file.before_eof;
reference_file.after_eof = file.after_eof;
reference_file.hunks = crate::diff::line_hunks(
&reference_file.before,
reference_file.before_eof,
&reference_file.after,
reference_file.after_eof,
);
let mut reference = Editor::open(None, vec![]).unwrap();
for _ in 1..session.editor().tab_count() {
reference.execute("tab.new");
}
reference
.set_chrome_config(
session.editor().top_bar_config().clone(),
session.editor().bottom_bar_config().clone(),
BTreeMap::new(),
)
.unwrap();
install_readonly_diff(&mut reference, reference_file);
reference.handle(Input::Ctrl('w'));
let cursor = session.editor().cursor();
for ch in format!("{}g", cursor.row + 1).chars() {
reference.handle(Input::Char(ch));
}
reference.handle(Input::Home);
for _ in 0..cursor.column {
reference.handle(Input::Right);
}
assert_eq!(reference.cursor(), cursor);
assert_eq!(
reference.diff_buffer_focus(),
session.editor().diff_buffer_focus()
);
let mut reference_terminal =
Terminal::new(ratatui::backend::TestBackend::new(120, 30)).unwrap();
let mut reference_views = BufferViewports::default();
reference_views.set_theme(Some(DARK_DIFF_THEME));
for command in [
None,
Some("diff.orientation.toggle"),
Some("diff.view.toggle"),
Some("diff.view.toggle"),
Some("diff.orientation.toggle"),
"diff.orientation.toggle",
"diff.view.toggle",
"diff.view.toggle",
"diff.orientation.toggle",
] {
if let Some(command) = command {
session.execute(command);
reference.execute(command);
}
render(&mut terminal, &mut views, &mut session);
render_native_diff(
&mut reference_terminal,
&mut reference_views,
&mut reference,
);
let actual = views.panels.diff.as_ref().unwrap();
let expected = reference_views.panels.diff.as_ref().unwrap();
assert_eq!(actual.view_mode, expected.view_mode);
assert_eq!(actual.split_axis, expected.split_axis);
assert_eq!(actual.text_widths, expected.text_widths);
for (side, ((actual_pane, _), (expected_pane, _))) in actual.regions[1..]
.iter()
.zip(&expected.regions[1..])
.enumerate()
{
assert_eq!(
(
actual_pane.x,
actual_pane.y,
actual_pane.width,
actual_pane.height
),
(
expected_pane.x,
expected_pane.y,
expected_pane.width,
expected_pane.height
)
);
let actual_text = Rect {
x: diff_inner(*actual_pane).right() - actual.text_widths[side],
width: actual.text_widths[side],
..diff_inner(*actual_pane)
};
let expected_text = Rect {
x: diff_inner(*expected_pane).right() - expected.text_widths[side],
width: expected.text_widths[side],
..diff_inner(*expected_pane)
};
for row in 0..actual_text.height {
assert_eq!(
line_at(&terminal, actual_text, actual_text.y + row),
line_at(&reference_terminal, expected_text, expected_text.y + row,),
"{command:?}, row {row}, full rows: {:?} / {:?}",
line_at(&terminal, diff_inner(*actual_pane), actual_text.y + row),
line_at(
&reference_terminal,
diff_inner(*expected_pane),
expected_text.y + row,
),
);
}
}
let screen = screen_text(&terminal);
assert!(screen.contains("value = base;"));
assert!(screen.contains("value = left;"));
assert!(screen.contains("value = right;"));
assert_eq!(session.editor().lines(), original_draft);
}
let folded_context_rows = screen_text(&terminal).matches("context").count();
session.handle(Input::Char(']'));
reference.handle(Input::Char(']'));
render(&mut terminal, &mut views, &mut session);
render_native_diff(
&mut reference_terminal,
&mut reference_views,
&mut reference,
);
let folds = |editor: &Editor| {
editor
.diff()
.unwrap()
.display_rows
.iter()
.filter_map(|row| match row {
DisplayRow::Fold { start, end } => Some((*start, *end)),
_ => None,
})
.collect::<Vec<_>>()
};
assert_eq!(folds(session.editor()), folds(&reference));
let expanded_context_rows = screen_text(&terminal).matches("context").count();
assert!(expanded_context_rows > folded_context_rows);
assert_eq!(
expanded_context_rows,
screen_text(&reference_terminal).matches("context").count()
);
let third_source = session.editor().resolver().unwrap().files[current_file]
.sources
.iter()
@@ -1453,7 +1335,7 @@ fn resolver_renders_aligned_native_draft_alternative_and_separate_history() {
session.execute(&format!("resolve.source {}", third_source + 1));
render(&mut terminal, &mut views, &mut session);
assert!(
line_at(&terminal, diff_inner(alternative), alternative.y + 1).contains("value = third;")
line_at(&terminal, diff_inner(alternative), alternative.y + 2).contains("value = third;")
);
let right_content = diff_inner(alternative);
assert!(
@@ -1516,9 +1398,15 @@ fn resolver_renders_aligned_native_draft_alternative_and_separate_history() {
assert!(left_heading.contains("unsaved"));
assert!(!right_heading.contains("unsaved"));
assert!(right_heading.contains("read-only"));
assert!(line_at(&terminal, left, left.y + 1).contains("manual opening context 00"));
assert!(line_at(&terminal, right, right.y + 1).contains("opening context 00"));
assert!(!line_at(&terminal, right, right.y + 1).contains("manual"));
assert!(
(left.y..left.bottom())
.any(|y| line_at(&terminal, left, y).contains("manual opening context 00"))
);
assert!(
(right.y..right.bottom())
.any(|y| line_at(&terminal, right, y).contains("opening context 00"))
);
assert!(!(right.y..right.bottom()).any(|y| line_at(&terminal, right, y).contains("manual")));
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), original_draft);
session.handle(Input::Up);
@@ -1637,7 +1525,11 @@ fn resolver_renders_aligned_native_draft_alternative_and_separate_history() {
let source_rows: Vec<_> = views
.rects
.iter()
.map(|(_, area)| line_at(&terminal, *area, area.y))
.map(|(_, area)| {
(area.y..area.bottom())
.map(|y| line_at(&terminal, *area, y))
.collect::<String>()
})
.collect();
assert!(source_rows.iter().any(|row| row.contains("base")));
assert!(source_rows.iter().any(|row| row.contains("left")));
@@ -1645,19 +1537,15 @@ fn resolver_renders_aligned_native_draft_alternative_and_separate_history() {
let history = &views.history[&views.history_key.unwrap()];
for (column, (_, content)) in views.rects.iter().enumerate() {
let x = content.right() - history.widths[column] as u16;
let side = usize::from(column != 1);
let buffer = terminal.backend().buffer();
assert_eq!(buffer[(x, content.y)].symbol(), "v");
assert_eq!(
buffer[(x, content.y)].bg,
views.panels.diff_colors.row[side]
);
assert_eq!(buffer[(x, content.y)].bg, views.panels.diff_colors.row[0]);
assert_eq!(
buffer[(x + 8, content.y)].bg,
views.panels.diff_colors.character[side]
views.panels.diff_colors.character[0]
);
assert_eq!(
buffer[(x, content.y + 1)].bg,
buffer[(x, content.y + 2)].bg,
Color::Reset,
"Shared historical context must not be highlighted as changed"
);
@@ -1672,22 +1560,21 @@ fn resolver_renders_aligned_native_draft_alternative_and_separate_history() {
session.editor_mut().focus_resolver_pane(right_history);
session.execute(&format!("resolve.source {}", third_source + 1));
render(&mut terminal, &mut views, &mut session);
assert!(
views
.rects
.iter()
.any(|(_, area)| { line_at(&terminal, *area, area.y).contains("value = third;") })
);
assert!(views.rects.iter().any(|(_, area)| {
(area.y..area.bottom()).any(|y| line_at(&terminal, *area, y).contains("value = third;"))
}));
let history_areas = [views.rects[0].1, views.rects[1].1, views.rects[2].1];
let insertion_y = (history_areas[2].y..history_areas[2].bottom())
.find(|&y| line_at(&terminal, history_areas[2], y).contains("third-only"))
.unwrap();
for content in history_areas.into_iter().take(2) {
assert!(
line_at(&terminal, content, content.y + 1)
line_at(&terminal, content, insertion_y)
.chars()
.all(|ch| ch == '╱'),
"The historical input insertion must leave aligned gaps in input/base"
);
}
assert!(line_at(&terminal, history_areas[2], history_areas[2].y + 1).contains("third-only"));
let context_rows: Vec<_> = history_areas
.into_iter()
.map(|content| {
@@ -1734,9 +1621,18 @@ fn resolver_renders_aligned_native_draft_alternative_and_separate_history() {
)
));
render(&mut terminal, &mut views, &mut session);
for content in history_areas {
assert!(line_at(&terminal, content, content.y).contains("context 01"));
}
let context: Vec<_> = history_areas
.into_iter()
.map(|content| {
line_at(&terminal, content, content.y)
.split_once("context ")
.unwrap()
.1
.trim_end()
.to_owned()
})
.collect();
assert!(context.iter().all(|line| line == &context[0]));
assert_eq!(session.editor().pane_id(), history_pane);
assert_eq!(session.editor().cursor(), history_cursor);
assert!(views.scroll(
@@ -1801,7 +1697,7 @@ fn resolver_renders_aligned_native_draft_alternative_and_separate_history() {
(right_area, "value = right;"),
(base_area, "value = base;"),
] {
assert!(line_at(&terminal, area, area.y).contains(value));
assert!((area.y..area.bottom()).any(|y| line_at(&terminal, area, y).contains(value)));
}
bottom.handle(Input::Char('\t'));
assert_eq!(bottom.editor().pane_id(), right);
@@ -1818,9 +1714,18 @@ fn resolver_renders_aligned_native_draft_alternative_and_separate_history() {
document_wheel(MouseEventKind::ScrollDown, base_area.x, base_area.y)
));
render(&mut terminal, &mut views, &mut bottom);
for area in [left_area, right_area, base_area] {
assert!(line_at(&terminal, area, area.y).contains("context 02"));
}
let context: Vec<_> = [left_area, right_area, base_area]
.into_iter()
.map(|area| {
line_at(&terminal, area, area.y)
.split_once("context ")
.unwrap()
.1
.trim_end()
.to_owned()
})
.collect();
assert!(context.iter().all(|line| line == &context[0]));
let readonly = bottom.editor().context().text;
bottom.handle(Input::Char('i'));
bottom.handle(Input::Paste("must not edit base".into()));