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
+213 -3
View File
@@ -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());
}
}