feat: resolve current jj working-copy conflicts

Add a dedicated native resolver workspace with all side/base inputs,
shared editable Results, region choices, retained per-file status,
responsive rendering, and explicit nontext file choices.

Guard writes with jj working-copy locking, frozen checkout identity,
exact disk baselines, native dirty-buffer protection, and asynchronous
jj snapshot/status confirmation. Historical comparisons remain readonly.

Verified all 916 tests and live isolated terminal workflows covering text
regions, narrow layouts, binary bytes and symlink targets/types.
Clippy completes with pre-existing warnings only.

Size exception: 2614 implementation additions/deletions excluding tests
and docs. Native workspace integration, explicit file choices and the
safe save/check operation are retained as one complete behavioral slice.
This commit is contained in:
Valdemar Lorenzen
2026-10-14 15:49:26 +02:00
parent dcfa50319c
commit a3aef713e2
13 changed files with 3591 additions and 20 deletions
+172 -1
View File
@@ -14,7 +14,10 @@ use std::{
use crate::{
blame::BlameWorker,
diff::{Focus, Operation as DiffOperation, Outcome as DiffOutcome},
editor::{BlameSource, Cursor, Editor, Effect, Input, Invocation, Mode},
editor::{
BlameSource, Cursor, Editor, Effect, Input, Invocation, Mode, ResolverOutcome,
ResolverRequest,
},
lsp::Lsp,
plugin::{
DocumentEvent, DocumentEventKind, DocumentSnapshot, PluginEvent, PluginUpdate,
@@ -26,6 +29,72 @@ use crate::{
type DiffResult = (u64, Result<DiffOutcome, String>);
struct ResolverWorker {
requests: Option<Sender<ResolverRequest>>,
results: Receiver<ResolverOutcome>,
stopped: Arc<AtomicBool>,
thread: Option<JoinHandle<()>>,
}
impl ResolverWorker {
fn start() -> Result<Self, String> {
let (requests, incoming) = mpsc::channel::<ResolverRequest>();
let (outgoing, results) = mpsc::channel();
let stopped = Arc::new(AtomicBool::new(false));
let stop = Arc::clone(&stopped);
let thread = thread::Builder::new()
.name("vamp-resolve".into())
.spawn(move || {
while let Ok(request) = incoming.recv() {
if stop.load(Ordering::Acquire) {
break;
}
let cancelled = || stop.load(Ordering::Acquire);
let outcome = match request {
ResolverRequest::Load { tab, root } => ResolverOutcome::Loaded {
tab,
result: crate::resolve::load(&root, &cancelled),
},
ResolverRequest::Save {
tab,
file,
buffer,
workspace,
resolution,
quit,
} => ResolverOutcome::Saved {
tab,
file,
buffer,
quit,
result: crate::resolve::save(&workspace, file, resolution, &cancelled),
},
};
if outgoing.send(outcome).is_err() {
break;
}
}
})
.map_err(|error| format!("Cannot start resolver worker: {error}"))?;
Ok(Self {
requests: Some(requests),
results,
stopped,
thread: Some(thread),
})
}
}
impl Drop for ResolverWorker {
fn drop(&mut self) {
self.stopped.store(true, Ordering::Release);
self.requests.take();
if let Some(thread) = self.thread.take() {
let _ = thread.join();
}
}
}
#[derive(Clone, Copy)]
struct PullRequestJob {
page: u32,
@@ -367,6 +436,7 @@ pub struct EditorSession {
pending_completion: bool,
hover_owner: Option<String>,
diff_workers: BTreeMap<usize, DiffWorker>,
resolver_worker: Option<ResolverWorker>,
blame_worker: Option<BlameWorker>,
blame_source: Option<BlameSource>,
blame_lines: Option<Vec<Option<Arc<crate::blame::LineBlame>>>>,
@@ -487,6 +557,7 @@ impl EditorSession {
pending_completion: false,
hover_owner: None,
diff_workers: BTreeMap::new(),
resolver_worker: None,
blame_worker: None,
blame_source: None,
blame_lines: None,
@@ -557,6 +628,7 @@ impl EditorSession {
self.synchronize_documents();
}
self.start_diff_request();
self.start_resolver_requests();
}
pub fn execute(&mut self, command: &str) {
@@ -568,6 +640,67 @@ impl EditorSession {
self.synchronize_documents();
}
self.start_diff_request();
self.start_resolver_requests();
}
fn start_resolver_requests(&mut self) {
while let Some(request) = self.editor.take_resolver_request() {
if self.resolver_worker.is_none() {
match ResolverWorker::start() {
Ok(worker) => self.resolver_worker = Some(worker),
Err(error) => {
self.fail_resolver_request(request, error);
continue;
}
}
}
if let Err(error) = self
.resolver_worker
.as_ref()
.unwrap()
.requests
.as_ref()
.unwrap()
.send(request)
{
self.fail_resolver_request(error.0, "Resolver worker stopped unexpectedly".into());
self.resolver_worker = None;
}
}
}
fn fail_resolver_request(&mut self, request: ResolverRequest, error: String) {
let outcome = match request {
ResolverRequest::Load { tab, .. } => ResolverOutcome::Loaded {
tab,
result: Err(error),
},
ResolverRequest::Save {
tab,
file,
buffer,
quit,
..
} => ResolverOutcome::Saved {
tab,
file,
buffer,
quit,
result: Err(error),
},
};
self.editor.apply_resolver_outcome(outcome);
}
fn poll_resolver(&mut self) -> bool {
self.start_resolver_requests();
let mut changed = false;
if let Some(worker) = &self.resolver_worker {
while let Ok(outcome) = worker.results.try_recv() {
changed |= self.editor.apply_resolver_outcome(outcome);
}
}
changed
}
fn start_diff_request(&mut self) {
@@ -1603,6 +1736,7 @@ impl EditorSession {
changed |= self.poll_battery();
self.refresh_checkout_diffs_at(now);
changed |= self.poll_diff();
changed |= self.poll_resolver();
for update in self.lsp.poll() {
match self.apply_update("core", update) {
Ok(applied) => changed |= applied,
@@ -1664,6 +1798,43 @@ mod tests {
sync::atomic::{AtomicU64, Ordering},
};
#[test]
fn resolver_load_errors_return_through_real_session_without_touching_the_draft() {
let fixture = Fixture::new();
let path = fixture.file("buffer.txt", "disk\n");
let mut session = EditorSession::open(Some(path), vec![], None).unwrap();
session
.editor
.apply(vec![Effect::Replace("draft\n".into())])
.unwrap();
let buffer = session.editor.buffer_id();
session.execute("resolve");
assert!(session.editor.resolver().unwrap().loading);
let outcome = session
.resolver_worker
.as_ref()
.unwrap()
.results
.recv_timeout(Duration::from_secs(5))
.unwrap();
session.editor.apply_resolver_outcome(outcome);
assert!(!session.editor.resolver().unwrap().loading);
assert!(session.editor.resolver().unwrap().error.is_some());
session.execute("resolve.close");
assert!(
session
.editor
.documents()
.any(|document| document.id == buffer
&& document.dirty
&& document.lines == ["draft", ""])
);
assert_eq!(
fs::read_to_string(fixture.0.join("buffer.txt")).unwrap(),
"disk\n"
);
}
#[test]
fn pull_request_pages_stream_concurrently_and_retry_only_the_failed_category() {
use crate::diff::{PullRequestPage, RepoKind};