refactor: deepen conflict History alignment ownership

This commit is contained in:
Valdemar Lorenzen
2026-10-16 17:07:28 +02:00
parent 53c37e6209
commit a1fdc1e1b0
5 changed files with 250 additions and 153 deletions
+3
View File
@@ -29,6 +29,9 @@ The full command name of a command-capable core action, without its `core.` pref
**Command alias**: **Command alias**:
A shorthand spelling of a canonical Ex command, such as `q` for `quit`. An alias refers to the same action, not a separate command implementation. A shorthand spelling of a canonical Ex command, such as `q` for `quit`. An alias refers to the same action, not a separate command implementation.
**Conflict History**:
A read-only comparison of selected original conflict inputs against a shared base, separate from the editable resolution Draft.
## Configuration interfaces ## Configuration interfaces
- Prefer typed objects for editor-owned actions, modes, and other closed sets. Expose new core binding targets through `config.core` rather than requiring command-name strings. - Prefer typed objects for editor-owned actions, modes, and other closed sets. Expose new core binding targets through `config.core` rather than requiring command-name strings.
+2
View File
@@ -84,6 +84,8 @@ The resolver owns a dedicated native tab with an editable Draft and frozen read-
Resolution exposes its projected comparison through the same active DiffState and native FilePicker as ordinary diffs. Pane labels and writability are explicit comparison properties; file selection, bindings, native navigation, layout and scrolling share the existing interaction path. Conflict state owns only projections, source choices and guarded save/check metadata; there is no separate resolver filter or file-list renderer. Resolution exposes its projected comparison through the same active DiffState and native FilePicker as ordinary diffs. Pane labels and writability are explicit comparison properties; file selection, bindings, native navigation, layout and scrolling share the existing interaction path. Conflict state owns only projections, source choices and guarded save/check metadata; there is no separate resolver filter or file-list renderer.
Conflict History alignment lives in `diff::HistoryComparison`: it joins the selected inputs at shared base rows, combines base change ranges, retains native EOF rows, and exposes immutable column projections with source/display mapping. The frontend consumes each projection's source side rather than assigning Before/After conventions itself. `HistoryDiff` retains only presentation state: geometry, wrapping, shared scrolling, and caret visibility. Ordinary comparisons and Draft resolution continue to use their existing `DiffState` behavior.
`Editor::scroll_distance()` supplies the session-wide optional edge margin. The TUI applies it to document/diff visual rows and unwrapped text columns, and retains independent picker offsets across navigation frames. Picker windows exclude their fixed chrome and align whole multi-row entries. File projections carry a monotonic viewport revision, so catalog/filter/expansion changes invalidate picker identity without hashing the entire catalog on every redraw. Metadata-only status changes do not move the viewport. Explicit LSP/wrap-toggle anchors and detached wheel views retain precedence. `Editor::scroll_distance()` supplies the session-wide optional edge margin. The TUI applies it to document/diff visual rows and unwrapped text columns, and retains independent picker offsets across navigation frames. Picker windows exclude their fixed chrome and align whole multi-row entries. File projections carry a monotonic viewport revision, so catalog/filter/expansion changes invalidate picker identity without hashing the entire catalog on every redraw. Metadata-only status changes do not move the viewport. Explicit LSP/wrap-toggle anchors and detached wheel views retain precedence.
Keyboard viewport actions queue per-view row and configured-step counts without moving carets. `Editor::take_viewport_scroll(height)` resolves step counts using the session-wide `scroll_step_lines` preference, or half the focused viewport when unset, and saturates multiplication before the frontend clamps movement to content bounds. Ordinary, diff, and conflict-history rendering share this resolution. Keyboard viewport actions queue per-view row and configured-step counts without moving carets. `Editor::take_viewport_scroll(height)` resolves step counts using the session-wide `scroll_step_lines` preference, or half the focused viewport when unset, and saturates multiplication before the frontend clamps movement to content bounds. Ordinary, diff, and conflict-history rendering share this resolution.
+113
View File
@@ -240,6 +240,119 @@ pub enum DisplayRow {
Fold { start: usize, end: usize }, Fold { start: usize, end: usize },
} }
/// Immutable left/base/right conflict History, aligned at shared base rows.
pub(crate) struct HistoryComparison {
columns: [DiffState; 3],
}
/// A read-only projection for the existing diff painter.
pub(crate) struct HistoryColumn<'a> {
pub diff: &'a DiffState,
pub file: &'a ChangedFile,
pub focus: Focus,
pub lines: &'a [String],
}
impl HistoryColumn<'_> {
pub fn source_row(&self, display: usize) -> Option<usize> {
self.diff.display_source_row(self.focus, display)
}
pub fn display_row(&self, source: usize) -> Option<usize> {
self.diff.source_display_row(self.focus, source)
}
}
impl HistoryComparison {
pub fn new(sources: [&str; 3]) -> Self {
let [left, base, 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 {
column.display_rows = (0..column.rows.len()).map(DisplayRow::Line).collect();
}
Self { columns }
}
pub fn row_count(&self) -> usize {
self.columns[0].rows.len()
}
pub fn column(&self, index: usize) -> HistoryColumn<'_> {
let diff = &self.columns[index];
let file = &diff.comparison.as_ref().unwrap().files[0];
let focus = if index == 1 {
Focus::Before
} else {
Focus::After
};
let lines = if focus == Focus::Before {
&file.before
} else {
&file.after
};
HistoryColumn {
diff,
file,
focus,
lines,
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum RequestKind { enum RequestKind {
Catalog, Catalog,
+71 -153
View File
@@ -41,8 +41,8 @@ use unicode_width::UnicodeWidthStr;
use crate::{ use crate::{
chrome::{ChromeLine, PopupAnchor, SegmentConfig}, chrome::{ChromeLine, PopupAnchor, SegmentConfig},
diff::{ diff::{
AlignedRow, ChangedFile, DiffState, DisplayRow, FileStatus, Focus, PaneLayout, Phase, ChangedFile, DiffState, DisplayRow, FileStatus, Focus, HistoryComparison, PaneLayout,
RowKind, ViewMode, Phase, RowKind, ViewMode,
}, },
editor::{ editor::{
Completion, Cursor, Editor, Hover, Input, Mode, Palette, ResolverLayout, ResolverStatus, Completion, Cursor, Editor, Hover, Input, Mode, Palette, ResolverLayout, ResolverStatus,
@@ -352,7 +352,7 @@ fn history_key(resolver: &ResolverWorkspace) -> Option<HistoryKey> {
} }
struct HistoryDiff { struct HistoryDiff {
columns: [DiffState; 3], comparison: HistoryComparison,
top: DiffWrapPosition, top: DiffWrapPosition,
left: [usize; 3], left: [usize; 3],
widths: [usize; 3], widths: [usize; 3],
@@ -369,70 +369,14 @@ impl HistoryDiff {
index index
.and_then(|index| file.sources.get(index)) .and_then(|index| file.sources.get(index))
.map_or("", |source| source.text.as_str()) .map_or("", |source| source.text.as_str())
.split('\n')
.map(str::to_owned)
.collect::<Vec<_>>()
}; };
let base = text(resolver.selected_base); let comparison = HistoryComparison::new([
let mut columns: [DiffState; 3] = std::array::from_fn(|_| DiffState::new()); text(Some(resolver.history_sources[0])),
columns[0].set_resolver_text(&base, &text(Some(resolver.history_sources[0]))); text(resolver.selected_base),
columns[1].set_resolver_text(&base, &base); text(Some(resolver.history_sources[1])),
columns[2].set_resolver_text(&base, &text(Some(resolver.history_sources[1]))); ]);
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 {
column.display_rows = (0..column.rows.len()).map(DisplayRow::Line).collect();
}
Self { Self {
columns, comparison,
top: DiffWrapPosition::default(), top: DiffWrapPosition::default(),
left: [0; 3], left: [0; 3],
widths: [0; 3], widths: [0; 3],
@@ -443,28 +387,14 @@ impl HistoryDiff {
} }
} }
fn focus(column: usize) -> Focus {
if column == 1 {
Focus::Before
} else {
Focus::After
}
}
fn row_height(&mut self, row: usize) -> usize { fn row_height(&mut self, row: usize) -> usize {
let mut height = 1; let mut height = 1;
for column in 0..3 { for column in 0..3 {
if self.wrapping[column] if self.wrapping[column]
&& self.widths[column] > 0 && self.widths[column] > 0
&& let Some(source) = && let Some(source) = self.comparison.column(column).source_row(row)
self.columns[column].display_source_row(Self::focus(column), row)
{ {
let file = &self.columns[column].comparison.as_ref().unwrap().files[0]; let lines = self.comparison.column(column).lines;
let lines = if column == 1 {
&file.before
} else {
&file.after
};
height = height.max( height = height.max(
self.wrap[column] self.wrap[column]
.segments(source, lines.get(source).map_or("", String::as_str)) .segments(source, lines.get(source).map_or("", String::as_str))
@@ -479,7 +409,7 @@ impl HistoryDiff {
if down { if down {
if self.top.continuation + 1 < self.row_height(self.top.display) { if self.top.continuation + 1 < self.row_height(self.top.display) {
self.top.continuation += 1; self.top.continuation += 1;
} else if self.top.display + 1 < self.columns[0].rows.len() { } else if self.top.display + 1 < self.comparison.row_count() {
self.top.display += 1; self.top.display += 1;
self.top.continuation = 0; self.top.continuation = 0;
} }
@@ -500,17 +430,10 @@ impl HistoryDiff {
for column in 0..3 { for column in 0..3 {
let mut segments = [None; 2]; let mut segments = [None; 2];
let mut final_segments = [false; 2]; let mut final_segments = [false; 2];
let side = usize::from(column != 1); let projection = self.comparison.column(column);
if let Some(source) = let side = usize::from(projection.focus == Focus::After);
self.columns[column].display_source_row(Self::focus(column), self.top.display) if let Some(source) = projection.source_row(self.top.display) {
{ let text = projection.lines.get(source).map_or("", String::as_str);
let file = &self.columns[column].comparison.as_ref().unwrap().files[0];
let lines = if column == 1 {
&file.before
} else {
&file.after
};
let text = lines.get(source).map_or("", String::as_str);
if self.wrapping[column] { if self.wrapping[column] {
let parts = self.wrap[column].segments(source, text); let parts = self.wrap[column].segments(source, text);
segments[side] = parts.get(self.top.continuation).copied(); segments[side] = parts.get(self.top.continuation).copied();
@@ -951,14 +874,13 @@ impl BufferViewports {
history.widths = [0; 3]; history.widths = [0; 3];
for &(column, pane, content) in panes.iter().flatten() { for &(column, pane, content) in panes.iter().flatten() {
editor.with_render_pane(pane, |view| { editor.with_render_pane(pane, |view| {
let diff = &history.columns[column]; let projection = history.comparison.column(column);
let file = &diff.comparison.as_ref().unwrap().files[0];
let width = usize::from(content.width); let width = usize::from(content.width);
history.widths[column] = width.saturating_sub(diff_gutter( history.widths[column] = width.saturating_sub(diff_gutter(
diff, projection.diff,
file, projection.file,
width, width,
HistoryDiff::focus(column), projection.focus,
)); ));
history.wrapping[column] = view.word_wrap(); history.wrapping[column] = view.word_wrap();
history.wrap[column].prepare((view.buffer_id(), 0), history.widths[column]); history.wrap[column].prepare((view.buffer_id(), 0), history.widths[column]);
@@ -989,52 +911,48 @@ impl BufferViewports {
); );
if !editor.file_picker().is_some_and(|picker| picker.focused) if !editor.file_picker().is_some_and(|picker| picker.focused)
&& (history.caret != Some(identity) || previous_widths != history.widths) && (history.caret != Some(identity) || previous_widths != history.widths)
&& let Some(display) = history.comparison.column(column).display_row(cursor.row)
{ {
let diff = &history.columns[column]; let column_offset = display_column(&editor.lines()[cursor.row], cursor.column);
if let Some(display) = let continuation = if history.wrapping[column] {
diff.source_display_row(HistoryDiff::focus(column), cursor.row) caret_segment(
{ history.wrap[column].segments(cursor.row, &editor.lines()[cursor.row]),
let column_offset = display_column(&editor.lines()[cursor.row], cursor.column); column_offset,
let continuation = if history.wrapping[column] { )
caret_segment( } else {
history.wrap[column].segments(cursor.row, &editor.lines()[cursor.row]), history.left[column] = scroll_offset(
column_offset, history.left[column],
) column_offset,
} else { history.widths[column],
history.left[column] = scroll_offset( editor.scroll_distance(),
history.left[column], );
column_offset, 0
history.widths[column], };
editor.scroll_distance(), let caret = DiffWrapPosition {
); display,
0 continuation,
}; };
let caret = DiffWrapPosition { let screen = history.visible[column]
display, .iter()
continuation, .position(|row| row.position == caret);
}; let height = usize::from(content.height);
let screen = history.visible[column] let margin = scroll_margin(height, editor.scroll_distance());
.iter() let anchor = if height == 0 {
.position(|row| row.position == caret); None
let height = usize::from(content.height); } else if caret < history.top || screen.is_some_and(|row| row < margin) {
let margin = scroll_margin(height, editor.scroll_distance()); Some(margin)
let anchor = if height == 0 { } else if screen.is_none_or(|row| row >= height.saturating_sub(margin)) {
None Some(height.saturating_sub(margin + 1))
} else if caret < history.top || screen.is_some_and(|row| row < margin) { } else {
Some(margin) None
} else if screen.is_none_or(|row| row >= height.saturating_sub(margin)) { };
Some(height.saturating_sub(margin + 1)) if let Some(anchor) = anchor {
} else { history.top = caret;
None for _ in 0..anchor {
}; history.step(false);
if let Some(anchor) = anchor {
history.top = caret;
for _ in 0..anchor {
history.step(false);
}
} }
history.fill(height);
} }
history.fill(height);
} }
history.caret = Some(identity); history.caret = Some(identity);
} }
@@ -1051,12 +969,11 @@ impl BufferViewports {
} }
for (column, pane, content) in panes.into_iter().flatten() { for (column, pane, content) in panes.into_iter().flatten() {
editor.with_render_pane(pane, |view| { editor.with_render_pane(pane, |view| {
let diff = &history.columns[column]; let projection = history.comparison.column(column);
let file = &diff.comparison.as_ref().unwrap().files[0];
let syntax = self.syntax.entry(view.buffer_id()).or_default(); let syntax = self.syntax.entry(view.buffer_id()).or_default();
let source_rows = history.visible[column].iter().filter_map(|row| { let source_rows = history.visible[column]
diff.display_source_row(HistoryDiff::focus(column), row.position.display) .iter()
}); .filter_map(|row| projection.source_row(row.position.display));
let first = source_rows let first = source_rows
.clone() .clone()
.min() .min()
@@ -1074,10 +991,10 @@ impl BufferViewports {
); );
let text_area = draw_diff_pane( let text_area = draw_diff_pane(
frame, frame,
diff, projection.diff,
file, projection.file,
content, content,
column == 1, projection.focus == Focus::Before,
[&*syntax, &*syntax], [&*syntax, &*syntax],
[view.semantic_tokens(), view.semantic_tokens()], [view.semantic_tokens(), view.semantic_tokens()],
(history.top.display, &mut history.left[column], None), (history.top.display, &mut history.left[column], None),
@@ -1095,9 +1012,9 @@ impl BufferViewports {
self.panels.caret = paint_diff_editor( self.panels.caret = paint_diff_editor(
frame, frame,
view, view,
diff, projection.diff,
text_area, text_area,
HistoryDiff::focus(column), projection.focus,
(history.top.display, history.left[column]), (history.top.display, history.left[column]),
Some(&history.visible[column]), Some(&history.visible[column]),
) )
@@ -1194,8 +1111,9 @@ impl BufferViewports {
} else { } else {
history.left[column].saturating_sub(3) history.left[column].saturating_sub(3)
}; };
history.left[column] = history.columns[column].bound_horizontal( let projection = history.comparison.column(column);
HistoryDiff::focus(column), history.left[column] = projection.diff.bound_horizontal(
projection.focus,
left, left,
history.widths[column], history.widths[column],
); );
+61
View File
@@ -4,6 +4,67 @@ std::thread_local! {
pub(super) static WIDTH_SCANS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) }; pub(super) static WIDTH_SCANS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
} }
#[test]
fn conflict_history_preserves_source_gaps_base_changes_and_native_eof_rows() {
let history = HistoryComparison::new([
"xbc def\nkeep\n",
"abc def\nkeep\n",
"abc deg\nadded\nkeep\n",
]);
let rows: Vec<_> = (0..history.row_count())
.map(|row| {
std::array::from_fn::<_, 3, _>(|column| history.column(column).source_row(row))
})
.collect();
assert_eq!(
rows,
[
[Some(0), Some(0), Some(0)],
[None, None, Some(1)],
[Some(1), Some(1), Some(2)],
[Some(2), Some(2), Some(3)],
]
);
for column in 0..3 {
let projection = history.column(column);
for (display, row) in rows.iter().enumerate() {
if let Some(source) = row[column] {
assert_eq!(projection.display_row(source), Some(display));
}
}
}
let base = history.column(1);
let changed = &base.diff.rows[0].changes;
for column in [0, 6] {
assert!(changed.iter().any(|range| range[0].contains(&column)));
}
assert!(!changed.iter().any(|range| range[0].contains(&4)));
assert_eq!(base.diff.rows[2].kind, RowKind::Equal);
assert!(base.diff.rows[2].changes.is_empty());
assert_eq!(history.column(2).lines[1], "added");
// Rebuilding for a new input/base choice must not retain old source mappings.
let history = HistoryComparison::new([
"abc def\nkeep\n",
"abc deg\nadded\nkeep\n",
"xbc def\nkeep\n",
]);
assert_eq!(history.column(1).source_row(1), Some(1));
assert_eq!(history.column(0).source_row(1), None);
assert_eq!(history.column(2).source_row(1), None);
for text in ["", "\n", "same", "same\n"] {
let history = HistoryComparison::new([text; 3]);
let native_rows = text.split('\n').count();
assert_eq!(history.row_count(), native_rows);
for column in 0..3 {
for row in 0..native_rows {
assert_eq!(history.column(column).source_row(row), Some(row));
assert_eq!(history.column(column).display_row(row), Some(row));
}
}
}
}
#[test] #[test]
fn resolver_equal_text_retains_native_rows_and_cached_viewport() { fn resolver_equal_text_retains_native_rows_and_cached_viewport() {
for text in ["", "\n", "same\n", "same"] { for text in ["", "\n", "same\n", "same"] {