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
+22 -9
View File
@@ -146,7 +146,10 @@ fn resolver_keeps_unsaved_result_and_undo_then_saves_and_retains_resolved_file()
]);
session.execute("resolve");
ready(&mut session);
assert_eq!(session.editor().context().text, draft);
let seeded = format!("draft\n{}", Fixture::text("left"));
assert_eq!(session.editor().context().text, seeded);
assert_ne!(seeded, draft);
assert!(session.editor().dirty());
assert_eq!(
fs::read_to_string(fixture.root.join("first.txt")).unwrap(),
original_disk
@@ -164,23 +167,33 @@ fn resolver_keeps_unsaved_result_and_undo_then_saves_and_retains_resolved_file()
]),
operation
);
session.handle(Input::Char('u'));
assert_eq!(session.editor().context().text, draft);
session.handle(Input::Char('U'));
assert_eq!(session.editor().context().text, seeded);
let source = session.editor().resolver().unwrap().files[0]
.sources
.iter()
.position(|source| !source.base)
.enumerate()
.filter(|(_, source)| !source.base)
.nth(1)
.unwrap()
.0
+ 1;
session.execute(&format!("resolve.use_source {source}"));
assert_ne!(session.editor().context().text, draft);
assert!(session.editor().context().text.starts_with("draft\n"));
let chosen = session.editor().context().text;
assert_ne!(chosen, seeded);
assert!(chosen.starts_with("draft\n"));
assert!(chosen.contains("context_15\n"));
session.handle(Input::Char('u'));
assert_eq!(session.editor().context().text, draft);
session.execute(&format!("resolve.use_source {source}"));
session.execute("resolve.next");
assert_eq!(session.editor().context().text, seeded);
session.execute(&format!("resolve.use_source {source}"));
let cursor = session.editor().cursor();
let resolved_text = session.editor().context().text;
assert!(resolved_text.starts_with("draft\n"));
assert!(resolved_text.contains("context_15\n"));
session.execute("resolve.view.toggle");
session.execute("resolve.view.toggle");
assert_eq!(session.editor().context().text, resolved_text);
assert_eq!(session.editor().cursor(), cursor);
session.execute("w");
ready(&mut session);
let resolver = session.editor().resolver().unwrap();