refactor: deepen conflict History alignment ownership
This commit is contained in:
@@ -29,6 +29,9 @@ The full command name of a command-capable core action, without its `core.` pref
|
||||
**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.
|
||||
|
||||
**Conflict History**:
|
||||
A read-only comparison of selected original conflict inputs against a shared base, separate from the editable resolution Draft.
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
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.
|
||||
|
||||
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
@@ -240,6 +240,119 @@ pub enum DisplayRow {
|
||||
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)]
|
||||
enum RequestKind {
|
||||
Catalog,
|
||||
|
||||
+71
-153
@@ -41,8 +41,8 @@ use unicode_width::UnicodeWidthStr;
|
||||
use crate::{
|
||||
chrome::{ChromeLine, PopupAnchor, SegmentConfig},
|
||||
diff::{
|
||||
AlignedRow, ChangedFile, DiffState, DisplayRow, FileStatus, Focus, PaneLayout, Phase,
|
||||
RowKind, ViewMode,
|
||||
ChangedFile, DiffState, DisplayRow, FileStatus, Focus, HistoryComparison, PaneLayout,
|
||||
Phase, RowKind, ViewMode,
|
||||
},
|
||||
editor::{
|
||||
Completion, Cursor, Editor, Hover, Input, Mode, Palette, ResolverLayout, ResolverStatus,
|
||||
@@ -352,7 +352,7 @@ fn history_key(resolver: &ResolverWorkspace) -> Option<HistoryKey> {
|
||||
}
|
||||
|
||||
struct HistoryDiff {
|
||||
columns: [DiffState; 3],
|
||||
comparison: HistoryComparison,
|
||||
top: DiffWrapPosition,
|
||||
left: [usize; 3],
|
||||
widths: [usize; 3],
|
||||
@@ -369,70 +369,14 @@ impl HistoryDiff {
|
||||
index
|
||||
.and_then(|index| file.sources.get(index))
|
||||
.map_or("", |source| source.text.as_str())
|
||||
.split('\n')
|
||||
.map(str::to_owned)
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let base = text(resolver.selected_base);
|
||||
let mut columns: [DiffState; 3] = std::array::from_fn(|_| DiffState::new());
|
||||
columns[0].set_resolver_text(&base, &text(Some(resolver.history_sources[0])));
|
||||
columns[1].set_resolver_text(&base, &base);
|
||||
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();
|
||||
}
|
||||
let comparison = HistoryComparison::new([
|
||||
text(Some(resolver.history_sources[0])),
|
||||
text(resolver.selected_base),
|
||||
text(Some(resolver.history_sources[1])),
|
||||
]);
|
||||
Self {
|
||||
columns,
|
||||
comparison,
|
||||
top: DiffWrapPosition::default(),
|
||||
left: [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 {
|
||||
let mut height = 1;
|
||||
for column in 0..3 {
|
||||
if self.wrapping[column]
|
||||
&& self.widths[column] > 0
|
||||
&& let Some(source) =
|
||||
self.columns[column].display_source_row(Self::focus(column), row)
|
||||
&& let Some(source) = self.comparison.column(column).source_row(row)
|
||||
{
|
||||
let file = &self.columns[column].comparison.as_ref().unwrap().files[0];
|
||||
let lines = if column == 1 {
|
||||
&file.before
|
||||
} else {
|
||||
&file.after
|
||||
};
|
||||
let lines = self.comparison.column(column).lines;
|
||||
height = height.max(
|
||||
self.wrap[column]
|
||||
.segments(source, lines.get(source).map_or("", String::as_str))
|
||||
@@ -479,7 +409,7 @@ impl HistoryDiff {
|
||||
if down {
|
||||
if self.top.continuation + 1 < self.row_height(self.top.display) {
|
||||
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.continuation = 0;
|
||||
}
|
||||
@@ -500,17 +430,10 @@ impl HistoryDiff {
|
||||
for column in 0..3 {
|
||||
let mut segments = [None; 2];
|
||||
let mut final_segments = [false; 2];
|
||||
let side = usize::from(column != 1);
|
||||
if let Some(source) =
|
||||
self.columns[column].display_source_row(Self::focus(column), self.top.display)
|
||||
{
|
||||
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);
|
||||
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 self.wrapping[column] {
|
||||
let parts = self.wrap[column].segments(source, text);
|
||||
segments[side] = parts.get(self.top.continuation).copied();
|
||||
@@ -951,14 +874,13 @@ impl BufferViewports {
|
||||
history.widths = [0; 3];
|
||||
for &(column, pane, content) in panes.iter().flatten() {
|
||||
editor.with_render_pane(pane, |view| {
|
||||
let diff = &history.columns[column];
|
||||
let file = &diff.comparison.as_ref().unwrap().files[0];
|
||||
let projection = history.comparison.column(column);
|
||||
let width = usize::from(content.width);
|
||||
history.widths[column] = width.saturating_sub(diff_gutter(
|
||||
diff,
|
||||
file,
|
||||
projection.diff,
|
||||
projection.file,
|
||||
width,
|
||||
HistoryDiff::focus(column),
|
||||
projection.focus,
|
||||
));
|
||||
history.wrapping[column] = view.word_wrap();
|
||||
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)
|
||||
&& (history.caret != Some(identity) || previous_widths != history.widths)
|
||||
&& let Some(display) = history.comparison.column(column).display_row(cursor.row)
|
||||
{
|
||||
let diff = &history.columns[column];
|
||||
if let Some(display) =
|
||||
diff.source_display_row(HistoryDiff::focus(column), cursor.row)
|
||||
{
|
||||
let column_offset = display_column(&editor.lines()[cursor.row], cursor.column);
|
||||
let continuation = if history.wrapping[column] {
|
||||
caret_segment(
|
||||
history.wrap[column].segments(cursor.row, &editor.lines()[cursor.row]),
|
||||
column_offset,
|
||||
)
|
||||
} else {
|
||||
history.left[column] = scroll_offset(
|
||||
history.left[column],
|
||||
column_offset,
|
||||
history.widths[column],
|
||||
editor.scroll_distance(),
|
||||
);
|
||||
0
|
||||
};
|
||||
let caret = DiffWrapPosition {
|
||||
display,
|
||||
continuation,
|
||||
};
|
||||
let screen = history.visible[column]
|
||||
.iter()
|
||||
.position(|row| row.position == caret);
|
||||
let height = usize::from(content.height);
|
||||
let margin = scroll_margin(height, editor.scroll_distance());
|
||||
let anchor = if height == 0 {
|
||||
None
|
||||
} else if caret < history.top || screen.is_some_and(|row| row < margin) {
|
||||
Some(margin)
|
||||
} else if screen.is_none_or(|row| row >= height.saturating_sub(margin)) {
|
||||
Some(height.saturating_sub(margin + 1))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(anchor) = anchor {
|
||||
history.top = caret;
|
||||
for _ in 0..anchor {
|
||||
history.step(false);
|
||||
}
|
||||
let column_offset = display_column(&editor.lines()[cursor.row], cursor.column);
|
||||
let continuation = if history.wrapping[column] {
|
||||
caret_segment(
|
||||
history.wrap[column].segments(cursor.row, &editor.lines()[cursor.row]),
|
||||
column_offset,
|
||||
)
|
||||
} else {
|
||||
history.left[column] = scroll_offset(
|
||||
history.left[column],
|
||||
column_offset,
|
||||
history.widths[column],
|
||||
editor.scroll_distance(),
|
||||
);
|
||||
0
|
||||
};
|
||||
let caret = DiffWrapPosition {
|
||||
display,
|
||||
continuation,
|
||||
};
|
||||
let screen = history.visible[column]
|
||||
.iter()
|
||||
.position(|row| row.position == caret);
|
||||
let height = usize::from(content.height);
|
||||
let margin = scroll_margin(height, editor.scroll_distance());
|
||||
let anchor = if height == 0 {
|
||||
None
|
||||
} else if caret < history.top || screen.is_some_and(|row| row < margin) {
|
||||
Some(margin)
|
||||
} else if screen.is_none_or(|row| row >= height.saturating_sub(margin)) {
|
||||
Some(height.saturating_sub(margin + 1))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
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);
|
||||
}
|
||||
@@ -1051,12 +969,11 @@ impl BufferViewports {
|
||||
}
|
||||
for (column, pane, content) in panes.into_iter().flatten() {
|
||||
editor.with_render_pane(pane, |view| {
|
||||
let diff = &history.columns[column];
|
||||
let file = &diff.comparison.as_ref().unwrap().files[0];
|
||||
let projection = history.comparison.column(column);
|
||||
let syntax = self.syntax.entry(view.buffer_id()).or_default();
|
||||
let source_rows = history.visible[column].iter().filter_map(|row| {
|
||||
diff.display_source_row(HistoryDiff::focus(column), row.position.display)
|
||||
});
|
||||
let source_rows = history.visible[column]
|
||||
.iter()
|
||||
.filter_map(|row| projection.source_row(row.position.display));
|
||||
let first = source_rows
|
||||
.clone()
|
||||
.min()
|
||||
@@ -1074,10 +991,10 @@ impl BufferViewports {
|
||||
);
|
||||
let text_area = draw_diff_pane(
|
||||
frame,
|
||||
diff,
|
||||
file,
|
||||
projection.diff,
|
||||
projection.file,
|
||||
content,
|
||||
column == 1,
|
||||
projection.focus == Focus::Before,
|
||||
[&*syntax, &*syntax],
|
||||
[view.semantic_tokens(), view.semantic_tokens()],
|
||||
(history.top.display, &mut history.left[column], None),
|
||||
@@ -1095,9 +1012,9 @@ impl BufferViewports {
|
||||
self.panels.caret = paint_diff_editor(
|
||||
frame,
|
||||
view,
|
||||
diff,
|
||||
projection.diff,
|
||||
text_area,
|
||||
HistoryDiff::focus(column),
|
||||
projection.focus,
|
||||
(history.top.display, history.left[column]),
|
||||
Some(&history.visible[column]),
|
||||
)
|
||||
@@ -1194,8 +1111,9 @@ impl BufferViewports {
|
||||
} else {
|
||||
history.left[column].saturating_sub(3)
|
||||
};
|
||||
history.left[column] = history.columns[column].bound_horizontal(
|
||||
HistoryDiff::focus(column),
|
||||
let projection = history.comparison.column(column);
|
||||
history.left[column] = projection.diff.bound_horizontal(
|
||||
projection.focus,
|
||||
left,
|
||||
history.widths[column],
|
||||
);
|
||||
|
||||
@@ -4,6 +4,67 @@ std::thread_local! {
|
||||
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]
|
||||
fn resolver_equal_text_retains_native_rows_and_cached_viewport() {
|
||||
for text in ["", "\n", "same\n", "same"] {
|
||||
|
||||
Reference in New Issue
Block a user