refactor: route conflict resolution through the diff workspace

Use the active DiffState, native FilePicker and existing diff input,
layout, focus, source navigation and scrolling paths for Resolution.
Delete separate resolver filtering, file-list rendering and scroll APIs.
Keep projections, source choices, History and guarded jj saves distinct.

Verify native parity for picker remaps, current-file selection, excerpt
initialization, folded search, wheel focus, layouts, buffer transitions
and per-file viewports. All 932 tests pass; actual terminal smoke covers
Space-e explorer, Down/Enter file selection and guarded confirmed save.
Clippy completes with existing warnings only.

Size exception: 1197 implementation added/deleted lines excluding tests
and docs; input routing, native buffer lifecycle and rendering are kept
as one coherent cutover, without a parallel legacy resolver path.
This commit is contained in:
Valdemar Lorenzen
2026-10-15 09:53:45 +02:00
parent 4aff22377f
commit 168e12160a
8 changed files with 1231 additions and 802 deletions
+116 -34
View File
@@ -258,6 +258,8 @@ pub struct DiffState {
pub view_mode: ViewMode,
pub comparison_mode: ComparisonMode,
pub split_axis: crate::panes::SplitAxis,
pub writable_side: Option<Focus>,
pub side_labels: [&'static str; 2],
pub before: Option<Revision>,
pub after: Option<Revision>,
pub category: usize,
@@ -272,6 +274,8 @@ pub struct DiffState {
pub files: Option<FilePicker>,
pub files_visible: bool,
pub file: usize,
external_file_selection: bool,
pub(crate) requested_file: Option<usize>,
pub focus: Focus,
pub top: usize,
pub left_before: usize,
@@ -310,6 +314,8 @@ impl DiffState {
view_mode: ViewMode::Split,
comparison_mode: ComparisonMode::default(),
split_axis: crate::panes::SplitAxis::Vertical,
writable_side: Some(Focus::After),
side_labels: ["Before", "After"],
before: None,
after: None,
category: 0,
@@ -324,6 +330,8 @@ impl DiffState {
snapshot_roots: [None, None],
files_visible: true,
file: 0,
external_file_selection: false,
requested_file: None,
focus: Focus::Files,
top: 0,
left_before: 0,
@@ -349,6 +357,42 @@ impl DiffState {
after_widths: Vec::new(),
}
}
pub(crate) fn set_resolver_paths(&mut self, root: PathBuf, paths: Vec<PathBuf>, file: usize) {
let comparison = self.comparison.get_or_insert_with(|| Comparison {
before: "Draft".into(),
after: "Alternative".into(),
files: Vec::new(),
root: root.clone(),
editable_paths: BTreeSet::new(),
});
comparison.root = root.clone();
comparison.files.resize_with(paths.len(), || ChangedFile {
path: PathBuf::new(),
old_path: None,
status: FileStatus::Modified,
loaded: true,
before: Vec::new(),
after: Vec::new(),
before_eof: false,
after_eof: false,
hunks: Vec::new(),
notice: None,
});
for (entry, path) in comparison.files.iter_mut().zip(&paths) {
entry.path.clone_from(path);
}
self.file = file;
self.external_file_selection = true;
self.requested_file = None;
if self.files.is_none() {
self.files = Some(FilePicker::from_paths(root, paths.into_iter().enumerate()));
} else if let Some(files) = &mut self.files {
files.replace_paths(paths.into_iter().enumerate());
}
if let Some(files) = &mut self.files {
files.reveal_path(&comparison.files[file].path);
}
}
pub(crate) fn set_resolver_text(&mut self, draft: &[String], alternative: &[String]) {
fn content(lines: &[String]) -> (&[String], bool) {
@@ -369,47 +413,34 @@ impl DiffState {
}
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
}) {
if self.phase == Phase::View
&& self.comparison.as_ref().is_some_and(|comparison| {
let file = &comparison.files[self.file];
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);
if self.comparison.is_none() {
self.set_resolver_paths(PathBuf::new(), vec![PathBuf::from("resolution.txt")], 0);
}
self.phase = Phase::View;
self.loading = false;
self.pending = None;
self.request_kind = None;
self.files_visible = false;
self.side_labels = ["Draft", "Alternative"];
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
};
let file = &mut self.comparison.as_mut().unwrap().files[self.file];
file.before = before.to_vec();
file.after = after.to_vec();
file.before_eof = before_eof;
file.after_eof = after_eof;
file.hunks = line_hunks(before, before_eof, after, after_eof);
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());
@@ -422,7 +453,10 @@ impl DiffState {
});
self.reset_excerpts();
}
if self.comparison.as_ref().unwrap().files[0].hunks.is_empty() {
if self.comparison.as_ref().unwrap().files[self.file]
.hunks
.is_empty()
{
self.excerpt_ranges = vec![(0, self.rows.len())];
self.rebuild_display_rows();
}
@@ -1151,7 +1185,9 @@ impl DiffState {
if let Some(path) = files.accept()
&& self.select_file(&path)
{
self.focus = Focus::After;
self.focus = self
.writable_side
.unwrap_or_else(|| self.first_text_focus());
self.normalize_focus();
let files = self.files.as_mut().expect("comparison picker");
files.focused = false;
@@ -1351,6 +1387,18 @@ impl DiffState {
}
}
}
pub fn side_label(&self, focus: Focus) -> &'static str {
self.side_labels[usize::from(focus == Focus::After)]
}
pub fn side_editable(&self, focus: Focus) -> bool {
self.writable_side == Some(focus)
&& match focus {
Focus::Before => self.selected_file_loaded(),
Focus::After => self.after_editable(),
Focus::Files => false,
}
}
pub fn after_editable(&self) -> bool {
matches!(self.pane_layout(), PaneLayout::Split | PaneLayout::After)
@@ -1435,6 +1483,10 @@ impl DiffState {
if let Some(files) = &mut self.files {
files.reveal_path(&file.path);
}
if self.external_file_selection {
self.requested_file = Some(index);
return true;
}
self.file = index;
self.normalize_focus();
self.live_buffer = None;
@@ -2381,6 +2433,11 @@ mod tests {
state.set_resolver_text(&lines, &lines);
assert_eq!(state.pane_layout(), PaneLayout::Split);
assert!(state.comparison.as_ref().unwrap().files[0].hunks.is_empty());
assert_eq!(
state.comparison.as_ref().unwrap().files[0].path,
Path::new("resolution.txt")
);
assert_eq!(state.files.as_ref().unwrap().entries().len(), 1);
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());
@@ -2395,6 +2452,31 @@ mod tests {
}
}
#[test]
fn resolver_catalog_reveals_the_current_native_file() {
let mut state = DiffState::new();
state.set_resolver_paths(
PathBuf::new(),
vec!["binary.bin".into(), "first.txt".into(), "second.txt".into()],
1,
);
let files = state.files.as_ref().unwrap();
assert_eq!(
files.entries().nth(files.selected).unwrap().path,
Path::new("first.txt")
);
state.set_resolver_paths(
PathBuf::new(),
vec!["binary.bin".into(), "first.txt".into(), "second.txt".into()],
2,
);
let files = state.files.as_ref().unwrap();
assert_eq!(
files.entries().nth(files.selected).unwrap().path,
Path::new("second.txt")
);
}
fn excerpt_state(len: usize, changed: &[usize]) -> DiffState {
let mut state = DiffState::new();
state.phase = Phase::View;