diff --git a/CONTEXT.md b/CONTEXT.md index 135e00d..4f35fb2 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -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. diff --git a/docs/architecture.md b/docs/architecture.md index 7f84761..a15fdc9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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. diff --git a/src/diff.rs b/src/diff.rs index 3fac378..618713f 100644 --- a/src/diff.rs +++ b/src/diff.rs @@ -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 { + self.diff.display_source_row(self.focus, display) + } + + pub fn display_row(&self, source: usize) -> Option { + 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::>()); + 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, diff --git a/src/tui.rs b/src/tui.rs index 5300ecd..581cdf4 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -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 { } 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::>() }; - 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], ); diff --git a/tests/unit/diff.rs b/tests/unit/diff.rs index d8cbc8f..cd18c4d 100644 --- a/tests/unit/diff.rs +++ b/tests/unit/diff.rs @@ -4,6 +4,67 @@ std::thread_local! { pub(super) static WIDTH_SCANS: std::cell::Cell = 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"] {