feat: resolve conflicts through focused two-way drafts

Replace the marker-filled three-pane resolver with a marker-free native
Draft versus frozen Alternative projected from current working text.
Preserve preexisting outside edits, native undo, dirty-save guards, all
sides/bases, and draft caret/viewports across separate History views.
Reuse DiffState alignment/highlighting/excerpts for Resolution and merge
its base-aligned rows for readonly three-way History. Hide explorer and
source-selector chrome by default.

Verified 926 tests plus actual terminal projection, History highlighting,
hunk choice, optional explorer, and guarded jj-confirmed save workflows.
Clippy completes with pre-existing warnings only. Smoke fixtures stay
unresolved in this ancestry; remove that ancestry before publication.

Size exception: approximately 1650 implementation added/deleted lines,
excluding tests/docs, kept coherent because native draft lifecycle and
aligned rendering must change together without parallel legacy modes.
This commit is contained in:
Valdemar Lorenzen
2026-10-14 16:40:23 +02:00
parent f2d505b952
commit ef4c38d8a7
12 changed files with 2292 additions and 421 deletions
+103
View File
@@ -350,6 +350,87 @@ impl DiffState {
}
}
pub(crate) fn set_resolver_text(&mut self, draft: &[String], alternative: &[String]) {
fn content(lines: &[String]) -> (&[String], bool) {
let eof = lines.len() > 1 && lines.last().is_some_and(String::is_empty);
let content = if eof {
&lines[..lines.len() - 1]
} else {
lines
};
(
if !eof && content.len() == 1 && content[0].is_empty() {
&[]
} else {
content
},
eof,
)
}
let (before, before_eof) = content(draft);
let (after, after_eof) = content(alternative);
if self.comparison.as_ref().is_some_and(|comparison| {
let file = &comparison.files[0];
file.before == before
&& file.after == after
&& file.before_eof == before_eof
&& file.after_eof == after_eof
}) {
return;
}
let viewport = (self.top, self.left_before, self.left_after);
self.phase = Phase::View;
self.loading = false;
self.pending = None;
self.request_kind = None;
self.files_visible = false;
self.comparison_id = Some(self.comparison_id.unwrap_or(0).wrapping_add(1));
self.file = 0;
self.live_files.clear();
self.comparison = Some(Comparison {
before: "Draft".into(),
after: "Alternative".into(),
root: PathBuf::new(),
editable_paths: BTreeSet::new(),
files: vec![ChangedFile {
path: PathBuf::new(),
old_path: None,
status: FileStatus::Modified,
loaded: true,
before: before.to_vec(),
after: after.to_vec(),
before_eof,
after_eof,
hunks: line_hunks(before, before_eof, after, after_eof),
notice: None,
}],
});
self.focus = if self.focus == Focus::After {
Focus::After
} else {
Focus::Before
};
self.align_file();
let before_terminal = (draft.len() > before.len()).then_some(before.len());
let after_terminal = (alternative.len() > after.len()).then_some(after.len());
if before_terminal.is_some() || after_terminal.is_some() {
self.rows.push(AlignedRow {
before: before_terminal,
after: after_terminal,
kind: RowKind::Equal,
changes: Vec::new(),
});
self.reset_excerpts();
}
if self.comparison.as_ref().unwrap().files[0].hunks.is_empty() {
self.excerpt_ranges = vec![(0, self.rows.len())];
self.rebuild_display_rows();
}
self.top = viewport.0.min(self.display_rows.len().saturating_sub(1));
self.left_before = viewport.1.min(self.max_before);
self.left_after = viewport.2.min(self.max_after);
}
pub(crate) fn pull_requests(remote: String) -> Self {
Self {
pull_request_remote: Some(remote.clone()),
@@ -2291,6 +2372,28 @@ mod tests {
pub(super) static WIDTH_SCANS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
#[test]
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);
assert_eq!(state.pane_layout(), PaneLayout::Split);
assert!(state.comparison.as_ref().unwrap().files[0].hunks.is_empty());
for row in 0..lines.len() {
assert!(state.source_display_row(Focus::Before, row).is_some());
assert!(state.source_display_row(Focus::After, row).is_some());
}
let id = state.comparison_id;
state.top = 1;
state.left_before = 3;
state.left_after = 4;
state.set_resolver_text(&lines, &lines);
assert_eq!(state.comparison_id, id);
assert_eq!((state.top, state.left_before, state.left_after), (1, 3, 4));
}
}
fn excerpt_state(len: usize, changed: &[usize]) -> DiffState {
let mut state = DiffState::new();
state.phase = Phase::View;