Files
Valdemar Lorenzen 168e12160a 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.
2026-10-15 10:21:10 +02:00

276 lines
8.5 KiB
Rust
Executable File

#![cfg(unix)]
use std::{
fs,
path::PathBuf,
process::Command,
sync::atomic::{AtomicU64, Ordering},
thread,
time::{Duration, Instant},
};
use vamp::{
editor::{Input, ResolverStatus},
session::EditorSession,
};
static NEXT: AtomicU64 = AtomicU64::new(0);
struct Fixture {
root: PathBuf,
base: String,
}
impl Fixture {
fn jj(&self, args: &[&str]) -> String {
let result = Command::new("jj")
.args(["--no-pager", "--color=never"])
.args(args)
.current_dir(&self.root)
.env("JJ_CONFIG", "/dev/null")
.env("JJ_USER", "Resolver Test")
.env("JJ_EMAIL", "resolver@example.invalid")
.env("JJ_TIMESTAMP", "2000-01-01T00:00:00Z")
.env("GIT_CONFIG_NOSYSTEM", "1")
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.output()
.unwrap();
assert!(
result.status.success(),
"{}",
String::from_utf8_lossy(&result.stderr)
);
String::from_utf8(result.stdout).unwrap().trim().into()
}
fn new() -> Self {
let root = std::env::temp_dir().join(format!(
"vamp-resolve-session-{}-{}",
std::process::id(),
NEXT.fetch_add(1, Ordering::Relaxed)
));
fs::create_dir(&root).unwrap();
let mut fixture = Self {
root,
base: String::new(),
};
fixture.jj(&["git", "init", "--no-colocate", "."]);
for name in ["first.txt", "second.txt"] {
fs::write(fixture.root.join(name), Self::text("base")).unwrap();
}
fixture.jj(&["describe", "-m", "base"]);
fixture.base = fixture.jj(&["log", "--no-graph", "-r", "@", "-T", "commit_id"]);
fixture.jj(&["new", &fixture.base]);
for name in ["first.txt", "second.txt"] {
fs::write(fixture.root.join(name), Self::text("left")).unwrap();
}
fixture.jj(&["describe", "-m", "left"]);
let left = fixture.jj(&["log", "--no-graph", "-r", "@", "-T", "commit_id"]);
fixture.jj(&["new", &fixture.base]);
for name in ["first.txt", "second.txt"] {
fs::write(fixture.root.join(name), Self::text("right")).unwrap();
}
fixture.jj(&["describe", "-m", "right"]);
let right = fixture.jj(&["log", "--no-graph", "-r", "@", "-T", "commit_id"]);
fixture.jj(&["new", &left, &right, "-m", "conflicted result"]);
fixture
}
fn text(side: &str) -> String {
format!(
"{side} first\n{}{side} last\n",
(0..30)
.map(|n| format!("context_{n}\n"))
.collect::<String>()
)
}
fn open(&self) -> EditorSession {
let config = self.root.join("test-config.rb");
fs::write(
&config,
"Vamp.configure do |config|\n config.clipboard_mode = 'separate'\nend\n",
)
.unwrap();
EditorSession::open(
Some(self.root.join("first.txt")),
vec!["text".into()],
Some(&config),
)
.unwrap()
}
}
impl Drop for Fixture {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.root);
}
}
fn ready(session: &mut EditorSession) {
let deadline = Instant::now() + Duration::from_secs(15);
loop {
session.poll();
if session.editor().resolver().is_some_and(|resolver| {
!resolver.loading
&& resolver
.files
.iter()
.all(|file| file.status != ResolverStatus::Checking)
}) {
return;
}
assert!(
Instant::now() < deadline,
"resolver did not settle: {}",
session.editor().message()
);
thread::sleep(Duration::from_millis(1));
}
}
#[test]
fn resolver_keeps_unsaved_result_and_undo_then_saves_and_retains_resolved_file() {
let fixture = Fixture::new();
let original_disk = fs::read_to_string(fixture.root.join("first.txt")).unwrap();
let mut session = fixture.open();
for input in [
Input::Char('i'),
Input::Paste("draft\n".into()),
Input::Escape,
] {
session.handle(input);
}
let draft = session.editor().context().text;
let operation = fixture.jj(&[
"--ignore-working-copy",
"op",
"log",
"--no-graph",
"--limit",
"1",
"-T",
"id",
]);
session.execute("resolve");
ready(&mut session);
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
);
assert_eq!(
fixture.jj(&[
"--ignore-working-copy",
"op",
"log",
"--no-graph",
"--limit",
"1",
"-T",
"id"
]),
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()
.enumerate()
.filter(|(_, source)| !source.base)
.nth(1)
.unwrap()
.0
+ 1;
session.execute(&format!("resolve.use_source {source}"));
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, seeded);
session.execute(&format!("resolve.use_source {source}"));
let cursor = session.editor().cursor();
let resolved_text = session.editor().context().text;
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();
assert_eq!(resolver.files.len(), 2);
assert_eq!(
resolver.files[resolver.current_file].status,
ResolverStatus::Resolved
);
assert_eq!(
fs::read_to_string(fixture.root.join("first.txt")).unwrap(),
resolved_text
);
assert!(
!fixture
.jj(&["--ignore-working-copy", "resolve", "--list"])
.contains("first.txt")
);
assert!(
fixture
.jj(&["--ignore-working-copy", "resolve", "--list"])
.contains("second.txt")
);
}
#[test]
fn resolver_refuses_save_after_checkout_change_even_when_forced_and_preserves_draft() {
let fixture = Fixture::new();
let mut session = fixture.open();
session.execute("resolve");
ready(&mut session);
for input in [
Input::Char('i'),
Input::Paste("keep my draft\n".into()),
Input::Escape,
] {
session.handle(input);
}
let draft = session.editor().context().text;
fixture.jj(&["new", &fixture.base]);
let checkout_disk = fs::read_to_string(fixture.root.join("first.txt")).unwrap();
for save in ["w", "w!"] {
session.execute(save);
ready(&mut session);
assert_eq!(
fs::read_to_string(fixture.root.join("first.txt")).unwrap(),
checkout_disk
);
assert_eq!(session.editor().context().text, draft);
assert!(session.editor().dirty());
}
session.execute("resolve.close");
assert_eq!(session.editor().context().text, draft);
assert!(session.editor().dirty());
}
#[test]
fn resolver_save_preserves_external_writes_and_keeps_the_unsaved_result() {
let fixture = Fixture::new();
let mut session = fixture.open();
session.execute("resolve");
ready(&mut session);
for input in [
Input::Char('i'),
Input::Paste("local draft\n".into()),
Input::Escape,
] {
session.handle(input);
}
let draft = session.editor().context().text;
fs::write(fixture.root.join("first.txt"), "external edit\n").unwrap();
session.execute("w");
ready(&mut session);
assert_eq!(
fs::read_to_string(fixture.root.join("first.txt")).unwrap(),
"external edit\n"
);
assert_eq!(session.editor().context().text, draft);
assert!(session.editor().dirty());
}