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:
+103
@@ -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;
|
||||
|
||||
+875
-171
File diff suppressed because it is too large
Load Diff
+5
-4
@@ -84,10 +84,11 @@ Diff: :diff (Normal); choose Before then After; Working checkout After edits liv
|
||||
Pull requests: :pull_requests [REMOTE]; GitHub/GitLab/Gitea Open/Closed/All tabs, type to search
|
||||
Enter opens the immutable PR head against its merge base; API auth uses provider tokens or gh/glab
|
||||
Resolve jj conflicts: :resolve opens the current working-copy conflicts in a dedicated workspace
|
||||
Native readonly inputs above editable Result; Tab focus, :resolve.source N / :resolve.base N
|
||||
:resolve.next/previous navigates regions; :resolve.use_source [N] chooses one region
|
||||
:w saves and checks jj status; :resolve.next_file/previous_file switches files
|
||||
:resolve.files focuses the list; :resolve.result focuses Result; :resolve.close closes cleanly
|
||||
Editable marker-free Draft vs readonly Alternative; :resolve.history shows inputs/base
|
||||
:resolve.next/previous navigates differing hunks; :resolve.use_source [N] takes a hunk
|
||||
:resolve.source N selects Alternative; :resolve.base N selects History base
|
||||
:resolve.draft returns to Draft; :resolve.view.toggle switches Resolution/History
|
||||
:w saves and checks jj; :resolve.files toggles the explorer; :resolve.close closes cleanly
|
||||
|
||||
Ruby config: $XDG_CONFIG_HOME/vamp/config.rb or ~/.config/vamp/config.rb
|
||||
Set VAMP_RUBY to choose the Ruby executable.
|
||||
|
||||
+213
-3
@@ -858,7 +858,7 @@ pub struct Region {
|
||||
pub bases: Vec<String>,
|
||||
}
|
||||
impl Region {
|
||||
pub fn choose(&self, source: &Source) -> Result<String, String> {
|
||||
fn selected(&self, source: &Source) -> Result<&str, String> {
|
||||
if !matches!(source.kind, FileKind::Text | FileKind::Deleted) {
|
||||
return Err("Non-text sources require an explicit whole-file choice".into());
|
||||
}
|
||||
@@ -870,7 +870,10 @@ impl Region {
|
||||
} else {
|
||||
&self.sides
|
||||
};
|
||||
terms.get(source.term).cloned().ok_or_else(|| "Conflict region no longer matches this source; edit it manually or choose a whole file".into())
|
||||
terms.get(source.term).map(String::as_str).ok_or_else(|| "Conflict region no longer matches this source; edit it manually or choose a whole file".into())
|
||||
}
|
||||
pub fn choose(&self, source: &Source) -> Result<String, String> {
|
||||
self.selected(source).map(str::to_owned)
|
||||
}
|
||||
}
|
||||
fn marker(line: &str) -> Option<(u8, usize)> {
|
||||
@@ -941,7 +944,7 @@ fn parse_region(lines: &[&str], width: usize) -> Option<(Vec<String>, Vec<String
|
||||
(sides.len() >= 2 && bases.len() + 1 == sides.len()).then_some((sides, bases))
|
||||
}
|
||||
/// Recognize jj snapshot/diff/diff-experimental and Git diff3 markers at any
|
||||
/// marker width. This is navigation/selection only; jj alone decides resolution.
|
||||
/// marker width. Recognition is local; jj alone decides saved resolution status.
|
||||
pub fn regions(text: &str) -> Vec<Region> {
|
||||
let lines = text.split_inclusive('\n').collect::<Vec<_>>();
|
||||
let mut regions: Vec<Region> = Vec::new();
|
||||
@@ -986,6 +989,66 @@ pub fn regions(text: &str) -> Vec<Region> {
|
||||
regions
|
||||
}
|
||||
|
||||
/// Marker-free working text and the original choices, never historical full files.
|
||||
/// Like jj-diffconflicts, replace marker sections in the working buffer, not
|
||||
/// entire branch snapshots: https://github.com/rafikdraoui/jj-diffconflicts.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Projection {
|
||||
seed_index: usize,
|
||||
alternatives: Vec<String>,
|
||||
}
|
||||
impl Projection {
|
||||
/// Freeze every choice from current working text. Indices are indices in
|
||||
/// `ConflictFile::sources`, including bases. Later draft edits are independent.
|
||||
pub fn new(raw: &str, sources: &[Source], seed_index: usize) -> Result<Self, String> {
|
||||
if seed_index >= sources.len() {
|
||||
return Err("No such conflict source".into());
|
||||
}
|
||||
let mut offsets = vec![0];
|
||||
for line in raw.split_inclusive('\n') {
|
||||
offsets.push(offsets.last().unwrap() + line.len());
|
||||
}
|
||||
let mut alternatives = sources
|
||||
.iter()
|
||||
.map(|_| String::with_capacity(raw.len()))
|
||||
.collect::<Vec<_>>();
|
||||
let mut end = 0;
|
||||
for region in regions(raw) {
|
||||
let outside = &raw[end..offsets[region.lines.start]];
|
||||
for (alternative, source) in alternatives.iter_mut().zip(sources) {
|
||||
alternative.push_str(outside);
|
||||
alternative.push_str(region.selected(source)?);
|
||||
}
|
||||
end = offsets[region.lines.end];
|
||||
}
|
||||
for alternative in &mut alternatives {
|
||||
alternative.push_str(&raw[end..]);
|
||||
}
|
||||
Ok(Self {
|
||||
seed_index,
|
||||
alternatives,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn seed(&self) -> &str {
|
||||
&self.alternatives[self.seed_index]
|
||||
}
|
||||
|
||||
pub fn alternative(&self, index: usize) -> Result<&str, String> {
|
||||
self.alternatives
|
||||
.get(index)
|
||||
.map(String::as_str)
|
||||
.ok_or_else(|| "No such conflict source".into())
|
||||
}
|
||||
}
|
||||
|
||||
/// Project the selected side/base through current materialized working text.
|
||||
/// Bytes outside recognized marker regions, including the final EOL, are retained.
|
||||
pub fn project(text: &str, source: &Source) -> Result<String, String> {
|
||||
Projection::new(text, std::slice::from_ref(source), 0)
|
||||
.map(|mut projection| projection.alternatives.pop().unwrap())
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1315,3 +1378,150 @@ mod tests {
|
||||
assert!(regions("<<<<<<< unfinished\nleft\n").is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod projection_tests {
|
||||
use super::*;
|
||||
|
||||
fn test_sources(sides: usize) -> Vec<Source> {
|
||||
(0..sides * 2 - 1)
|
||||
.map(|index| Source {
|
||||
label: format!("source {index}"),
|
||||
base: index % 2 == 1,
|
||||
kind: FileKind::Text,
|
||||
bytes: b"historical full file; must never replace working text\n".to_vec(),
|
||||
tree_id: String::new(),
|
||||
term: index / 2,
|
||||
sides,
|
||||
bases: sides - 1,
|
||||
node: Node::Absent,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn snapshot(left: &str, base: &str, right: &str) -> String {
|
||||
format!(
|
||||
"<<<<<<< conflict\n+++++++ side 1\n{left}------- base\n{base}+++++++ side 2\n{right}>>>>>>> conflict ends\n"
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_working_projections_keep_partial_resolutions_and_multiple_regions() {
|
||||
let sources = test_sources(2);
|
||||
let raw = format!(
|
||||
"unsaved heading\nalready manually resolved\n{}auto-merged middle\n<<<<<<< conflict\n%%%%%%% Changes from base to side 1\n\\\\\\\\\\\\\\ continuation\n-old two\n+left two\n+++++++ side 2\nright two\n>>>>>>> conflict ends\nunsaved tail",
|
||||
snapshot("left one\n", "old one\n", "right one\n")
|
||||
);
|
||||
for (index, first, second) in [
|
||||
(0, "left one", "left two"),
|
||||
(1, "old one", "old two"),
|
||||
(2, "right one", "right two"),
|
||||
] {
|
||||
assert_eq!(
|
||||
project(&raw, &sources[index]).unwrap(),
|
||||
format!(
|
||||
"unsaved heading\nalready manually resolved\n{first}\nauto-merged middle\n{second}\nunsaved tail"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alternatives_are_frozen_from_the_same_working_text_as_the_seed() {
|
||||
let sources = test_sources(2);
|
||||
let raw = format!(
|
||||
"unsaved heading\n{}auto-merged middle\n{}unsaved tail",
|
||||
snapshot("left-one\n", "old-one\n", "right-one\n"),
|
||||
snapshot("left-two\n", "old-two\n", "right-two\n")
|
||||
);
|
||||
let projection = Projection::new(&raw, &sources, 0).unwrap();
|
||||
let mut draft = projection.seed().to_owned();
|
||||
draft = draft.replace("unsaved heading", "later draft edit");
|
||||
draft.push_str("\nnew draft ending\n");
|
||||
assert_eq!(
|
||||
projection.alternative(2).unwrap(),
|
||||
"unsaved heading\nright-one\nauto-merged middle\nright-two\nunsaved tail"
|
||||
);
|
||||
assert_eq!(
|
||||
projection.alternative(1).unwrap(),
|
||||
"unsaved heading\nold-one\nauto-merged middle\nold-two\nunsaved tail"
|
||||
);
|
||||
assert_eq!(
|
||||
projection.seed(),
|
||||
"unsaved heading\nleft-one\nauto-merged middle\nleft-two\nunsaved tail"
|
||||
);
|
||||
assert_eq!(
|
||||
draft,
|
||||
"later draft edit\nleft-one\nauto-merged middle\nleft-two\nunsaved tail\nnew draft ending\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_multiway_sides_and_bases_project_the_same_current_context() {
|
||||
let sources = test_sources(3);
|
||||
let raw = "unsaved α\n<<<<<<<<<<< conflict\n+++++++++++ side 1\none\n----------- base 1\nold one\n+++++++++++ side 2\ntwo\n----------- base 2\nold two\n+++++++++++ side 3\nthree\n>>>>>>>>>>> conflict ends\nunsaved ω";
|
||||
let projection = Projection::new(raw, &sources, 0).unwrap();
|
||||
for (index, choice) in ["one", "old one", "two", "old two", "three"]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let expected = format!("unsaved α\n{choice}\nunsaved ω");
|
||||
assert_eq!(project(raw, &sources[index]).unwrap(), expected);
|
||||
assert_eq!(projection.alternative(index).unwrap(), expected);
|
||||
}
|
||||
assert_eq!(
|
||||
Projection::new(raw, &sources, 4).unwrap().seed(),
|
||||
"unsaved α\nthree\nunsaved ω"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projections_preserve_mixed_eof_and_crlf_terms() {
|
||||
let sources = test_sources(2);
|
||||
for eol in ["\n", "\r\n"] {
|
||||
let choices = [
|
||||
format!("left{eol}"),
|
||||
"old".to_owned(),
|
||||
format!("right{eol}{eol}"),
|
||||
];
|
||||
// jj appends one separator EOL to *each* term when any lacks an EOL,
|
||||
// and omits the EOL on the final marker. Existing term EOLs remain.
|
||||
let raw = format!(
|
||||
"heading{eol}<<<<<<< conflict{eol}+++++++ side 1{eol}{}{eol}------- base{eol}{}{eol}+++++++ side 2{eol}{}{eol}>>>>>>> conflict ends",
|
||||
choices[0], choices[1], choices[2]
|
||||
);
|
||||
let projection = Projection::new(&raw, &sources, 0).unwrap();
|
||||
for (index, choice) in choices.iter().enumerate() {
|
||||
assert_eq!(
|
||||
project(&raw, &sources[index]).unwrap(),
|
||||
format!("heading{eol}{choice}")
|
||||
);
|
||||
assert_eq!(
|
||||
projection.alternative(index).unwrap(),
|
||||
format!("heading{eol}{choice}")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_choices_markerfree_text_and_invalid_choices_are_handled() {
|
||||
let mut sources = test_sources(2);
|
||||
sources[1].kind = FileKind::Deleted;
|
||||
let raw = format!("before\n{}after\n", snapshot("", "", "new\n"));
|
||||
let projection = Projection::new(&raw, &sources, 0).unwrap();
|
||||
assert_eq!(projection.seed(), "before\nafter\n");
|
||||
assert_eq!(projection.alternative(1).unwrap(), "before\nafter\n");
|
||||
assert_eq!(projection.alternative(2).unwrap(), "before\nnew\nafter\n");
|
||||
let resolved = Projection::new("manually resolved, no final EOL", &sources, 0).unwrap();
|
||||
assert_eq!(
|
||||
resolved.alternative(2).unwrap(),
|
||||
"manually resolved, no final EOL"
|
||||
);
|
||||
assert!(resolved.alternative(sources.len()).is_err());
|
||||
assert!(Projection::new(&raw, &sources, sources.len()).is_err());
|
||||
assert!(Projection::new(&raw, &test_sources(3), 0).is_err());
|
||||
sources[0].kind = FileKind::Binary;
|
||||
assert!(project(&raw, &sources[0]).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -611,6 +611,25 @@ impl EditorSession {
|
||||
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) {
|
||||
if input == Input::Ctrl('r')
|
||||
&& self
|
||||
@@ -904,6 +923,7 @@ impl EditorSession {
|
||||
}
|
||||
|
||||
fn synchronize_documents(&mut self) -> bool {
|
||||
self.editor.refresh_resolver_comparison();
|
||||
let blame_changed = self.poll_blame();
|
||||
// Leaving a request's source invalidates it permanently, even if history returns there.
|
||||
if let Some(requests) = self.requests.get_mut("core")
|
||||
|
||||
+1039
-222
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user