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
+2
View File
@@ -80,6 +80,8 @@ Each comparison owns its split/unified mode and split axis; startup defaults app
The resolver owns a dedicated native tab with an editable Draft and frozen read-only Alternatives projected from the current raw working buffer. Projection replaces only conflict regions; automatically merged text and pre-existing outside edits remain identical across initial alternatives. Native undo includes initial seeding. Resolution reuses DiffState alignment, excerpts, highlighting and viewport mapping, including equal-text editing. A separate read-only History uses the original jj inputs/bases; native buffer identity and frontend offsets retain draft/view state. `resolve` reads all alternating Git-backed `jj:trees` terms. Background guarded saves retain checkout/disk validation and jj snapshot/status authority; no presentation change bypasses these write guards. The resolver owns a dedicated native tab with an editable Draft and frozen read-only Alternatives projected from the current raw working buffer. Projection replaces only conflict regions; automatically merged text and pre-existing outside edits remain identical across initial alternatives. Native undo includes initial seeding. Resolution reuses DiffState alignment, excerpts, highlighting and viewport mapping, including equal-text editing. A separate read-only History uses the original jj inputs/bases; native buffer identity and frontend offsets retain draft/view state. `resolve` reads all alternating Git-backed `jj:trees` terms. Background guarded saves retain checkout/disk validation and jj snapshot/status authority; no presentation change bypasses these write guards.
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.
`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.
Mouse wheel input uses the last rendered document/panel rectangles for hit-testing. Document offsets and explorer scroll state remain frontend-owned; three-row wheel steps never become document cursor motions. Manual document views persist until logical caret-reveal, caret/selection, mode, revision, or buffer changes; offscreen primary and browsing-selection cursors are not placed. Hover wheel input reuses the existing editor-owned popup scrolling. Mouse wheel input uses the last rendered document/panel rectangles for hit-testing. Document offsets and explorer scroll state remain frontend-owned; three-row wheel steps never become document cursor motions. Manual document views persist until logical caret-reveal, caret/selection, mode, revision, or buffer changes; offscreen primary and browsing-selection cursors are not placed. Hover wheel input reuses the existing editor-owned popup scrolling.
+2
View File
@@ -60,6 +60,8 @@ Conflicted-revision regressions preserve unchanged symlinks, defer oversized uns
Resolver checks use isolated real jj repositories: read-only discovery, marker-free all-side/base projections preserving outside edits and EOF, initial seed undo, hunk choice/undo, retained resolved files, jj-authoritative status, checkout/external-write refusal, cancellation, nontext bytes/types/modes/deletion, and directory-child protection. Render checks cover aligned gaps/highlights, context folding, native Draft focus/search/selection, separate History, hidden/on-demand explorer, viewport retention and narrow layouts. Live PTY smoke exercises frozen marker-free alternatives, History/Draft switching, hunk choice and guarded save confirmation without touching user repositories. Resolver checks use isolated real jj repositories: read-only discovery, marker-free all-side/base projections preserving outside edits and EOF, initial seed undo, hunk choice/undo, retained resolved files, jj-authoritative status, checkout/external-write refusal, cancellation, nontext bytes/types/modes/deletion, and directory-child protection. Render checks cover aligned gaps/highlights, context folding, native Draft focus/search/selection, separate History, hidden/on-demand explorer, viewport retention and narrow layouts. Live PTY smoke exercises frozen marker-free alternatives, History/Draft switching, hunk choice and guarded save confirmation without touching user repositories.
Shared-workspace parity checks compare ordinary and resolver diff geometry/content under identical chrome, focus and source cursors. Cover native picker selection/remaps, Tree/List and dock/floating actions, excerpt initialization, folded search, focus transfer on wheel, layout controls, loading/History/pane-rebuild transitions, and preserved per-file viewport state. Live PTY smoke uses Space e, Down/Enter file selection and guarded saving through the same explorer path.
Repository gutter checks cover LF/CRLF normalization, additions/replacements/deletion anchors, staged/disk/unsaved Git text, unborn and missing named files, ignored/unsupported paths, read-only colocated/non-colocated jj queries, stale results and per-buffer isolation, plus rendered modes, colors, current-line precedence, wrap/scroll and narrow/split geometry. Live PTY smoke verifies all four modes, edit/undo refresh, external Git HEAD changes, and jj parent changes without editor restart. Repository gutter checks cover LF/CRLF normalization, additions/replacements/deletion anchors, staged/disk/unsaved Git text, unborn and missing named files, ignored/unsupported paths, read-only colocated/non-colocated jj queries, stale results and per-buffer isolation, plus rendered modes, colors, current-line precedence, wrap/scroll and narrow/split geometry. Live PTY smoke verifies all four modes, edit/undo refresh, external Git HEAD changes, and jj parent changes without editor restart.
Normal-picker checks cover staged/disk/unsaved status, binary and mode changes, exact and ambiguous renames, deleted paths, unborn repositories, aliases and unusual filenames, ignores and safe roots, linked worktrees, and read-only colocated/non-colocated/conflicted jj parents. Catalog/render checks retain unchanged rows, query/selection/expansion and Tree/List navigation, clear stale metadata, restore recreated files, and sanitize rename labels. Live PTY smoke checks A/M/D/R colors in floating and docked pickers with the line gutter disabled, Git HEAD/jj parent refresh, and unchanged jj operation state during polling. Normal-picker checks cover staged/disk/unsaved status, binary and mode changes, exact and ambiguous renames, deleted paths, unborn repositories, aliases and unusual filenames, ignores and safe roots, linked worktrees, and read-only colocated/non-colocated/conflicted jj parents. Catalog/render checks retain unchanged rows, query/selection/expansion and Tree/List navigation, clear stale metadata, restore recreated files, and sanitize rename labels. Live PTY smoke checks A/M/D/R colors in floating and docked pickers with the line gutter disabled, Git HEAD/jj parent refresh, and unchanged jj operation state during polling.
+2 -2
View File
@@ -473,9 +473,9 @@ Run **`:resolve`** in Normal mode for the current jj working copy. It opens a de
| `resolve.history` / `resolve.view.toggle` | Show read-only input/base/input History / switch views | | `resolve.history` / `resolve.view.toggle` | Show read-only input/base/input History / switch views |
| `resolve.close` | Close the resolver without discarding dirty drafts | | `resolve.close` | Close the resolver without discarding dirty drafts |
**History** is separate from editing: it compares the original inputs and their base, with all sides and bases selectable for multi-sided conflicts. Draft text, undo, caret and viewport survive view switches. The file explorer is hidden by default and available on demand. Narrow Resolution panes retain side-by-side comparison where usable; narrow History shows the focused input. Native editing, copying, search and remapped pane navigation work normally; read-only Alternatives and History reject edits and saves. **History** is separate from editing: it compares the original inputs and their base, with all sides and bases selectable for multi-sided conflicts. Draft text, undo, caret and viewport survive view switches. Resolution uses the ordinary diff workspace, including its configured explorer, split/unified/orientation controls, source-line navigation, excerpts, focus and scrolling. Narrow History shows the focused input. Read-only Alternatives and History reject edits and saves.
Input indexes are one-based and include bases. Commands appear in the palette and through corresponding `config.core.resolve` actions; no new global default bindings are installed. Normal-mode Tab/Shift+Tab cycles visible panes and an open file list. In the list, `/` filters and Enter opens a file. The complete seeded Draft can be saved even when intentional differences remain against the Alternative: those differences are not unresolved conflict markers. There is no automatic “accept both”. Input indexes are one-based and include bases. Conflict-specific actions appear in the palette and through corresponding `config.core.resolve` methods. File navigation uses the existing picker/explorer actions: Space o opens floating search, Space e focuses the dock, Ctrl+t switches Tree/List, Ctrl+w closes it, and configured tree/selection bindings apply unchanged. Enter selects a relevant conflict file and focuses the editable Draft. Normal-mode Tab/Shift+Tab, native motions/search, context expansion and layout actions follow normal diff behavior. The complete seeded Draft can be saved even when intentional differences remain against the Alternative: those differences are not unresolved conflict markers. There is no automatic “accept both”.
**`:w` saves and checks jj**, asynchronously. Before writing, the resolver locks the working copy and validates the frozen checkout and exact on-disk file state. Checkout changes, external writes and unsafe filesystem ancestors refuse the write, including force-save. Successful writes trigger jj's normal snapshot operation, which may capture other on-disk changes. The list distinguishes unresolved, unsaved, checking, resolved and unconfirmed states. jj—not marker scanning or a manual checkbox—confirms resolution. A failed confirmation keeps saved contents and reports unconfirmed status. Resolved files remain in the session for review. Dirty drafts prevent destructive resolver/pane/tab closes. **`:w` saves and checks jj**, asynchronously. Before writing, the resolver locks the working copy and validates the frozen checkout and exact on-disk file state. Checkout changes, external writes and unsafe filesystem ancestors refuse the write, including force-save. Successful writes trigger jj's normal snapshot operation, which may capture other on-disk changes. The list distinguishes unresolved, unsaved, checking, resolved and unconfirmed states. jj—not marker scanning or a manual checkbox—confirms resolution. A failed confirmation keeps saved contents and reports unconfirmed status. Resolved files remain in the session for review. Dirty drafts prevent destructive resolver/pane/tab closes.
+116 -34
View File
@@ -258,6 +258,8 @@ pub struct DiffState {
pub view_mode: ViewMode, pub view_mode: ViewMode,
pub comparison_mode: ComparisonMode, pub comparison_mode: ComparisonMode,
pub split_axis: crate::panes::SplitAxis, pub split_axis: crate::panes::SplitAxis,
pub writable_side: Option<Focus>,
pub side_labels: [&'static str; 2],
pub before: Option<Revision>, pub before: Option<Revision>,
pub after: Option<Revision>, pub after: Option<Revision>,
pub category: usize, pub category: usize,
@@ -272,6 +274,8 @@ pub struct DiffState {
pub files: Option<FilePicker>, pub files: Option<FilePicker>,
pub files_visible: bool, pub files_visible: bool,
pub file: usize, pub file: usize,
external_file_selection: bool,
pub(crate) requested_file: Option<usize>,
pub focus: Focus, pub focus: Focus,
pub top: usize, pub top: usize,
pub left_before: usize, pub left_before: usize,
@@ -310,6 +314,8 @@ impl DiffState {
view_mode: ViewMode::Split, view_mode: ViewMode::Split,
comparison_mode: ComparisonMode::default(), comparison_mode: ComparisonMode::default(),
split_axis: crate::panes::SplitAxis::Vertical, split_axis: crate::panes::SplitAxis::Vertical,
writable_side: Some(Focus::After),
side_labels: ["Before", "After"],
before: None, before: None,
after: None, after: None,
category: 0, category: 0,
@@ -324,6 +330,8 @@ impl DiffState {
snapshot_roots: [None, None], snapshot_roots: [None, None],
files_visible: true, files_visible: true,
file: 0, file: 0,
external_file_selection: false,
requested_file: None,
focus: Focus::Files, focus: Focus::Files,
top: 0, top: 0,
left_before: 0, left_before: 0,
@@ -349,6 +357,42 @@ impl DiffState {
after_widths: Vec::new(), 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]) { pub(crate) fn set_resolver_text(&mut self, draft: &[String], alternative: &[String]) {
fn content(lines: &[String]) -> (&[String], bool) { fn content(lines: &[String]) -> (&[String], bool) {
@@ -369,47 +413,34 @@ impl DiffState {
} }
let (before, before_eof) = content(draft); let (before, before_eof) = content(draft);
let (after, after_eof) = content(alternative); let (after, after_eof) = content(alternative);
if self.comparison.as_ref().is_some_and(|comparison| { if self.phase == Phase::View
let file = &comparison.files[0]; && self.comparison.as_ref().is_some_and(|comparison| {
file.before == before let file = &comparison.files[self.file];
&& file.after == after file.before == before
&& file.before_eof == before_eof && file.after == after
&& file.after_eof == after_eof && file.before_eof == before_eof
}) { && file.after_eof == after_eof
})
{
return; return;
} }
let viewport = (self.top, self.left_before, self.left_after); 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.phase = Phase::View;
self.loading = false; self.loading = false;
self.pending = None; self.pending = None;
self.request_kind = 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.comparison_id = Some(self.comparison_id.unwrap_or(0).wrapping_add(1));
self.file = 0;
self.live_files.clear(); self.live_files.clear();
self.comparison = Some(Comparison { let file = &mut self.comparison.as_mut().unwrap().files[self.file];
before: "Draft".into(), file.before = before.to_vec();
after: "Alternative".into(), file.after = after.to_vec();
root: PathBuf::new(), file.before_eof = before_eof;
editable_paths: BTreeSet::new(), file.after_eof = after_eof;
files: vec![ChangedFile { file.hunks = line_hunks(before, before_eof, after, after_eof);
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(); self.align_file();
let before_terminal = (draft.len() > before.len()).then_some(before.len()); let before_terminal = (draft.len() > before.len()).then_some(before.len());
let after_terminal = (alternative.len() > after.len()).then_some(after.len()); let after_terminal = (alternative.len() > after.len()).then_some(after.len());
@@ -422,7 +453,10 @@ impl DiffState {
}); });
self.reset_excerpts(); 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.excerpt_ranges = vec![(0, self.rows.len())];
self.rebuild_display_rows(); self.rebuild_display_rows();
} }
@@ -1151,7 +1185,9 @@ impl DiffState {
if let Some(path) = files.accept() if let Some(path) = files.accept()
&& self.select_file(&path) && self.select_file(&path)
{ {
self.focus = Focus::After; self.focus = self
.writable_side
.unwrap_or_else(|| self.first_text_focus());
self.normalize_focus(); self.normalize_focus();
let files = self.files.as_mut().expect("comparison picker"); let files = self.files.as_mut().expect("comparison picker");
files.focused = false; 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 { pub fn after_editable(&self) -> bool {
matches!(self.pane_layout(), PaneLayout::Split | PaneLayout::After) matches!(self.pane_layout(), PaneLayout::Split | PaneLayout::After)
@@ -1435,6 +1483,10 @@ impl DiffState {
if let Some(files) = &mut self.files { if let Some(files) = &mut self.files {
files.reveal_path(&file.path); files.reveal_path(&file.path);
} }
if self.external_file_selection {
self.requested_file = Some(index);
return true;
}
self.file = index; self.file = index;
self.normalize_focus(); self.normalize_focus();
self.live_buffer = None; self.live_buffer = None;
@@ -2381,6 +2433,11 @@ mod tests {
state.set_resolver_text(&lines, &lines); state.set_resolver_text(&lines, &lines);
assert_eq!(state.pane_layout(), PaneLayout::Split); assert_eq!(state.pane_layout(), PaneLayout::Split);
assert!(state.comparison.as_ref().unwrap().files[0].hunks.is_empty()); 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() { for row in 0..lines.len() {
assert!(state.source_display_row(Focus::Before, row).is_some()); assert!(state.source_display_row(Focus::Before, row).is_some());
assert!(state.source_display_row(Focus::After, 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 { fn excerpt_state(len: usize, changed: &[usize]) -> DiffState {
let mut state = DiffState::new(); let mut state = DiffState::new();
state.phase = Phase::View; state.phase = Phase::View;
+619 -327
View File
File diff suppressed because it is too large Load Diff
-19
View File
@@ -611,25 +611,6 @@ impl EditorSession {
self.synchronize_documents(); self.synchronize_documents();
} }
pub(crate) fn set_resolver_viewport(
&mut self,
top: usize,
left_before: usize,
left_after: usize,
) {
self.editor
.set_resolver_viewport(top, left_before, left_after);
}
pub(crate) fn scroll_resolver(&mut self, focus: Focus, down: bool) {
self.editor.scroll_resolver_diff(focus, down);
}
pub(crate) fn scroll_resolver_horizontal(&mut self, focus: Focus, right: bool, width: usize) {
self.editor
.scroll_resolver_diff_horizontal(focus, right, width);
}
pub fn handle(&mut self, input: Input) { pub fn handle(&mut self, input: Input) {
if input == Input::Ctrl('r') if input == Input::Ctrl('r')
&& self && self
+489 -419
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -199,7 +199,7 @@ fn resolver_keeps_unsaved_result_and_undo_then_saves_and_retains_resolved_file()
let resolver = session.editor().resolver().unwrap(); let resolver = session.editor().resolver().unwrap();
assert_eq!(resolver.files.len(), 2); assert_eq!(resolver.files.len(), 2);
assert_eq!( assert_eq!(
resolver.files[resolver.selected_file].status, resolver.files[resolver.current_file].status,
ResolverStatus::Resolved ResolverStatus::Resolved
); );
assert_eq!( assert_eq!(