diff --git a/README.md b/README.md index 5077d55..df0dd36 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ Vamp starts in Normal mode. Press `i`, type text, then Esc to return to Normal. | Floating/docked explorer in Normal or Visual | Space then `o` / Space then `e` | | Repository diff in Normal | `:diff`; choose Before then After, Tab changes category, type to search; Esc closes the selector; Working or checkout-matched pinned After is editable; `:diff.close` exits the comparison | | Diff layout in Normal | Space then `v` cycles split/unified; Space then `s` flips left/right vs above/below split | +| Resolve jj working-copy conflicts | `:resolve`; inspect readonly inputs, edit Result or `:resolve.use_source N`, then `:w` to save and confirm with jj | Space is the default leader: press its suffix keys in order, not simultaneously. The [editing guide](docs/editing.md) contains the complete key table and behavior reference. diff --git a/TODO.md b/TODO.md index b451b5e..89e4291 100644 --- a/TODO.md +++ b/TODO.md @@ -1,4 +1,4 @@ -- [ ] jj conflict resolution with ui akin to the diff viewer +- [x] jj conflict resolution with ui akin to the diff viewer - [ ] make selecting a file in the diff view file picker focus the after buffer instead of the before buffer - [ ] improve the lsp hover floating window: - it should be taller diff --git a/docs/architecture.md b/docs/architecture.md index eb8a0ad..1136fb4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -78,6 +78,8 @@ Comparison views reserve no separate metadata header or native-buffer help foote Each comparison owns its split/unified mode and split axis; startup defaults apply only when opening a comparison. Source alignment and file-side availability stay independent of presentation. Unified display rows carry a side projection: equal context maps both sources, changed runs emit Before rows followed by After rows, and folds retain aligned coordinates. Native navigation/editing continues to bind source buffers rather than a synthetic unified document. The TUI reuses side-specific syntax/wrap/overlay pipelines in one unified region or the existing axis-based split geometry, exposing only the active logical side for unified hit-testing and directional focus. +The resolver owns a dedicated native tab with immutable input buffers and shared editable Result buffers, independent of historical comparison state. `resolve` reads all alternating jj merge-tree terms from immutable Git-backed `jj:trees` metadata; marker parsing only provides region navigation and draft replacement. Session background jobs load inputs and perform guarded saves. The write path holds jj's working-copy lock while validating checkout identity and on-disk data and installing the chosen result, then delegates snapshot/status authority to jj. Save aliases and shared Result tabs route through the same guards. TUI resolver geometry reuses native document rendering, with two inputs above Result or a single selected input at narrow widths. + `Editor::scroll_distance()` supplies the session-wide optional edge margin. The TUI applies it to document/diff visual rows and unwrapped text columns, and retains independent picker offsets across navigation frames. Picker windows exclude their fixed chrome and align whole multi-row entries. File projections carry a monotonic viewport revision, so catalog/filter/expansion changes invalidate picker identity without hashing the entire catalog on every redraw. Metadata-only status changes do not move the viewport. Explicit LSP/wrap-toggle anchors and detached wheel views retain precedence. Mouse wheel input uses the last rendered document/panel rectangles for hit-testing. Document offsets and explorer scroll state remain frontend-owned; three-row wheel steps never become document cursor motions. Manual document views persist until logical caret-reveal, caret/selection, mode, revision, or buffer changes; offscreen primary and browsing-selection cursors are not placed. Hover wheel input reuses the existing editor-owned popup scrolling. diff --git a/docs/configuration.md b/docs/configuration.md index 79e03bb..50fa9e8 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -223,6 +223,7 @@ Normal `C` / `D` / `Y` change/delete/copy the cursor-to-line-end suffix through | Normal | `follow_link` | | Normal | `diff`, `diff.mode.toggle`, `diff.view.toggle`, `diff.orientation.toggle`, `diff.review.toggle` | | Normal, Insert, Visual, Command, Search | `diff.close` | +| Normal | `resolve`, `resolve.next`, `resolve.previous`, `resolve.use_source`, `resolve.source`, `resolve.base`, `resolve.next_file`, `resolve.previous_file`, `resolve.files`, `resolve.result`, `resolve.close` | | Normal, Insert, Visual, Command, Search | `word_wrap.toggle` | | Normal, Insert, Visual, Command, Search | `git_blame.toggle` | | Normal | `pane.split_horizontal`, `pane.split_vertical`, `pane.focus_left`, `pane.focus_down`, `pane.focus_up`, `pane.focus_right`, `pane.close`, `pane.force_close` | diff --git a/docs/development.md b/docs/development.md index 921a331..a5850cf 100644 --- a/docs/development.md +++ b/docs/development.md @@ -58,6 +58,8 @@ Diff checks cover staged category/filter navigation, stale request cancellation, Conflicted-revision regressions preserve unchanged symlinks, defer oversized unselected blobs until file selection, compare selected conflict text with jj's projection, and exclude unchanged conflicts when comparing later revisions. Live terminal smoke covers local bookmark selection and opening an actual conflicted file without changing the checkout. +Resolver checks use isolated real jj repositories: read-only discovery, all sides/bases, per-region choice and undo, unsaved-buffer reuse, retained resolved files, jj-authoritative status, checkout/external-write refusal (including forced save), cancellation, nontext bytes/types/modes/deletion, and directory-child protection. Render checks cover native Result caret/focus, remapped pane movement and full/narrow layouts. Live PTY smoke resolves multiple text regions and binary/symlink whole-file choices, saving and verifying jj status without touching user repositories. + Repository gutter checks cover LF/CRLF normalization, additions/replacements/deletion anchors, staged/disk/unsaved Git text, unborn and missing named files, ignored/unsupported paths, read-only colocated/non-colocated jj queries, stale results and per-buffer isolation, plus rendered modes, colors, current-line precedence, wrap/scroll and narrow/split geometry. Live PTY smoke verifies all four modes, edit/undo refresh, external Git HEAD changes, and jj parent changes without editor restart. Normal-picker checks cover staged/disk/unsaved status, binary and mode changes, exact and ambiguous renames, deleted paths, unborn repositories, aliases and unusual filenames, ignores and safe roots, linked worktrees, and read-only colocated/non-colocated/conflicted jj parents. Catalog/render checks retain unchanged rows, query/selection/expansion and Tree/List navigation, clear stale metadata, restore recreated files, and sanitize rename labels. Live PTY smoke checks A/M/D/R colors in floating and docked pickers with the line gutter disabled, Git HEAD/jj parent refresh, and unchanged jj operation state during polling. diff --git a/docs/editing.md b/docs/editing.md index ca017af..d5d75ec 100644 --- a/docs/editing.md +++ b/docs/editing.md @@ -456,6 +456,30 @@ Both diff panes apply available semantic tokens from their own buffer, independe Binary data, missing final newlines, permission/type changes, symlink targets, and submodule pointers have explicit notices. Symlinks are not followed and submodule contents are not traversed. jj conflicts use jj's file-content projection and carry conflict notices; ordinary files remain readable. Identical-content moves are shown as renames; modified moves appear as an added and removed file. +## Jujutsu conflict resolution + +Run **`:resolve`** in Normal mode for the current jj working copy. It opens a dedicated tab, selects the current file if conflicted (otherwise the first conflict), and focuses its existing Result buffer at the first unresolved region. Already-open unsaved Result text and undo history are retained. Historical comparisons remain read-only; resolving never implicitly checks out or creates another change. + +Two read-only inputs appear above the editable Result, with a conflicted-file list on the right. Narrow terminals show one selected input above Result. Input labels identify numbered sides/bases and contributing changes/bookmarks when an exact tree match is available; otherwise they identify the source tree. All merge sides and bases remain selectable, including multi-sided jj conflicts. Inputs support native navigation, search, selection and copying, but reject edits, saves and mutating plugin effects. + +| Command | Behavior | +| --- | --- | +| `resolve.next` / `resolve.previous` | Jump to the next/previous unresolved region | +| `resolve.source [N]` | Focus a numbered input; without N, cycle inputs | +| `resolve.base [N]` | Focus a numbered base; default first base | +| `resolve.use_source [N]` | Replace the current region from that input as one undoable edit; default selected input | +| `resolve.next_file` / `resolve.previous_file` | Open another retained file | +| `resolve.files` / `resolve.result` | Focus the file list / editable Result | +| `resolve.close` | Close the resolver without discarding dirty drafts | + +Input indexes are one-based and include bases. Commands also appear in the palette and through corresponding `config.core.resolve` action methods; no new global default bindings are installed. Normal-mode Tab/Shift+Tab cycles visible panes and files. In the list, `/` filters, ordinary selection navigation moves the highlight, and Enter opens it. Native Result editing, search, undo/redo and remapped pane navigation work normally. There is no automatic “accept both”. + +**`:w` saves and checks jj**, asynchronously. Before writing, the resolver locks the working copy and validates the frozen checkout and exact on-disk file state. Checkout changes, external writes and unsafe filesystem ancestors refuse the write, including force-save. Successful writes trigger jj's normal snapshot operation, which may capture other on-disk changes. The list distinguishes unresolved, unsaved, checking, resolved and unconfirmed states. jj—not marker scanning or a manual checkbox—confirms resolution. A failed confirmation keeps saved contents and reports unconfirmed status. Resolved files remain in the session for review. Dirty drafts prevent destructive resolver/pane/tab closes. + +Binary, symlink, deletion, executable-bit and file/directory conflicts use explicit whole-file input choices: choose an input with `resolve.use_source`, then save to apply its exact bytes and type. Symlinks are never followed. Directory replacement refuses unrelated/untracked or modified child data. Git submodule choices and legacy jj conflict storage without `jj:trees` metadata report unsupported errors instead of fabricating a resolution. + +Opening, navigating and choosing draft resolutions do not snapshot jj; only saving does. If another jj operation changes the frozen checkout, retain your draft, close after preserving it, and reopen the resolver against the new state. + ## Command palette Ctrl+p opens a searchable overlay without leaving the current mode. It lists each core action once, plus registered plugin commands, with their owner, effective bindings in the current mode, and eligible modes. Disabled or overridden bindings are not advertised; actions with no key remain available through the palette. Mode-ineligible entries are dimmed and cannot execute. Retired plugin commands disappear. diff --git a/src/editor.rs b/src/editor.rs index a559eb4..8a0ca6e 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -793,6 +793,67 @@ core_actions![ true ), ("core.diff", Action::Diff, &[Mode::Normal], true), + ("core.resolve", Action::Resolve, &[Mode::Normal], true), + ( + "core.resolve.next", + Action::ResolveNext, + &[Mode::Normal], + true + ), + ( + "core.resolve.previous", + Action::ResolvePrevious, + &[Mode::Normal], + true + ), + ( + "core.resolve.use_source", + Action::ResolveUseSource, + &[Mode::Normal], + true + ), + ( + "core.resolve.source", + Action::ResolveSource, + &[Mode::Normal], + true + ), + ( + "core.resolve.base", + Action::ResolveBase, + &[Mode::Normal], + true + ), + ( + "core.resolve.next_file", + Action::ResolveNextFile, + &[Mode::Normal], + true + ), + ( + "core.resolve.previous_file", + Action::ResolvePreviousFile, + &[Mode::Normal], + true + ), + ( + "core.resolve.files", + Action::ResolveFiles, + &[Mode::Normal], + true + ), + ( + "core.resolve.result", + Action::ResolveResult, + &[Mode::Normal], + true + ), + ( + "core.resolve.close", + Action::ResolveClose, + &[Mode::Normal], + true + ), ( "core.pull_requests", Action::PullRequests, @@ -1384,6 +1445,77 @@ pub struct DocumentInfo<'a> { pub dirty: bool, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ResolverStatus { + Unresolved, + Unsaved, + Checking, + Resolved, + Unconfirmed, +} + +pub struct ResolverSource { + pub label: String, + pub text: String, + pub base: bool, +} + +pub struct ResolverFile { + pub path: PathBuf, + pub result_buffer: usize, + pub status: ResolverStatus, + pub sources: Vec, + pub text: bool, + pub whole_file_choice: Option, + pub error: Option, +} + +pub struct ResolverWorkspace { + pub files: Vec, + pub selected_file: usize, + pub current_file: usize, + pub selected_source: usize, + pub result_pane: usize, + pub source_panes: Vec, + pub files_focused: bool, + pub loading: bool, + pub filter: String, + pub filtering: bool, + pub error: Option, + model: Option, + preferred_path: Option, + pending: bool, +} + +pub(crate) enum ResolverRequest { + Load { + tab: usize, + root: PathBuf, + }, + Save { + tab: usize, + file: usize, + buffer: usize, + workspace: crate::resolve::Workspace, + resolution: crate::resolve::Resolution, + quit: bool, + }, +} + +pub(crate) enum ResolverOutcome { + Loaded { + tab: usize, + result: Result, + }, + Saved { + tab: usize, + file: usize, + buffer: usize, + quit: bool, + result: Result, + }, +} + struct AnnotationSet { revision: u64, items: Vec, @@ -1551,6 +1683,7 @@ struct GutterChanges { struct Buffer { id: usize, diff_snapshot: Option<(u64, usize, Focus)>, + resolver_readonly: bool, snapshot_document_path: Option, snapshot_root: Option, lines: Vec, @@ -1608,6 +1741,7 @@ impl Buffer { Ok(Self { id, diff_snapshot: None, + resolver_readonly: false, snapshot_document_path: None, snapshot_root: None, saved_lines: lines.clone(), @@ -1631,7 +1765,7 @@ impl Buffer { } fn dirty(&self) -> bool { - self.diff_snapshot.is_none() && self.lines != self.saved_lines + !self.resolver_readonly && self.diff_snapshot.is_none() && self.lines != self.saved_lines } fn refresh_file(&mut self) -> io::Result { @@ -1771,6 +1905,8 @@ pub struct Editor { next_tab_id: usize, tab_order: Vec, tabs: BTreeMap, + resolvers: BTreeMap, + resolver_requests: VecDeque, pane_bounds: PaneRect, render_pane_focused: bool, diff_owner: Option, @@ -1868,6 +2004,8 @@ impl Editor { next_tab_id: 2, tab_order: vec![1], tabs: BTreeMap::new(), + resolvers: BTreeMap::new(), + resolver_requests: VecDeque::new(), pane_bounds: PaneRect { x: 0, y: 0, @@ -2013,6 +2151,702 @@ impl Editor { Ok(editor) } + pub fn resolver(&self) -> Option<&ResolverWorkspace> { + self.resolvers.get(&self.tab_id) + } + + pub fn focus_resolver_pane(&mut self, pane: usize) { + let Some(resolver) = self.resolvers.get_mut(&self.tab_id) else { + return; + }; + if pane != resolver.result_pane && !resolver.source_panes.contains(&pane) { + return; + } + resolver.files_focused = false; + if let Some(source) = resolver.source_panes.iter().position(|id| *id == pane) { + resolver.selected_source = source; + } + self.finish_pane_interaction(); + self.select_pane(pane); + } + + pub fn focus_resolver_files(&mut self) { + self.finish_pane_interaction(); + if let Some(resolver) = self.resolvers.get_mut(&self.tab_id) { + resolver.files_focused = true; + } + } + + fn open_resolver(&mut self) { + if let Some(tab) = self.resolvers.keys().next().copied() { + self.finish_pane_interaction(); + self.select_tab(tab); + return; + } + let root = self + .active + .snapshot_root + .clone() + .or_else(|| { + self.active + .path + .as_deref() + .and_then(Path::parent) + .map(Path::to_path_buf) + }) + .unwrap_or_else(|| self.project_root.clone()); + let preferred_path = self + .active + .path + .as_deref() + .and_then(|path| path_identity(path).ok()); + self.new_tab(); + let result_pane = self.pane_id; + self.resolvers.insert( + self.tab_id, + ResolverWorkspace { + files: Vec::new(), + selected_file: 0, + current_file: 0, + selected_source: 0, + result_pane, + source_panes: Vec::new(), + files_focused: false, + loading: true, + filter: String::new(), + filtering: false, + error: None, + model: None, + preferred_path, + pending: false, + }, + ); + self.resolver_requests.push_back(ResolverRequest::Load { + tab: self.tab_id, + root, + }); + } + + pub(crate) fn take_resolver_request(&mut self) -> Option { + self.refresh_resolver_status(); + self.resolver_requests.pop_front() + } + + pub(crate) fn refresh_resolver_status(&mut self) { + let active = &self.active; + let parked = &self.parked; + for resolver in self.resolvers.values_mut() { + for (index, file) in resolver.files.iter_mut().enumerate() { + let buffer = if active.id == file.result_buffer { + Some(active) + } else { + parked.get(&file.result_buffer) + }; + if file.status != ResolverStatus::Checking + && (buffer.is_some_and(Buffer::dirty) || file.whole_file_choice.is_some()) + { + file.status = ResolverStatus::Unsaved; + } else if file.status == ResolverStatus::Unsaved { + file.status = match resolver + .model + .as_ref() + .map(|model| model.files[index].status) + { + Some(crate::resolve::Status::Resolved) => ResolverStatus::Resolved, + Some(crate::resolve::Status::Unconfirmed) => ResolverStatus::Unconfirmed, + _ => ResolverStatus::Unresolved, + }; + } + } + } + } + + pub(crate) fn apply_resolver_outcome(&mut self, outcome: ResolverOutcome) -> bool { + match outcome { + ResolverOutcome::Loaded { tab, result } => { + if !self.resolvers.contains_key(&tab) { + return false; + } + match result { + Err(error) => { + let resolver = self.resolvers.get_mut(&tab).unwrap(); + resolver.loading = false; + resolver.error = Some(error.clone()); + self.set_message(error); + } + Ok(model) => { + let preferred = self.resolvers[&tab].preferred_path.as_ref(); + let selected = model + .files + .iter() + .position(|file| { + preferred.is_some_and(|path| *path == model.root.join(&file.path)) + }) + .unwrap_or(0); + let mut files = Vec::with_capacity(model.files.len()); + for file in &model.files { + let path = model.root.join(&file.path); + let identity = path_identity(&path).unwrap_or_else(|_| path.clone()); + let existing = std::iter::once(&self.active) + .chain(self.parked.values()) + .find(|buffer| { + !buffer.resolver_readonly + && buffer.identity.as_ref() == Some(&identity) + }) + .map(|buffer| buffer.id); + let text = file + .result + .as_ref() + .is_some_and(|text| validate_text(text).is_ok()) + && file.sources.iter().all(|source| { + (source.kind == crate::resolve::FileKind::Deleted + && source.base) + || (source.kind == crate::resolve::FileKind::Text + && source + .text() + .is_some_and(|text| validate_text(text).is_ok())) + }); + let result_buffer = if let Some(id) = existing { + id + } else { + let contents = if text { + file.result.as_deref().unwrap() + } else { + "Non-text/type conflict: choose an explicit source, then :w.\n" + }; + let id = self.next_buffer_id; + match Buffer::from_text( + id, + Some(path.clone()), + Some(identity), + contents, + if text { + FileStamp::read(&path).ok().flatten() + } else { + None + }, + ) { + Ok(mut buffer) => { + buffer.resolver_readonly = !text; + self.next_buffer_id += 1; + self.parked.insert(id, buffer); + id + } + Err(error) => { + self.resolvers.get_mut(&tab).unwrap().error = + Some(error.to_string()); + self.resolvers.get_mut(&tab).unwrap().loading = false; + return true; + } + } + }; + files.push(ResolverFile { + path: file.path.clone(), result_buffer, + status: ResolverStatus::Unresolved, text, whole_file_choice: None, + error: file.notice.clone(), + sources: file.sources.iter().map(|source| ResolverSource { + label: source.label.clone(), base: source.base, + text: source.text().filter(|text| validate_text(text).is_ok()).map(str::to_owned).unwrap_or_else(|| + format!("{:?} input ({} bytes)\nChoose this source explicitly to resolve the whole file.", source.kind, source.bytes.len())), + }).collect(), + }); + } + let resolver = self.resolvers.get_mut(&tab).unwrap(); + resolver.files = files; + resolver.selected_file = selected; + resolver.loading = false; + resolver.model = Some(model); + let previous_tab = self.tab_id; + self.select_tab(tab); + self.select_resolver_file(selected); + self.select_tab(previous_tab); + self.refresh_resolver_status(); + } + } + } + ResolverOutcome::Saved { + tab, + file, + buffer, + quit, + result, + } => { + let Some(resolver) = self.resolvers.get_mut(&tab) else { + return false; + }; + resolver.pending = false; + match result { + Err(error) => { + resolver.files[file].status = ResolverStatus::Unsaved; + resolver.files[file].error = Some(error.clone()); + self.set_message(format!("Resolution not saved: {error}")); + } + Ok(outcome) => { + let model = resolver.model.as_mut().unwrap(); + model.checkout = outcome.checkout; + model.files[file] = outcome.file; + let target_path = model.root.join(&model.files[file].path); + let saved_text = model.files[file] + .result + .as_deref() + .filter(|text| validate_text(text).is_ok()) + .map(split_lines); + let was_text = resolver.files[file].text; + resolver.files[file].text = saved_text.is_some(); + let status = match model.files[file].status { + crate::resolve::Status::Resolved => ResolverStatus::Resolved, + crate::resolve::Status::Unresolved => ResolverStatus::Unresolved, + crate::resolve::Status::Unconfirmed => ResolverStatus::Unconfirmed, + }; + resolver.files[file].status = status; + resolver.files[file].error = outcome.confirmation.clone(); + resolver.files[file].whole_file_choice = None; + if let Some(buffer) = if self.active.id == buffer { + Some(&mut self.active) + } else { + self.parked.get_mut(&buffer) + } { + if let Some(saved) = saved_text { + if !was_text && !buffer.dirty() { + buffer.lines.clone_from(&saved); + buffer.text_changed(); + buffer.view.clamp(&buffer.lines); + } + buffer.saved_lines = saved; + buffer.identity = Some(target_path.clone()); + buffer.path = Some(target_path); + buffer.resolver_readonly = false; + } else if !buffer.dirty() { + buffer.lines = vec!["Non-text resolution saved; reopen the file to inspect its current type.".into()]; + buffer.saved_lines.clone_from(&buffer.lines); + buffer.path = None; + buffer.identity = None; + buffer.resolver_readonly = true; + buffer.text_changed(); + buffer.view.clamp(&buffer.lines); + } + buffer.disk_stamp = buffer + .path + .as_deref() + .and_then(|path| FileStamp::read(path).ok().flatten()); + buffer.save_revision = buffer.save_revision.wrapping_add(1); + buffer.reload_error = None; + } + self.set_message(outcome.confirmation.unwrap_or_else(|| match status { + ResolverStatus::Resolved => "Written; jj confirms resolved".into(), + ResolverStatus::Unresolved => { + "Written; conflict remains unresolved".into() + } + _ => "Written; resolution unconfirmed".into(), + })); + self.refresh_resolver_status(); + if quit && !self.any_dirty() && status != ResolverStatus::Unconfirmed { + self.quit = true; + } + } + } + } + } + true + } + + pub fn select_resolver_file(&mut self, file: usize) { + let Some(resolver) = self.resolver() else { + return; + }; + let Some(selected) = resolver.files.get(file) else { + return; + }; + let result = selected.result_buffer; + let sources: Vec<_> = selected + .sources + .iter() + .map(|source| (source.text.clone(), source.base)) + .collect(); + let result_pane = resolver.result_pane; + let old = resolver.source_panes.clone(); + self.finish_pane_interaction(); + self.select_pane(result_pane); + self.switch_buffer(result); + for pane in old { + let buffer = self.panes[&pane].buffer_id; + self.pane_layout.close(pane); + self.panes.remove(&pane); + self.forget_buffer(buffer); + } + let mut source_panes = Vec::new(); + for (text, _) in &sources { + let id = self.next_buffer_id; + let mut buffer = Buffer::from_text(id, None, None, text, None) + .expect("resolver source text was validated"); + buffer.resolver_readonly = true; + self.next_buffer_id += 1; + self.parked.insert(id, buffer); + self.split_pane(SplitAxis::Vertical); + self.switch_buffer(id); + source_panes.push(self.pane_id); + self.select_pane(result_pane); + } + let resolver = self.resolvers.get_mut(&self.tab_id).unwrap(); + resolver.selected_file = file; + resolver.current_file = file; + resolver.selected_source = sources.iter().position(|(_, base)| !base).unwrap_or(0); + resolver.source_panes = source_panes; + resolver.files_focused = false; + self.resolver_region(false, true); + } + + fn resolver_region(&mut self, forward: bool, first: bool) { + let Some(resolver) = self.resolver() else { + return; + }; + let pane = resolver.result_pane; + self.focus_resolver_pane(pane); + let regions = crate::resolve::regions(&self.active.lines.join("\n")); + let row = self.cursor().row; + let region = if first { + regions.first() + } else if forward { + regions + .iter() + .find(|region| region.lines.start > row) + .or(regions.first()) + } else { + regions + .iter() + .rev() + .find(|region| region.lines.start < row) + .or(regions.last()) + }; + if let Some(region) = region { + self.active.view.targets = vec![Target::caret(Cursor { + row: region.lines.start, + column: 0, + })]; + self.active.view.primary = 0; + self.reveal_caret(); + } + } + + fn resolver_command(&mut self, action: Action, args: &str) { + self.prepare_command(); + self.finish_command_mode(); + if action == Action::Resolve { + if args.is_empty() { + self.open_resolver(); + } else { + self.set_message("Usage: :resolve"); + } + return; + } + let Some(resolver) = self.resolver() else { + self.set_message("Open the current jj conflicts with :resolve"); + return; + }; + if !matches!( + action, + Action::ResolveSource | Action::ResolveBase | Action::ResolveUseSource + ) && !args.is_empty() + { + self.set_message("This resolution command takes no arguments"); + return; + } + let source = if args.is_empty() { + Some(resolver.selected_source) + } else { + args.parse::() + .ok() + .and_then(|index| index.checked_sub(1)) + }; + match action { + Action::ResolveNext | Action::ResolvePrevious => { + self.resolver_region(action == Action::ResolveNext, false) + } + Action::ResolveFiles => self.focus_resolver_files(), + Action::ResolveResult => self.focus_resolver_pane(resolver.result_pane), + Action::ResolveClose => self.close_tab(false), + Action::ResolveNextFile | Action::ResolvePreviousFile => { + let count = resolver.files.len(); + if count > 0 { + let next = (resolver.selected_file + + if action == Action::ResolveNextFile { + 1 + } else { + count - 1 + }) + % count; + self.select_resolver_file(next); + } + } + Action::ResolveSource | Action::ResolveBase => { + let index = if action == Action::ResolveBase { + let base = if args.is_empty() { Some(0) } else { source }; + base.and_then(|base| { + resolver + .files + .get(resolver.current_file)? + .sources + .iter() + .enumerate() + .filter(|(_, source)| source.base) + .nth(base) + .map(|(index, _)| index) + }) + } else if args.is_empty() && !resolver.source_panes.is_empty() { + Some((resolver.selected_source + 1) % resolver.source_panes.len()) + } else { + source + }; + if let Some(pane) = index + .and_then(|index| resolver.source_panes.get(index)) + .copied() + { + self.focus_resolver_pane(pane); + } else { + self.set_message("No such conflict source"); + } + } + Action::ResolveUseSource => { + let Some(index) = source.filter(|index| *index < resolver.source_panes.len()) + else { + self.set_message("No such conflict source"); + return; + }; + let file = resolver.current_file; + let pane = resolver.result_pane; + let deleted = resolver.model.as_ref().is_some_and(|model| { + model.files[file].sources[index].kind == crate::resolve::FileKind::Deleted + }); + if !resolver.files[file].text || deleted { + let resolver = self.resolvers.get_mut(&self.tab_id).unwrap(); + resolver.files[file].whole_file_choice = Some(index); + resolver.files[file].status = ResolverStatus::Unsaved; + resolver.selected_source = index; + self.focus_resolver_pane(pane); + self.set_message("Whole-file source selected; :w applies the explicit choice"); + return; + } + self.focus_resolver_pane(pane); + self.resolvers.get_mut(&self.tab_id).unwrap().files[file].whole_file_choice = None; + let row = self.cursor().row; + let regions = crate::resolve::regions(&self.active.lines.join("\n")); + let Some(region) = regions.iter().find(|region| region.lines.contains(&row)) else { + self.set_message("Move the Result caret into a conflict region"); + return; + }; + let source = &self.resolvers[&self.tab_id].model.as_ref().unwrap().files[file] + .sources[index]; + match region.choose(source) { + Ok(text) => { + let before = self.snapshot(); + let offsets = line_offsets(&self.active.lines); + let start = offsets[region.lines.start]; + let end = offsets + .get(region.lines.end) + .copied() + .unwrap_or_else(|| self.active.lines.join("\n").len()); + self.apply_edits(vec![Edit { + target: Some(self.active.view.primary), + start, + end, + text: Cow::Owned(text), + caret: 0, + round_up: false, + }]); + self.record(before); + self.resolvers + .get_mut(&self.tab_id) + .unwrap() + .selected_source = index; + self.refresh_resolver_status(); + } + Err(error) => self.set_message(error), + } + } + _ => unreachable!("resolver actions only"), + } + } + + fn queue_resolver_save(&mut self, path: Option<&Path>, quit: bool) -> bool { + let owner = self.resolvers.iter().find_map(|(tab, resolver)| { + resolver + .files + .iter() + .position(|file| file.result_buffer == self.active.id) + .map(|file| (*tab, file)) + }); + let Some((tab, file)) = owner else { + return false; + }; + if path.is_some_and(|path| self.active.path.as_deref() != Some(path)) { + self.set_message("Resolution Result must be written to its original conflict path"); + return true; + } + let resolver = self.resolvers.get_mut(&tab).unwrap(); + if resolver.pending { + self.set_message("Resolution save/check is already running"); + return true; + } + let model = resolver.model.as_ref().unwrap(); + let conflict_path = model.root.join(&model.files[file].path); + if resolver.files[file].whole_file_choice.is_some() + && std::iter::once(&self.active) + .chain(self.parked.values()) + .any(|buffer| { + buffer.dirty() + && buffer.identity.as_ref().is_some_and(|path| { + path != &conflict_path && path.starts_with(&conflict_path) + }) + }) + { + self.set_message( + "Directory resolution refused: an open child buffer has unsaved changes", + ); + return true; + } + if resolver.files[file].text + && model.files[file] + .result + .as_ref() + .is_some_and(|text| split_lines(text) != self.active.saved_lines) + { + self.set_message("Resolution draft predates the loaded disk content; preserve the draft before reopening"); + return true; + } + let resolution = if let Some(source) = resolver.files[file].whole_file_choice { + crate::resolve::Resolution::Source(source) + } else if resolver.files[file].text { + crate::resolve::Resolution::Text(self.active.lines.join("\n")) + } else { + self.set_message("Choose an explicit whole-file source before writing"); + return true; + }; + resolver.pending = true; + resolver.files[file].status = ResolverStatus::Checking; + self.resolver_requests.push_back(ResolverRequest::Save { + tab, + file, + buffer: self.active.id, + workspace: model.clone(), + resolution, + quit, + }); + true + } + + fn resolver_close_blocked(&self) -> bool { + self.resolver().is_some_and(|resolver| { + resolver.pending + || resolver.files.iter().any(|file| { + file.whole_file_choice.is_some() + || self.buffer(file.result_buffer).is_some_and(Buffer::dirty) + }) + }) + } + + fn handle_resolver_input(&mut self, input: &Input) -> bool { + let Some(resolver) = self.resolver() else { + return false; + }; + if self.palette.is_some() + || self.startup_diagnostic.is_some() + || self.active.view.mode != Mode::Normal + || self.seek_pending.is_some() + || self.line_pending.is_some() + { + return false; + } + if matches!(input, Input::Char('\t') | Input::BackTab) + && self.pending_sequence.keys.is_empty() + && self.pending_sequence.prefix.is_none() + { + let mut panes = vec![resolver.result_pane]; + panes.extend(&resolver.source_panes); + let current = if resolver.files_focused { + panes.len() + } else { + panes + .iter() + .position(|pane| *pane == self.pane_id) + .unwrap_or(0) + }; + let count = panes.len() + 1; + let next = (current + + if *input == Input::BackTab { + count - 1 + } else { + 1 + }) + % count; + if let Some(pane) = panes.get(next) { + self.focus_resolver_pane(*pane); + } else { + self.focus_resolver_files(); + } + return true; + } + if !resolver.files_focused { + return false; + } + if resolver.filtering { + match input { + Input::Escape | Input::Enter => { + self.resolvers.get_mut(&self.tab_id).unwrap().filtering = false + } + Input::Backspace => { + BackspaceKind::Grapheme + .truncate(&mut self.resolvers.get_mut(&self.tab_id).unwrap().filter); + } + Input::Char(ch) if !ch.is_control() => self + .resolvers + .get_mut(&self.tab_id) + .unwrap() + .filter + .push(*ch), + _ => return true, + } + return true; + } + match input { + Input::Char('/') => self.resolvers.get_mut(&self.tab_id).unwrap().filtering = true, + Input::Escape => self.focus_resolver_pane(resolver.result_pane), + Input::Enter => self.select_resolver_file(resolver.selected_file), + Input::Char('j') | Input::Down | Input::Char('k') | Input::Up => { + let forward = matches!(input, Input::Char('j') | Input::Down); + let visible: Vec<_> = resolver + .files + .iter() + .enumerate() + .filter(|(_, file)| { + file.path + .to_string_lossy() + .to_lowercase() + .contains(&resolver.filter.to_lowercase()) + }) + .map(|(index, _)| index) + .collect(); + if !visible.is_empty() { + let position = visible + .iter() + .position(|index| *index == resolver.selected_file) + .unwrap_or(0); + let next = + (position + if forward { 1 } else { visible.len() - 1 }) % visible.len(); + self.resolvers.get_mut(&self.tab_id).unwrap().selected_file = visible[next]; + } + } + _ => { + if matches!( + input, + Input::Char(':') | Input::Ctrl('p') | Input::Alt(_) | Input::CtrlAlt(_) + ) { + return false; + } + } + } + true + } + pub fn tab_id(&self) -> usize { self.tab_id } @@ -2290,7 +3124,11 @@ impl Editor { pub fn with_render_pane(&mut self, id: usize, callback: impl FnOnce(&Editor) -> R) -> R { let focused = self.render_pane_focused; - let is_focused = focused && self.pane_id == id; + let is_focused = focused + && self.pane_id == id + && !self + .resolver() + .is_some_and(|resolver| resolver.files_focused); self.render_pane_focused = is_focused; let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { self.with_pane(id, |editor| callback(editor)) @@ -2342,6 +3180,35 @@ impl Editor { } fn focus_pane(&mut self, direction: PaneDirection) { + if let Some(resolver) = self.resolver() { + let pane = match direction { + PaneDirection::Down => Some(resolver.result_pane), + PaneDirection::Up => resolver.source_panes.get(resolver.selected_source).copied(), + PaneDirection::Right if self.pane_id == resolver.result_pane => { + self.focus_resolver_files(); + return; + } + PaneDirection::Left | PaneDirection::Right => { + let count = resolver.source_panes.len(); + if count == 0 { + None + } else { + let index = (resolver.selected_source + + if direction == PaneDirection::Right { + 1 + } else { + count - 1 + }) + % count; + resolver.source_panes.get(index).copied() + } + } + }; + if let Some(pane) = pane { + self.focus_resolver_pane(pane); + } + return; + } if let Some(diff) = self.diff.as_ref().filter(|diff| diff.phase == Phase::View) { let focus = diff.focus; if let Some((_, current)) = self @@ -2424,6 +3291,13 @@ impl Editor { } fn forget_buffer(&mut self, id: usize) { + if self + .resolvers + .values() + .any(|resolver| resolver.files.iter().any(|file| file.result_buffer == id)) + { + return; + } self.parked.remove(&id); for pane in self.panes.values_mut() { pane.views.remove(&id); @@ -2431,6 +3305,12 @@ impl Editor { } fn close_tab(&mut self, force: bool) { + if self.resolver_close_blocked() { + self.set_message( + "Resolution drafts are unsaved; write them before closing the workspace", + ); + return; + } if self.tab_count() == 1 { if !force && self.any_dirty() { self.set_message("Unsaved changes; use :w or :tab.force_close"); @@ -2445,7 +3325,18 @@ impl Editor { .copied() .filter(|id| self.pane_layout.contains(*id)) .collect(); - let buffers = self.closing_buffers(&panes); + let mut buffers = self.closing_buffers(&panes); + if let Some(resolver) = self.resolver() { + for file in &resolver.files { + if !buffers.contains(&file.result_buffer) + && !self.panes.iter().any(|(id, pane)| { + !panes.contains(id) && pane.buffer_id == file.result_buffer + }) + { + buffers.push(file.result_buffer); + } + } + } if !force && buffers .iter() @@ -2470,6 +3361,7 @@ impl Editor { self.select_tab(next); self.tabs.remove(&id); self.tab_order.remove(index); + self.resolvers.remove(&id); for pane in panes { self.panes.remove(&pane); } @@ -2479,6 +3371,10 @@ impl Editor { } fn close_pane(&mut self, force: bool) { + if self.resolver().is_some() { + self.close_tab(force); + return; + } self.count = None; self.clear_sequence(); let picker_origin = self.pane_close_picker_origin.take().is_some_and(|origin| { @@ -2572,13 +3468,18 @@ impl Editor { pub fn buffers(&self) -> impl Iterator> + '_ { self.parked .range(..self.active.id) - .filter(|(_, buffer)| buffer.diff_snapshot.is_none()) + .filter(|(_, buffer)| buffer.diff_snapshot.is_none() && !buffer.resolver_readonly) .map(|(_, buffer)| buffer.info(false)) - .chain((self.active.diff_snapshot.is_none()).then(|| self.active.info(true))) + .chain( + (self.active.diff_snapshot.is_none() && !self.active.resolver_readonly) + .then(|| self.active.info(true)), + ) .chain( self.parked .range(self.active.id..) - .filter(|(_, buffer)| buffer.diff_snapshot.is_none()) + .filter(|(_, buffer)| { + buffer.diff_snapshot.is_none() && !buffer.resolver_readonly + }) .map(|(_, buffer)| buffer.info(false)), ) } @@ -2586,13 +3487,18 @@ impl Editor { pub fn documents(&self) -> impl Iterator> + '_ { self.parked .range(..self.active.id) - .filter(|(_, buffer)| buffer.diff_snapshot.is_none()) + .filter(|(_, buffer)| buffer.diff_snapshot.is_none() && !buffer.resolver_readonly) .map(|(_, buffer)| buffer.document()) - .chain((self.active.diff_snapshot.is_none()).then(|| self.active.document())) + .chain( + (self.active.diff_snapshot.is_none() && !self.active.resolver_readonly) + .then(|| self.active.document()), + ) .chain( self.parked .range(self.active.id..) - .filter(|(_, buffer)| buffer.diff_snapshot.is_none()) + .filter(|(_, buffer)| { + buffer.diff_snapshot.is_none() && !buffer.resolver_readonly + }) .map(|(_, buffer)| buffer.document()), ) } @@ -2601,7 +3507,7 @@ impl Editor { self.documents().chain( std::iter::once(&self.active) .chain(self.parked.values()) - .filter(|buffer| buffer.diff_snapshot.is_some()) + .filter(|buffer| buffer.diff_snapshot.is_some() || buffer.resolver_readonly) .map(Buffer::document), ) } @@ -3061,7 +3967,15 @@ impl Editor { } pub(crate) fn mutation_blocked(&self) -> bool { - self.active.diff_snapshot.is_some() || (self.diff.is_some() && !self.diff_editing()) + self.active.resolver_readonly + || self.active.diff_snapshot.is_some() + || (self.diff.is_some() && !self.diff_editing()) + || self.resolvers.values().any(|resolver| { + resolver.files.iter().any(|file| { + (!file.text || file.whole_file_choice.is_some()) + && file.result_buffer == self.active.id + }) + }) } fn leave_diff_on_navigation(&mut self) { @@ -5284,7 +6198,10 @@ impl Editor { } pub(crate) fn document_actions_available(&self) -> bool { - self.diff.is_none() || self.diff_interacting() + (self.diff.is_none() || self.diff_interacting()) + && !self + .resolver() + .is_some_and(|resolver| resolver.files_focused) } pub fn set_message(&mut self, message: impl Into) { @@ -6101,6 +7018,9 @@ impl Editor { Input::Ctrl(ch) => control_key(ch), input => input, }; + if self.handle_resolver_input(&input) { + return None; + } if self.palette.is_none() && self.active.view.mode != Mode::Command && !matches!( @@ -6123,6 +7043,7 @@ impl Editor { self.leave_diff_on_navigation(); self.bind_diff_buffer(); self.refresh_live_diff(); + self.refresh_resolver_status(); if self.diff_interacting() && let Some(diff) = &mut self.diff { @@ -6553,8 +7474,19 @@ impl Editor { return None; } let read_only = self.mutation_blocked(); - if read_only && action.mutates(self.active.view.mode) { - self.set_message("Diff pane is read-only"); + if read_only + && action.mutates(self.active.view.mode) + && !(matches!( + action, + Action::Save | Action::ForceSave | Action::SaveAndQuit | Action::ForceSaveAndQuit + ) && self.resolvers.values().any(|resolver| { + resolver + .files + .iter() + .any(|file| file.result_buffer == self.active.id) + })) + { + self.set_message("Input pane is read-only"); self.count = None; self.clear_sequence(); return None; @@ -6653,6 +7585,17 @@ impl Editor { | Action::BufferDelete | Action::BufferForceDelete | Action::Diff + | Action::Resolve + | Action::ResolveNext + | Action::ResolvePrevious + | Action::ResolveUseSource + | Action::ResolveSource + | Action::ResolveBase + | Action::ResolveNextFile + | Action::ResolvePreviousFile + | Action::ResolveFiles + | Action::ResolveResult + | Action::ResolveClose | Action::PullRequests | Action::DiffClose | Action::DiffModeToggle @@ -7163,7 +8106,7 @@ impl Editor { let identity = path_identity(&path)?; let loaded = std::iter::once(&self.active) .chain(self.parked.values()) - .find(|buffer| buffer.identity.as_ref() == Some(&identity)) + .find(|buffer| !buffer.resolver_readonly && buffer.identity.as_ref() == Some(&identity)) .map(|buffer| buffer.id); if let Some(id) = loaded { self.switch_buffer(id); @@ -7185,6 +8128,14 @@ impl Editor { } fn delete_buffer(&mut self, id: usize, force: bool) { + if self + .resolvers + .values() + .any(|resolver| resolver.files.iter().any(|file| file.result_buffer == id)) + { + self.set_message("Close the resolver workspace before deleting its Result buffer"); + return; + } let buffer = if id == self.active.id { Some(&self.active) } else { @@ -7322,7 +8273,15 @@ impl Editor { } fn any_dirty(&self) -> bool { - self.active.dirty() || self.parked.values().any(Buffer::dirty) + self.active.dirty() + || self.parked.values().any(Buffer::dirty) + || self.resolvers.values().any(|resolver| { + resolver.pending + || resolver + .files + .iter() + .any(|file| file.whole_file_choice.is_some()) + }) } fn restart(&mut self, force: bool) { @@ -7367,6 +8326,7 @@ impl Editor { let invocation = self.execute_command(command); self.leave_diff_on_navigation(); self.refresh_live_diff(); + self.refresh_resolver_status(); invocation } @@ -7420,6 +8380,71 @@ impl Editor { } fn execute_action(&mut self, entry: &CoreAction, args: &str) -> Option { + if matches!( + entry.action, + Action::Resolve + | Action::ResolveNext + | Action::ResolvePrevious + | Action::ResolveUseSource + | Action::ResolveSource + | Action::ResolveBase + | Action::ResolveNextFile + | Action::ResolvePreviousFile + | Action::ResolveFiles + | Action::ResolveResult + | Action::ResolveClose + ) { + self.resolver_command(entry.action, args); + return None; + } + if matches!( + entry.action, + Action::Save | Action::ForceSave | Action::SaveAndQuit | Action::ForceSaveAndQuit + ) { + self.prepare_command(); + self.finish_command_mode(); + let quit = matches!(entry.action, Action::SaveAndQuit | Action::ForceSaveAndQuit); + if self.queue_resolver_save((!args.is_empty()).then(|| Path::new(args)), quit) { + return None; + } + } + if self.resolver().is_some_and(|resolver| resolver.loading) + && !matches!( + entry.action, + Action::TabPrevious + | Action::TabNext + | Action::TabNew + | Action::TabClose + | Action::TabForceClose + | Action::PaneClose + | Action::PaneForceClose + ) + { + self.set_message("Conflict inputs are still loading"); + return None; + } + if self.resolver().is_some() + && matches!( + entry.action, + Action::PaneSplitHorizontal | Action::PaneSplitVertical + ) + { + self.set_message("Resolver panes are fixed; use :tab.new for an independent workspace"); + return None; + } + if self.resolver().is_some() + && matches!( + entry.action, + Action::Edit + | Action::Buffer + | Action::BufferNext + | Action::BufferPrevious + | Action::Diff + | Action::PullRequests + ) + { + self.new_tab(); + } if !matches!(entry.action, Action::PaneClose | Action::PaneForceClose) { self.pane_close_picker_origin = None; } @@ -7808,7 +8833,15 @@ impl Editor { let mut changed = false; let mut active_reloaded = false; let mut notice = None; + let resolver_buffers: HashSet<_> = self + .resolvers + .values() + .flat_map(|resolver| resolver.files.iter().map(|file| file.result_buffer)) + .collect(); for buffer in std::iter::once(&mut self.active).chain(self.parked.values_mut()) { + if buffer.resolver_readonly || resolver_buffers.contains(&buffer.id) { + continue; + } match buffer.refresh_file() { Ok(reloaded) => { buffer.reload_error = None; @@ -7853,6 +8886,9 @@ impl Editor { } fn save(&mut self, path: Option, force: bool) -> bool { + if self.queue_resolver_save(path.as_deref(), false) { + return false; + } if self.mutation_blocked() { self.set_message("Diff pane is read-only"); return false; @@ -10908,6 +11944,63 @@ mod tests { use crate::plugin::CompletionEdit; use std::collections::BTreeSet; + #[test] + fn resolver_native_inputs_are_readonly_and_dirty_shared_results_survive_close() { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("draft\n".into())]) + .unwrap(); + let result = editor.buffer_id(); + editor.open_resolver(); + let tab = editor.tab_id(); + editor.resolver_requests.clear(); + let resolver = editor.resolvers.get_mut(&tab).unwrap(); + resolver.loading = false; + resolver.files.push(ResolverFile { + path: "file.txt".into(), + result_buffer: result, + status: ResolverStatus::Unsaved, + text: true, + whole_file_choice: None, + error: None, + sources: vec![ResolverSource { + label: "Side #1".into(), + text: "input\n".into(), + base: false, + }], + }); + editor.select_resolver_file(0); + let source = editor.resolver().unwrap().source_panes[0]; + editor.focus_resolver_pane(source); + for command in ["w", "w! /tmp/vamp-readonly-must-not-write", "buffer_delete"] { + editor.execute(command); + assert_eq!(editor.lines(), ["input", ""]); + } + assert!( + editor + .apply(vec![Effect::Insert("blocked".into())]) + .is_err() + ); + for input in [Input::Char('i'), Input::Char('u'), Input::Char('U')] { + editor.handle(input); + } + assert_eq!(editor.lines(), ["input", ""]); + editor.handle(Input::Char('/')); + editor.handle(Input::Char('i')); + editor.handle(Input::Enter); + assert_eq!(editor.active.view.search_query, "i"); + editor.handle(Input::Char('\t')); + assert!(editor.resolver().unwrap().files_focused); + editor.execute("resolve.close"); + assert_eq!(editor.tab_id(), tab); + editor.execute("pane.force_close"); + assert_eq!(editor.tab_id(), tab); + assert_eq!(editor.buffer(result).unwrap().lines, ["draft", ""]); + editor.execute("tab.previous"); + assert_eq!(editor.buffer_id(), result); + assert!(editor.dirty()); + } + struct Directory(PathBuf); impl Directory { fn new() -> Self { diff --git a/src/lib.rs b/src/lib.rs index 67fa52a..8eda57e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,6 +13,7 @@ mod markdown; pub mod panes; pub mod plugin; pub mod repository; +pub mod resolve; pub mod ruby; pub mod session; mod snippet; diff --git a/src/main.rs b/src/main.rs index d7e190e..6e84bea 100644 --- a/src/main.rs +++ b/src/main.rs @@ -83,6 +83,11 @@ Diff: :diff (Normal); choose Before then After; Working checkout After edits liv Changed files: normal picker/explorer bindings, Ctrl+w close; A/M/D/R status and rename paths 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 Ruby config: $XDG_CONFIG_HOME/vamp/config.rb or ~/.config/vamp/config.rb Set VAMP_RUBY to choose the Ruby executable. diff --git a/src/resolve.rs b/src/resolve.rs new file mode 100644 index 0000000..edfb09e --- /dev/null +++ b/src/resolve.rs @@ -0,0 +1,1317 @@ +//! Current-working-copy conflict inputs and guarded saves. Browsing never snapshots jj. +use crate::repository::{jj_command, safe_path, store_for_jj}; +use std::{ + collections::BTreeMap, + ffi::OsString, + fs::{self, File, OpenOptions}, + io::Write, + ops::Range, + path::{Path, PathBuf}, + process::Command, + sync::atomic::{AtomicU64, Ordering}, +}; + +const LIMIT: usize = 16 * 1024 * 1024; +static NEXT: AtomicU64 = AtomicU64::new(0); + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Checkout { + commit: String, + change: String, + operation: String, + state: Vec, +} +#[derive(Clone, Debug)] +pub struct Workspace { + pub root: PathBuf, + pub checkout: Checkout, + pub files: Vec, +} +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum FileKind { + Text, + Binary, + Symlink, + Deleted, + Submodule, + Directory, +} +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Status { + Unresolved, + Resolved, + Unconfirmed, +} +#[derive(Clone, Debug)] +pub struct Source { + pub label: String, + pub base: bool, + pub kind: FileKind, + pub bytes: Vec, + pub tree_id: String, + term: usize, + sides: usize, + bases: usize, + node: Node, +} +impl Source { + pub fn text(&self) -> Option<&str> { + (self.kind == FileKind::Text) + .then(|| std::str::from_utf8(&self.bytes).ok()) + .flatten() + } +} +#[derive(Clone, Debug)] +pub struct ConflictFile { + pub path: PathBuf, + pub sources: Vec, + pub result: Option, + pub notice: Option, + pub status: Status, + baseline: Node, +} +#[derive(Clone, Debug)] +pub enum Resolution { + Text(String), + Source(usize), +} +#[derive(Clone, Debug)] +pub struct SaveOutcome { + pub checkout: Checkout, + pub file: ConflictFile, + /// A write succeeded but jj confirmation failed. Never interpret this as resolved. + pub confirmation: Option, +} +#[derive(Clone, Debug, Eq, PartialEq)] +enum Node { + Absent, + File(Vec, u32), + Symlink(Vec), + Directory(Vec<(OsString, Node)>), + Submodule(String), +} +impl Node { + fn kind(&self) -> FileKind { + match self { + Self::Absent => FileKind::Deleted, + Self::File(bytes, _) + if std::str::from_utf8(bytes).is_ok_and(|s| { + s.chars() + .all(|ch| !ch.is_control() || matches!(ch, '\n' | '\r' | '\t')) + }) => + { + FileKind::Text + } + Self::File(..) => FileKind::Binary, + Self::Symlink(_) => FileKind::Symlink, + Self::Directory(_) => FileKind::Directory, + Self::Submodule(_) => FileKind::Submodule, + } + } + fn bytes(&self) -> &[u8] { + match self { + Self::File(bytes, _) | Self::Symlink(bytes) => bytes, + _ => &[], + } + } +} +fn check(cancelled: &dyn Fn() -> bool) -> Result<(), String> { + if cancelled() { + Err("Conflict operation cancelled".into()) + } else { + Ok(()) + } +} +fn run(command: Command, cancelled: &dyn Fn() -> bool) -> Result, String> { + let description = format!("{command:?}"); + crate::blame::run(command, &[], cancelled)? + .ok_or_else(|| format!("Jujutsu conflict command failed: {description}")) +} +fn text(bytes: Vec) -> Result { + String::from_utf8(bytes).map_err(|_| "Jujutsu returned non-UTF-8 metadata".into()) +} +fn git(store: &Path) -> Command { + let mut command = Command::new("git"); + command.arg("--no-pager").arg("--git-dir").arg(store).args([ + "-c", + "core.hooksPath=/dev/null", + "-c", + "core.fsmonitor=false", + ]); + command +} +fn identity(root: &Path, cancelled: &dyn Fn() -> bool) -> Result { + check(cancelled)?; + let state_path = root.join(".jj/working_copy/checkout"); + let state = fs::read(&state_path).map_err(|e| format!("Cannot read local jj checkout: {e}"))?; + let mut command = jj_command(root); + command.args([ + "log", + "--no-graph", + "-r", + "@", + "-T", + "\"[\" ++ json(commit_id) ++ \",\" ++ json(change_id) ++ \"]\"", + ]); + let (commit, change): (String, String) = serde_json::from_slice(&run(command, cancelled)?) + .map_err(|e| format!("Invalid jj checkout identity: {e}"))?; + let mut command = jj_command(root); + command.args(["op", "log", "--no-graph", "-n", "1", "-T", "id"]); + let operation = text(run(command, cancelled)?)?; + if fs::read(state_path).map_err(|e| e.to_string())? != state { + return Err("Jujutsu checkout changed while reading; reopen :resolve".into()); + } + Ok(Checkout { + commit, + change, + operation, + state, + }) +} +fn entries( + root: &Path, + revision: &str, + cancelled: &dyn Fn() -> bool, +) -> Result, String> { + let revision = format!("commit_id({})", serde_json::to_string(revision).unwrap()); + let mut command = jj_command(root); + command.args([ + "file", + "list", + "-r", + &revision, + "-T", + "if(conflict, json(path) ++ \"\\n\")", + ]); + let mut paths = Vec::new(); + for line in run(command, cancelled)? + .split(|b| *b == b'\n') + .filter(|line| !line.is_empty()) + { + let path: String = + serde_json::from_slice(line).map_err(|e| format!("Invalid jj conflict path: {e}"))?; + let path = PathBuf::from(path); + safe_path(&path)?; + paths.push(path); + if paths.len() > 100_000 { + return Err("More than 100000 conflicted files".into()); + } + } + Ok(paths) +} +fn oid(value: &str) -> Result<&str, String> { + if matches!(value.len(), 40 | 64) && value.bytes().all(|b| b.is_ascii_hexdigit()) { + Ok(value) + } else { + Err("Invalid Git object ID in jj conflict inputs".into()) + } +} +fn trees(store: &Path, commit: &str, cancelled: &dyn Fn() -> bool) -> Result, String> { + let mut command = git(store); + command.args(["cat-file", "commit", oid(commit)?]); + let bytes = run(command, cancelled)?; + // jj's Git format is an alternating add/remove/add merge, not a 3-way assumption. + // https://github.com/jj-vcs/jj/blob/v0.46.0/lib/src/git_backend.rs + let headers = bytes + .split(|b| *b == b'\n') + .take_while(|line| !line.is_empty()); + for line in headers { + if let Some(value) = line.strip_prefix(b"jj:trees ") { + let value = std::str::from_utf8(value).map_err(|_| "Invalid jj:trees header")?; + let trees = value + .split(' ') + .map(|id| oid(id).map(str::to_owned)) + .collect::, _>>()?; + if trees.len() < 3 || trees.len() % 2 == 0 { + return Err("Invalid jj merge-tree shape".into()); + } + return Ok(trees); + } + } + Err("Conflicted jj commit has no supported jj:trees header; legacy conflict storage is unsupported".into()) +} +fn provenance( + root: &Path, + store: &Path, + commit: &str, + cancelled: &dyn Fn() -> bool, +) -> Result, String> { + let mut command = jj_command(root); + let revision = format!( + "ancestors(commit_id({}))", + serde_json::to_string(commit).unwrap() + ); + // ponytail: inspect the nearest 256 ancestors once; deeper inputs show their + // exact tree ID. Expand the bounded batch only if provenance needs it. + command.args(["log", "--no-graph", "-r", &revision, "-n", "256", "-T", + "\"[\" ++ json(commit_id) ++ \",\" ++ json(change_id) ++ \",\" ++ json(local_bookmarks.map(|b| b.name())) ++ \"]\\n\""]); + let mut commits = BTreeMap::new(); + let mut input = Vec::new(); + for line in run(command, cancelled)? + .split(|b| *b == b'\n') + .filter(|line| !line.is_empty()) + { + let (id, change, bookmarks): (String, String, Vec) = + serde_json::from_slice(line).map_err(|e| format!("Invalid source provenance: {e}"))?; + if id.bytes().all(|b| b == b'0') { + continue; + } + oid(&id)?; + let label = format!( + "{}{}", + &change[..change.len().min(12)], + if bookmarks.is_empty() { + String::new() + } else { + format!(" [{}]", bookmarks.join(", ")) + } + ); + input.extend_from_slice(id.as_bytes()); + input.push(b'\n'); + commits.insert(id, (!bookmarks.is_empty(), label)); + } + if input.is_empty() { + return Ok(BTreeMap::new()); + } + let mut command = git(store); + command.args([ + "log", + "--no-walk=unsorted", + "--no-decorate", + "--no-show-signature", + "--format=%H %T", + "--stdin", + ]); + let output = crate::blame::run(command, &input, cancelled)? + .ok_or("Cannot read source provenance trees")?; + let mut labels: BTreeMap = BTreeMap::new(); + for line in text(output)?.lines() { + let (id, tree) = line + .split_once(' ') + .ok_or("Invalid source provenance tree record")?; + oid(tree)?; + let label = commits.get(id).ok_or("Unknown source provenance commit")?; + if labels.get(tree).is_none_or(|old| !old.0 && label.0) { + labels.insert(tree.to_owned(), label.clone()); + } + } + Ok(labels + .into_iter() + .map(|(tree, (_, label))| (tree, label)) + .collect()) +} +fn os(bytes: Vec) -> Result { + #[cfg(unix)] + { + use std::os::unix::ffi::OsStringExt; + Ok(OsString::from_vec(bytes)) + } + #[cfg(not(unix))] + { + String::from_utf8(bytes) + .map(OsString::from) + .map_err(|_| "Non-UTF-8 path".into()) + } +} +fn os_bytes(value: OsString) -> Vec { + #[cfg(unix)] + { + use std::os::unix::ffi::OsStringExt; + value.into_vec() + } + #[cfg(not(unix))] + { + value.to_string_lossy().as_bytes().to_vec() + } +} +fn blob( + store: &Path, + id: &str, + budget: &mut usize, + cancelled: &dyn Fn() -> bool, +) -> Result, String> { + let mut command = git(store); + command.args(["cat-file", "blob", oid(id)?]); + let bytes = run(command, cancelled)?; + if bytes.len() > LIMIT { + return Err("Conflict input exceeds 16 MiB".into()); + } + *budget = budget + .checked_sub(bytes.len()) + .ok_or("Conflict source and disk inputs exceed 64 MiB total")?; + Ok(bytes) +} +fn tree_entries( + store: &Path, + tree: &str, + path: Option<&Path>, + budget: &mut usize, + cancelled: &dyn Fn() -> bool, +) -> Result, String> { + let mut command = git(store); + command.args(["--literal-pathspecs", "ls-tree", "-z", oid(tree)?]); + if let Some(path) = path { + command.arg("--").arg(path); + } + let mut result = Vec::new(); + for record in run(command, cancelled)? + .split(|b| *b == 0) + .filter(|r| !r.is_empty()) + { + check(cancelled)?; + let (header, name) = record.split_at( + record + .iter() + .position(|b| *b == b'\t') + .ok_or("Invalid Git tree record")?, + ); + let fields = std::str::from_utf8(header) + .map_err(|_| "Invalid Git tree header")? + .split(' ') + .collect::>(); + if fields.len() != 3 { + return Err("Invalid Git tree fields".into()); + } + let mode = u32::from_str_radix(fields[0], 8).map_err(|_| "Invalid Git mode")?; + let id = oid(fields[2])?; + let node = match mode { + 0o100644 | 0o100755 => Node::File(blob(store, id, budget, cancelled)?, mode & 0o777), + 0o120000 => Node::Symlink(blob(store, id, budget, cancelled)?), + 0o040000 => Node::Directory(tree_entries(store, id, None, budget, cancelled)?), + 0o160000 => Node::Submodule(id.to_owned()), + _ => return Err(format!("Unsupported Git conflict mode {mode:o}")), + }; + result.push((os(name[1..].to_vec())?, node)); + } + result.sort_by(|a, b| a.0.cmp(&b.0)); + Ok(result) +} +fn safe_ancestors(root: &Path, path: &Path) -> Result<(), String> { + safe_path(path)?; + let mut full = root.to_path_buf(); + for part in path.parent().into_iter().flat_map(Path::components) { + full.push(part); + let metadata = fs::symlink_metadata(&full) + .map_err(|e| format!("Unsafe conflict parent {}: {e}", full.display()))?; + if !metadata.is_dir() || metadata.file_type().is_symlink() { + return Err(format!( + "Conflict parent is not a real directory: {}", + full.display() + )); + } + } + Ok(()) +} +fn disk(path: &Path, budget: &mut usize, cancelled: &dyn Fn() -> bool) -> Result { + check(cancelled)?; + let metadata = match fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Node::Absent), + Err(e) => return Err(format!("{}: {e}", path.display())), + }; + if metadata.file_type().is_symlink() { + return Ok(Node::Symlink(os_bytes( + fs::read_link(path) + .map_err(|e| e.to_string())? + .into_os_string(), + ))); + } + if metadata.is_dir() { + let mut entries = Vec::new(); + for entry in fs::read_dir(path).map_err(|e| e.to_string())? { + let entry = entry.map_err(|e| e.to_string())?; + safe_path(Path::new(&entry.file_name()))?; + entries.push((entry.file_name(), disk(&entry.path(), budget, cancelled)?)); + } + entries.sort_by(|a, b| a.0.cmp(&b.0)); + return Ok(Node::Directory(entries)); + } + if !metadata.is_file() { + return Err(format!("Unsupported filesystem object: {}", path.display())); + } + if metadata.len() > LIMIT as u64 || metadata.len() > *budget as u64 { + return Err("Conflict disk contents exceed 16 MiB per file or 64 MiB total".into()); + } + let bytes = fs::read(path).map_err(|e| e.to_string())?; + *budget = budget + .checked_sub(bytes.len()) + .ok_or("Conflict disk contents exceed 64 MiB")?; + #[cfg(unix)] + let mode = { + use std::os::unix::fs::PermissionsExt; + metadata.permissions().mode() & 0o777 + }; + #[cfg(not(unix))] + let mode = 0o644; + Ok(Node::File(bytes, mode)) +} +fn disk_result(node: &Node) -> Option { + (node.kind() == FileKind::Text) + .then(|| String::from_utf8(node.bytes().to_vec()).ok()) + .flatten() +} + +pub fn load(root: &Path, cancelled: &dyn Fn() -> bool) -> Result { + let root = fs::canonicalize(root).map_err(|e| e.to_string())?; + let root = root + .ancestors() + .find(|p| p.join(".jj").is_dir()) + .ok_or("Resolve requires a current Jujutsu working copy")? + .to_path_buf(); + let checkout = identity(&root, cancelled)?; + let paths = entries(&root, &checkout.commit, cancelled)?; + let store = store_for_jj(&root)?; + let trees = if paths.is_empty() { + Vec::new() + } else { + trees(&store, &checkout.commit, cancelled)? + }; + let (labels, provenance_notice) = if paths.is_empty() { + (BTreeMap::new(), None) + } else { + match provenance(&root, &store, &checkout.commit, cancelled) { + Ok(labels) => (labels, None), + Err(error) => { + check(cancelled)?; + ( + BTreeMap::new(), + Some(format!( + "Source provenance unavailable; showing exact tree IDs: {error}" + )), + ) + } + } + }; + let mut files = Vec::new(); + let mut budget = 64 * 1024 * 1024; + for path in paths { + check(cancelled)?; + safe_ancestors(&root, &path)?; + let mut terms = Vec::new(); + for tree in &trees { + let mut values = tree_entries(&store, tree, Some(&path), &mut budget, cancelled)?; + if values.len() > 1 { + return Err("Ambiguous Git tree conflict path".into()); + } + terms.push(values.pop().map_or(Node::Absent, |(_, value)| value)); + } + // Cancel equal add/remove terms exactly as jj simplifies a path's merge. + let mut keep = vec![true; terms.len()]; + for base in (1..terms.len()).step_by(2) { + if let Some(side) = (0..terms.len()) + .step_by(2) + .find(|side| keep[*side] && terms[*side] == terms[base]) + { + keep[base] = false; + keep[side] = false; + } + } + let mut sides = 0; + let mut bases = 0; + let mut sources = Vec::new(); + let num_sides = keep + .iter() + .enumerate() + .filter(|(i, kept)| **kept && i % 2 == 0) + .count(); + let num_bases = keep + .iter() + .enumerate() + .filter(|(i, kept)| **kept && i % 2 == 1) + .count(); + for (index, node) in terms.into_iter().enumerate().filter(|(i, _)| keep[*i]) { + let base = index % 2 == 1; + let term = if base { + let n = bases; + bases += 1; + n + } else { + let n = sides; + sides += 1; + n + }; + let tree_id = trees[index].clone(); + let provenance = labels + .get(&tree_id) + .cloned() + .unwrap_or_else(|| format!("tree {}", &tree_id[..12])); + sources.push(Source { + label: format!( + "{} #{} - {} ({:?}{})", + if base { "Base" } else { "Side" }, + term + 1, + provenance, + node.kind(), + if matches!(&node, Node::File(_, mode) if mode & 0o111 != 0) { + ", executable" + } else { + "" + } + ), + base, + kind: node.kind(), + bytes: node.bytes().to_vec(), + tree_id, + term, + sides: num_sides, + bases: num_bases, + node, + }); + } + let baseline = disk(&root.join(&path), &mut budget, cancelled)?; + let nontext = sources + .iter() + .any(|s| s.kind != FileKind::Text && !(s.base && s.kind == FileKind::Deleted)); + let result = if nontext { + None + } else { + disk_result(&baseline) + }; + let notice = match (nontext, &provenance_notice) { + (true, Some(error)) => Some(format!("Non-text/type conflict: choose an explicit whole-file source; byte content and file type are preserved. {error}")), + (true, None) => Some("Non-text/type conflict: choose an explicit whole-file source; byte content and file type are preserved".into()), + (false, notice) => notice.clone(), + }; + files.push(ConflictFile { + path, + sources, + result, + notice, + status: Status::Unresolved, + baseline, + }); + } + if identity(&root, cancelled)? != checkout { + return Err("Jujutsu checkout changed while loading; reopen :resolve".into()); + } + Ok(Workspace { + root, + checkout, + files, + }) +} + +// jj uses flock on this exact path, unlinks it before unlocking, and retries +// unlinked descriptors. Use the same protocol so checkout changes cannot race writes. +struct WorkingCopyLock { + path: PathBuf, + _file: File, +} +impl WorkingCopyLock { + fn acquire(root: &Path) -> Result { + let path = root.join(".jj/working_copy/working_copy.lock"); + loop { + let file = OpenOptions::new() + .create(true) + .write(true) + .truncate(false) + .open(&path) + .map_err(|e| e.to_string())?; + file.try_lock() + .map_err(|e| format!("Jujutsu working copy is busy: {e}"))?; + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + if file.metadata().map_err(|e| e.to_string())?.nlink() == 0 { + continue; + } + } + return Ok(Self { path, _file: file }); + } + } +} +impl Drop for WorkingCopyLock { + fn drop(&mut self) { + let _ = fs::remove_file(&self.path); + } +} +struct Staged(PathBuf); +impl Drop for Staged { + fn drop(&mut self) { + let _ = remove(&self.0); + } +} +impl Staged { + fn new(parent: &Path) -> Result { + for _ in 0..100 { + let path = parent.join(format!( + ".vamp-resolve-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + let mut builder = fs::DirBuilder::new(); + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + builder.mode(0o700); + } + match builder.create(&path) { + Ok(()) => return Ok(Self(path)), + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(e) => return Err(e.to_string()), + } + } + Err("Cannot create a private conflict save directory".into()) + } +} +fn remove(path: &Path) -> Result<(), String> { + match fs::symlink_metadata(path) { + Ok(metadata) if metadata.is_dir() => fs::remove_dir_all(path), + Ok(_) => fs::remove_file(path), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(e) => return Err(e.to_string()), + } + .map_err(|e| e.to_string()) +} +fn materialize(path: &Path, node: &Node, cancelled: &dyn Fn() -> bool) -> Result<(), String> { + check(cancelled)?; + match node { + Node::Absent => Ok(()), + Node::File(bytes, mode) => { + let mut file = OpenOptions::new().write(true).create_new(true).open(path).map_err(|e| e.to_string())?; + file.write_all(bytes).and_then(|_| file.sync_all()).map_err(|e| e.to_string())?; + #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; fs::set_permissions(path, fs::Permissions::from_mode(*mode)).map_err(|e| e.to_string())?; } + Ok(()) + } + Node::Symlink(bytes) => { + let target = PathBuf::from(os(bytes.clone())?); + #[cfg(unix)] { std::os::unix::fs::symlink(target, path).map_err(|e| e.to_string()) } + #[cfg(not(unix))] { let _ = target; Err("Symlink resolution requires Unix symlink support".into()) } + } + Node::Directory(entries) => { + fs::create_dir(path).map_err(|e| e.to_string())?; + for (name, value) in entries { safe_path(Path::new(name))?; materialize(&path.join(name), value, cancelled)?; } + Ok(()) + } + Node::Submodule(_) => Err("Jujutsu does not materialize Git submodules; resolve this source with jj outside the editor".into()), + } +} + +pub fn save( + workspace: &Workspace, + file_index: usize, + resolution: Resolution, + cancelled: &dyn Fn() -> bool, +) -> Result { + check(cancelled)?; + let file = workspace + .files + .get(file_index) + .ok_or("Unknown resolver file")?; + let desired = match resolution { + Resolution::Text(text) => { + if file.result.is_none() + || (file.status != Status::Resolved + && file.sources.iter().any(|source| { + source.kind != FileKind::Text + && !(source.base && source.kind == FileKind::Deleted) + })) + { + return Err( + "Choose an explicit whole-file source for this non-text/type conflict".into(), + ); + } + if text.len() > LIMIT { + return Err("Resolution exceeds 16 MiB".into()); + } + let mode = match &file.baseline { + Node::File(_, mode) => *mode, + _ => 0o644, + }; + Node::File(text.into_bytes(), mode) + } + Resolution::Source(index) => file + .sources + .get(index) + .ok_or("Unknown conflict source")? + .node + .clone(), + }; + if fs::canonicalize(&workspace.root).map_err(|e| e.to_string())? != workspace.root { + return Err("Jujutsu workspace path changed; no file written. Reopen :resolve".into()); + } + let lock = WorkingCopyLock::acquire(&workspace.root)?; + if identity(&workspace.root, cancelled)? != workspace.checkout { + return Err("Jujutsu checkout changed; no file written. Reopen :resolve".into()); + } + safe_ancestors(&workspace.root, &file.path)?; + let path = workspace.root.join(&file.path); + if disk(&path, &mut (64 * 1024 * 1024), cancelled)? != file.baseline { + return Err("Conflict file changed externally; no file written. Reopen :resolve".into()); + } + if matches!(file.baseline, Node::Directory(_)) + && !file + .sources + .iter() + .any(|source| source.node == file.baseline) + { + return Err("Directory contains untracked or modified children; no files written. Preserve those files before choosing a whole-file resolution".into()); + } + let staged = Staged::new(path.parent().ok_or("Missing conflict parent")?)?; + let replacement = staged.0.join("result"); + materialize(&replacement, &desired, cancelled)?; + check(cancelled)?; + // Recheck immediately before replacing, including filesystem ancestors. + safe_ancestors(&workspace.root, &file.path)?; + if identity(&workspace.root, cancelled)? != workspace.checkout + || disk(&path, &mut (64 * 1024 * 1024), cancelled)? != file.baseline + { + return Err("Checkout or conflict file changed; no file written. Reopen :resolve".into()); + } + if matches!(desired, Node::Absent | Node::Directory(_)) + || matches!(file.baseline, Node::Directory(_)) + { + let backup = staged.0.join("original"); + if !matches!(file.baseline, Node::Absent) { + fs::rename(&path, &backup).map_err(|e| e.to_string())?; + } + if !matches!(desired, Node::Absent) + && let Err(error) = fs::rename(&replacement, &path) + { + if !matches!(file.baseline, Node::Absent) + && let Err(restore) = fs::rename(&backup, &path) + { + let location = backup.display().to_string(); + std::mem::forget(staged); + return Err(format!( + "Cannot install resolution: {error}; cannot restore original: {restore}; original retained at {location}" + )); + } + return Err(error.to_string()); + } + } else { + fs::rename(&replacement, &path).map_err(|e| e.to_string())?; + } + let cleanup_error = remove(&staged.0).err().map(|error| format!("Saved; private backup cleanup failed at {}: {error}; jj confirmation was not attempted", staged.0.display())); + drop(staged); + drop(lock); + let mut saved = file.clone(); + saved.baseline = desired; + saved.result = disk_result(&saved.baseline); + saved.status = Status::Unconfirmed; + if let Some(error) = cleanup_error { + return Ok(SaveOutcome { + checkout: workspace.checkout.clone(), + file: saved, + confirmation: Some(error), + }); + } + // A cancellation after the write must still produce a saved/unconfirmed + // outcome, rather than leaving the UI believing nothing reached disk. + let confirmation: Result = (|| { + let before = identity(&workspace.root, cancelled)?; + if before != workspace.checkout { + return Err("Checkout changed after writing; jj confirmation was not attempted".into()); + } + let mut command = Command::new("jj"); + command + .arg("--repository") + .arg(&workspace.root) + .current_dir(&workspace.root) + .args([ + "--no-pager", + "--color=never", + "--config=snapshot.auto-update-stale=false", + "util", + "snapshot", + ]); + run(command, cancelled)?; + let checkout = identity(&workspace.root, cancelled)?; + if checkout.change != workspace.checkout.change { + return Err("Jujutsu changed checkout during confirmation".into()); + } + let conflicts = entries(&workspace.root, &checkout.commit, cancelled)?; + if identity(&workspace.root, cancelled)? != checkout { + return Err("Jujutsu changed during conflict status confirmation".into()); + } + if disk(&path, &mut (64 * 1024 * 1024), cancelled)? != saved.baseline { + return Err( + "Conflict file changed during jj confirmation; saved content cannot be confirmed" + .into(), + ); + } + saved.status = if conflicts.contains(&saved.path) { + Status::Unresolved + } else { + Status::Resolved + }; + Ok(checkout) + })(); + match confirmation { + Ok(checkout) => Ok(SaveOutcome { + checkout, + file: saved, + confirmation: None, + }), + Err(error) => Ok(SaveOutcome { + checkout: workspace.checkout.clone(), + file: saved, + confirmation: Some(format!("Saved; jj status unconfirmed: {error}")), + }), + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Region { + pub lines: Range, + pub sides: Vec, + pub bases: Vec, +} +impl Region { + pub fn choose(&self, source: &Source) -> Result { + if !matches!(source.kind, FileKind::Text | FileKind::Deleted) { + return Err("Non-text sources require an explicit whole-file choice".into()); + } + if self.sides.len() != source.sides || self.bases.len() != source.bases { + return Err("Conflict region no longer has the original side/base count; edit it manually or choose a whole file".into()); + } + let terms = if source.base { + &self.bases + } 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()) + } +} +fn marker(line: &str) -> Option<(u8, usize)> { + let bytes = line.as_bytes(); + let first = *bytes.first()?; + if !b"<>+-%%|=^\\".contains(&first) { + return None; + } + let width = bytes.iter().take_while(|b| **b == first).count(); + (width >= 7 && matches!(bytes.get(width), None | Some(b' ' | b'\r' | b'\n'))) + .then_some((first, width)) +} +fn parse_region(lines: &[&str], width: usize) -> Option<(Vec, Vec)> { + let mut sides = Vec::new(); + let mut bases = Vec::new(); + if lines.iter().any(|line| marker(line) == Some((b'|', width))) { + let mut section = 0; + sides.push(String::new()); + bases.push(String::new()); + sides.push(String::new()); + for line in lines { + match marker(line) { + Some((b'|', n)) if n == width && section == 0 => section = 1, + Some((b'=', n)) if n == width && section == 1 => section = 2, + _ => match section { + 0 => sides[0].push_str(line), + 1 => bases[0].push_str(line), + _ => sides[1].push_str(line), + }, + } + } + return (section == 2).then_some((sides, bases)); + } + let mut current = None; + for line in lines { + match marker(line) { + Some((b'+', n)) if n == width => { + sides.push(String::new()); + current = Some((b'+', sides.len() - 1, 0)); + } + Some((b'-', n)) if n == width => { + bases.push(String::new()); + current = Some((b'-', bases.len() - 1, 0)); + } + Some((b'%', n)) if n == width => { + bases.push(String::new()); + sides.push(String::new()); + current = Some((b'%', sides.len() - 1, bases.len() - 1)); + } + Some((b'^' | b'\\', n)) + if n == width && current.is_some_and(|(kind, _, _)| kind == b'%') => {} + _ => match current? { + (b'+', side, _) => sides[side].push_str(line), + (b'-', base, _) => bases[base].push_str(line), + (b'%', side, base) => match line.as_bytes().first()? { + b' ' => { + sides[side].push_str(&line[1..]); + bases[base].push_str(&line[1..]); + } + b'+' => sides[side].push_str(&line[1..]), + b'-' => bases[base].push_str(&line[1..]), + _ => return None, + }, + _ => return None, + }, + } + } + (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. +pub fn regions(text: &str) -> Vec { + let lines = text.split_inclusive('\n').collect::>(); + let mut regions: Vec = Vec::new(); + let mut starts = Vec::new(); + for (row, line) in lines.iter().enumerate() { + let width = match marker(line) { + Some((b'<', width)) => { + starts.push((row, width)); + continue; + } + Some((b'>', width)) => width, + _ => continue, + }; + let Some(position) = starts + .iter() + .rposition(|(_, candidate)| *candidate == width) + else { + continue; + }; + let (start, _) = starts[position]; + starts.truncate(position); + if let Some((mut sides, mut bases)) = parse_region(&lines[start + 1..row], width) { + // jj adds a separator EOL to every term if any input lacks its final + // newline; the end marker then has no EOL (jj conflicts.rs protocol). + if !lines[row].ends_with('\n') { + for term in sides.iter_mut().chain(bases.iter_mut()) { + if term.ends_with("\r\n") { + term.truncate(term.len() - 2); + } else if term.ends_with('\n') { + term.pop(); + } + } + } + regions.retain(|region| region.lines.start < start || region.lines.end > row + 1); + regions.push(Region { + lines: start..row + 1, + sides, + bases, + }); + } + } + regions +} + +#[cfg(all(test, unix))] +mod tests { + use super::*; + struct Fixture(PathBuf); + impl Fixture { + fn new() -> Self { + let path = std::env::temp_dir().join(format!( + "vamp-resolver-test-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir(&path).unwrap(); + Self(path) + } + fn jj(&self, args: &[&str]) -> String { + let output = Command::new("jj") + .current_dir(&self.0) + .env("JJ_CONFIG", "/dev/null") + .args([ + "--config=user.name=Resolver", + "--config=user.email=resolver@example.test", + "--config=debug.commit-timestamp=\"2001-01-01T00:00:00Z\"", + "--config=debug.operation-timestamp=\"2001-01-01T00:00:00Z\"", + ]) + .args(args) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout).unwrap() + } + fn conflict(&self) { + self.jj(&["git", "init", "--no-colocate", "."]); + self.jj(&["config", "set", "--repo", "user.name", "Resolver"]); + self.jj(&[ + "config", + "set", + "--repo", + "user.email", + "resolver@example.test", + ]); + self.jj(&[ + "config", + "set", + "--repo", + "debug.commit-timestamp", + "\"2001-01-01T00:00:00Z\"", + ]); + self.jj(&[ + "config", + "set", + "--repo", + "debug.operation-timestamp", + "\"2001-01-01T00:00:00Z\"", + ]); + fs::write(self.0.join("file"), "base\n").unwrap(); + self.jj(&["bookmark", "create", "base"]); + self.jj(&["new", "base"]); + fs::write(self.0.join("file"), "left\n").unwrap(); + self.jj(&["bookmark", "create", "left"]); + self.jj(&["new", "base"]); + fs::write(self.0.join("file"), "right\n").unwrap(); + self.jj(&["bookmark", "create", "right"]); + self.jj(&["new", "left", "right"]); + } + } + impl Drop for Fixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + #[test] + fn discovery_is_readonly_and_save_uses_jj_authority_and_stale_guards() { + let fixture = Fixture::new(); + fixture.conflict(); + let operation = fixture.jj(&[ + "--ignore-working-copy", + "op", + "log", + "--no-graph", + "-n", + "1", + "-T", + "id", + ]); + let workspace = load(&fixture.0, &|| false).unwrap(); + assert_eq!(workspace.files.len(), 1); + let file = &workspace.files[0]; + assert!(file.sources.iter().any(|s| s.base && s.bytes == b"base\n")); + assert!(file.sources.iter().any(|s| !s.base && s.bytes == b"left\n")); + assert!( + file.sources + .iter() + .any(|s| !s.base && s.bytes == b"right\n") + ); + for bookmark in ["base", "left", "right"] { + assert!( + file.sources + .iter() + .any(|s| s.label.contains(&format!("[{bookmark}]"))) + ); + } + assert_eq!( + operation, + fixture.jj(&[ + "--ignore-working-copy", + "op", + "log", + "--no-graph", + "-n", + "1", + "-T", + "id" + ]) + ); + let blocks = regions(file.result.as_ref().unwrap()); + assert_eq!(blocks.len(), 1); + for source in &file.sources { + assert_eq!(blocks[0].choose(source).unwrap().as_bytes(), source.bytes); + } + let mut changed = blocks[0].clone(); + changed.sides.pop(); + assert!(changed.choose(&file.sources[0]).is_err()); + fs::write(fixture.0.join("file"), "external\n").unwrap(); + assert!( + save(&workspace, 0, Resolution::Text("lost\n".into()), &|| false) + .unwrap_err() + .contains("externally") + ); + fs::write(fixture.0.join("file"), file.result.as_ref().unwrap()).unwrap(); + fixture.jj(&["new", "base"]); + let before = fs::read(fixture.0.join("file")).unwrap(); + assert!( + save( + &workspace, + 0, + Resolution::Text("wrong checkout\n".into()), + &|| false + ) + .unwrap_err() + .contains("checkout changed") + ); + assert_eq!(fs::read(fixture.0.join("file")).unwrap(), before); + } + #[test] + fn text_choice_is_confirmed_by_jj() { + let fixture = Fixture::new(); + fixture.conflict(); + let mut workspace = load(&fixture.0, &|| false).unwrap(); + let cancelled_after_write = + || fs::read(fixture.0.join("file")).is_ok_and(|bytes| bytes == b"draft\n"); + let saved = save( + &workspace, + 0, + Resolution::Text("draft\n".into()), + &cancelled_after_write, + ) + .unwrap(); + assert_eq!(saved.file.status, Status::Unconfirmed); + assert!(saved.confirmation.is_some()); + assert_eq!(fs::read(fixture.0.join("file")).unwrap(), b"draft\n"); + assert_eq!( + entries(&fixture.0, &workspace.checkout.commit, &|| false).unwrap(), + [PathBuf::from("file")] + ); + workspace.checkout = saved.checkout; + workspace.files[0] = saved.file; + let outcome = save( + &workspace, + 0, + Resolution::Text("resolved\n".into()), + &|| false, + ) + .unwrap(); + assert_eq!( + outcome.file.status, + Status::Resolved, + "{:?}", + outcome.confirmation + ); + assert_eq!(fs::read(fixture.0.join("file")).unwrap(), b"resolved\n"); + assert!( + entries(&fixture.0, &outcome.checkout.commit, &|| false) + .unwrap() + .is_empty() + ); + } + #[test] + fn all_three_sides_and_bases_are_loaded_and_directory_children_are_protected() { + let fixture = Fixture::new(); + fixture.conflict(); + fixture.jj(&["new", "base"]); + fs::write(fixture.0.join("file"), "third\n").unwrap(); + fixture.jj(&["bookmark", "create", "third"]); + fixture.jj(&["new", "left", "right", "third"]); + let workspace = load(&fixture.0, &|| false).unwrap(); + let file = &workspace.files[0]; + assert_eq!(file.sources.iter().filter(|s| !s.base).count(), 3); + assert_eq!(file.sources.iter().filter(|s| s.base).count(), 2); + assert!(file.sources.iter().any(|s| s.bytes == b"third\n")); + let blocks = regions(file.result.as_ref().unwrap()); + for source in &file.sources { + assert_eq!(blocks[0].choose(source).unwrap().as_bytes(), source.bytes); + } + fs::remove_file(fixture.0.join("file")).unwrap(); + fs::create_dir(fixture.0.join("file")).unwrap(); + fs::write(fixture.0.join("file/untracked"), b"keep my draft").unwrap(); + let workspace = load(&fixture.0, &|| false).unwrap(); + assert!( + save(&workspace, 0, Resolution::Source(0), &|| false) + .unwrap_err() + .contains("untracked or modified children") + ); + assert_eq!( + fs::read(fixture.0.join("file/untracked")).unwrap(), + b"keep my draft" + ); + } + #[test] + fn binary_symlink_and_deleted_file_choices_preserve_types_and_bytes() { + let fixture = Fixture::new(); + fixture.conflict(); + fixture.jj(&["edit", "left"]); + fs::write(fixture.0.join("binary"), b"left\0\xff").unwrap(); + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(fixture.0.join("binary"), fs::Permissions::from_mode(0o755)) + .unwrap(); + } + std::os::unix::fs::symlink("left-target", fixture.0.join("link")).unwrap(); + fixture.jj(&["new", "right"]); + fs::write(fixture.0.join("binary"), b"right\0\xfe").unwrap(); + std::os::unix::fs::symlink("right-target", fixture.0.join("link")).unwrap(); + fixture.jj(&["bookmark", "create", "right-extra"]); + fixture.jj(&["new", "left", "right-extra"]); + let mut workspace = load(&fixture.0, &|| false).unwrap(); + for (name, kind, bytes) in [ + ("binary", FileKind::Binary, b"left\0\xff".as_slice()), + ("link", FileKind::Symlink, b"left-target".as_slice()), + ] { + let file = workspace + .files + .iter() + .position(|f| f.path == Path::new(name)) + .unwrap(); + assert!(workspace.files[file].result.is_none()); + let source = workspace.files[file] + .sources + .iter() + .position(|s| !s.base && s.kind == kind && s.bytes == bytes) + .unwrap(); + assert!( + save( + &workspace, + file, + Resolution::Text("not allowed".into()), + &|| false + ) + .is_err() + ); + let outcome = save(&workspace, file, Resolution::Source(source), &|| false).unwrap(); + assert_eq!( + outcome.file.status, + Status::Resolved, + "{:?}", + outcome.confirmation + ); + workspace.checkout = outcome.checkout; + workspace.files[file] = outcome.file; + if kind == FileKind::Symlink { + assert_eq!( + fs::read_link(fixture.0.join(name)).unwrap(), + Path::new("left-target") + ); + } else { + assert_eq!(fs::read(fixture.0.join(name)).unwrap(), bytes); + } + if kind == FileKind::Binary { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + fs::metadata(fixture.0.join(name)) + .unwrap() + .permissions() + .mode() + & 0o111, + 0o111 + ); + } + } + // add/add conflicts retain an absent base as an explicit deletion choice. + let file = workspace + .files + .iter() + .position(|f| f.path == Path::new("binary")) + .unwrap(); + let source = workspace.files[file] + .sources + .iter() + .position(|s| s.kind == FileKind::Deleted) + .unwrap(); + let outcome = save(&workspace, file, Resolution::Source(source), &|| false).unwrap(); + assert_eq!( + outcome.file.status, + Status::Resolved, + "{:?}", + outcome.confirmation + ); + assert!(!fixture.0.join("binary").exists()); + } + #[test] + fn marker_styles_widths_multisides_and_missing_eol() { + let snapshot = "before\n<<<<<<<<<<< conflict 1 of 1\n+++++++++++ a\nleft\n----------- base\nold\n+++++++++++ b\nright\n----------- base2\nold2\n+++++++++++ c\nthird\n>>>>>>>>>>> conflict 1 of 1 ends\nafter\n"; + let parsed = regions(snapshot); + assert_eq!(parsed[0].lines, 1..13); + assert_eq!(parsed[0].sides, ["left\n", "right\n", "third\n"]); + assert_eq!(parsed[0].bases, ["old\n", "old2\n"]); + let prefixed = format!("<<<<<<< literal marker\n{snapshot}"); + assert_eq!(regions(&prefixed).len(), 1); + assert_eq!(regions(&prefixed)[0].lines, 2..14); + let diff = "<<<<<<< conflict 1 of 1\n%%%%%%% diff from: base\n^^^^^^^ to: left\n-old\n+left\n+++++++ right\nright\n>>>>>>> conflict 1 of 1 ends\n"; + assert_eq!(regions(diff)[0].sides, ["left\n", "right\n"]); + let git = "<<<<<<< left\nleft\n||||||| base\nold\n=======\nright\n>>>>>>> right"; + assert_eq!(regions(git)[0].sides, ["left", "right"]); + assert!(regions("<<<<<<< unfinished\nleft\n").is_empty()); + } +} diff --git a/src/session.rs b/src/session.rs index 5736e57..cdf728e 100644 --- a/src/session.rs +++ b/src/session.rs @@ -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); +struct ResolverWorker { + requests: Option>, + results: Receiver, + stopped: Arc, + thread: Option>, +} + +impl ResolverWorker { + fn start() -> Result { + let (requests, incoming) = mpsc::channel::(); + 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, diff_workers: BTreeMap, + resolver_worker: Option, blame_worker: Option, blame_source: Option, blame_lines: Option>>>, @@ -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}; diff --git a/src/tui.rs b/src/tui.rs index ba84e45..1853f27 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -39,8 +39,8 @@ use crate::{ ChangedFile, DiffState, DisplayRow, FileStatus, Focus, PaneLayout, Phase, RowKind, ViewMode, }, editor::{ - Completion, Cursor, Editor, Hover, Input, Mode, Palette, Selection, StartupDiagnostic, - WhitespaceScope, + Completion, Cursor, Editor, Hover, Input, Mode, Palette, ResolverStatus, ResolverWorkspace, + Selection, StartupDiagnostic, WhitespaceScope, }, files::{DockSide, FilePicker, FilePresentation, FileView}, panes::{PaneRect, SplitAxis, split_rect}, @@ -450,6 +450,10 @@ impl BufferViewports { self.syntax.retain(|id, _| editor.has_buffer(*id)); let syntax = self.syntax.entry(editor.buffer_id()).or_default(); let full_workspace = draw_bars(frame, editor, frame.area(), syntax); + if editor.resolver().is_some() { + self.draw_resolver(frame, editor, full_workspace); + return; + } let multiple = editor.pane_layout().len() > 1; let (workspace, dock) = match editor.file_picker() { Some(picker) if picker.presentation == FilePresentation::Docked => dock_layout( @@ -585,6 +589,87 @@ impl BufferViewports { .any(|geometry| geometry.viewport.is_some()); } + fn draw_resolver(&mut self, frame: &mut Frame, editor: &mut Editor, area: Rect) { + let focused = editor.pane_id(); + let resolver = editor.resolver().expect("resolver workspace is active"); + let layout = resolver_layout(area, resolver, focused); + frame.render_widget(Clear, area); + draw_resolver_heading(frame, resolver, layout.heading, layout.selector); + self.rects.clear(); + if let Some(file) = resolver.files.get(resolver.current_file) { + for (index, panel) in &layout.sources { + if let (Some(pane), Some(source)) = + (resolver.source_panes.get(*index), file.sources.get(*index)) + { + let content = resolver_document_panel( + frame, + *panel, + &format!("{} · readonly", source.label), + !resolver.files_focused && focused == *pane, + ); + self.rects.push((*pane, content)); + } + } + let result = resolver_document_panel( + frame, + layout.result, + &format!("Result · {}", resolver_status(&file.status).0), + !resolver.files_focused && focused == resolver.result_pane, + ); + if file.text { + self.rects.push((resolver.result_pane, result)); + } else { + draw_resolver_choices(frame, resolver, result); + } + } + editor.set_pane_bounds(pane_rect(area)); + for active_pass in [false, true] { + for index in 0..self.rects.len() { + let (id, leaf) = self.rects[index]; + if (id == focused) != active_pass { + continue; + } + self.activate(id, editor); + self.panels.workspace_managed = true; + editor.with_render_pane(id, |view| { + self.activate_document(view.buffer_id()); + let (top, left) = self.offsets.entry(view.buffer_id()).or_default(); + let syntax = self.syntax.entry(view.buffer_id()).or_default(); + draw_document( + frame, + view, + leaf, + area, + top, + left, + syntax, + &mut self.document, + &mut self.panels, + ); + }); + } + } + self.activate(focused, editor); + self.panels.workspace_managed = false; + if !self.rects.iter().any(|(id, _)| *id == focused) { + self.panels.begin_frame(); + self.document.geometry = None; + } + let resolver = editor.resolver().expect("resolver workspace is active"); + self.panels.resolver_files = layout.files; + if let Some(files) = layout.files { + draw_resolver_files( + frame, + resolver, + files, + editor.file_navigation_hint(), + &mut self.panels, + ); + } + draw_overlays(frame, editor, area, self.panels.caret, &mut self.panels); + self.diff_visible = false; + } + fn draw_session(&mut self, frame: &mut Frame, session: &mut EditorSession) { self.draw(frame, session.editor_mut()); if let Some((comparison_id, file, top, left_before, left_after)) = self @@ -1493,6 +1578,333 @@ fn draw_bars(frame: &mut Frame, editor: &Editor, area: Rect, syntax: &mut Syntax workspace } +struct ResolverLayout { + heading: Rect, + selector: Rect, + sources: Vec<(usize, Rect)>, + result: Rect, + files: Option, +} + +fn resolver_layout(area: Rect, resolver: &ResolverWorkspace, focused: usize) -> ResolverLayout { + let [heading, selector, workspace] = Layout::vertical([ + Constraint::Length(u16::from(area.height >= 4)), + Constraint::Length(u16::from(area.height >= 7)), + Constraint::Fill(1), + ]) + .areas(area); + let (workspace, files) = if area.width >= 76 { + let [documents, files] = Layout::horizontal([ + Constraint::Fill(1), + Constraint::Length((area.width / 4).clamp(24, 36)), + ]) + .areas(workspace); + (documents, Some(files)) + } else if resolver.files_focused { + (Rect::default(), Some(workspace)) + } else { + (workspace, None) + }; + let mut layout = ResolverLayout { + heading, + selector, + sources: Vec::with_capacity(2), + result: workspace, + files, + }; + let Some(file) = resolver.files.get(resolver.current_file) else { + return layout; + }; + if file.sources.is_empty() || workspace.is_empty() { + return layout; + } + let selected = resolver.selected_source.min(file.sources.len() - 1); + if workspace.height < 4 { + if resolver.source_panes.get(selected) == Some(&focused) { + layout.sources.push((selected, workspace)); + layout.result = Rect::default(); + } + return layout; + } + let [sources, result] = + Layout::vertical([Constraint::Ratio(1, 2), Constraint::Fill(1)]).areas(workspace); + layout.result = result; + if workspace.width >= 88 + && let Some(other) = file + .sources + .iter() + .enumerate() + .find(|(index, source)| *index != selected && !source.base) + .or_else(|| { + file.sources + .iter() + .enumerate() + .find(|(index, _)| *index != selected) + }) + .map(|(index, _)| index) + { + let [left, right] = + Layout::horizontal([Constraint::Ratio(1, 2), Constraint::Fill(1)]).areas(sources); + layout + .sources + .extend([(selected.min(other), left), (selected.max(other), right)]); + } else { + layout.sources.push((selected, sources)); + } + layout +} + +fn resolver_status(status: &ResolverStatus) -> (&'static str, Color) { + match status { + ResolverStatus::Unresolved => ("unresolved", Color::Yellow), + ResolverStatus::Unsaved => ("edited · unsaved", Color::Yellow), + ResolverStatus::Checking => ("saved · checking jj", Color::Cyan), + ResolverStatus::Resolved => ("resolved", Color::Green), + ResolverStatus::Unconfirmed => ("unconfirmed · jj check failed", Color::Red), + } +} + +fn draw_resolver_heading( + frame: &mut Frame, + resolver: &ResolverWorkspace, + heading: Rect, + selector: Rect, +) { + let file = resolver.files.get(resolver.current_file); + let status = if resolver.loading { + ("loading", Color::Cyan) + } else { + file.map_or(("no conflicted files", Color::Green), |file| { + resolver_status(&file.status) + }) + }; + let mut spans = vec![ + Span::styled( + format!("Resolve · {} ", status.0), + Style::default().fg(status.1), + ), + Span::raw(file.map_or_else(|| Cow::Borrowed(""), |file| display_path(&file.path))), + ]; + if let Some(error) = resolver + .error + .as_deref() + .or_else(|| file.and_then(|file| file.error.as_deref())) + { + spans.push(Span::styled( + format!(" · {}", display_text(error)), + Style::default().fg(Color::Red), + )); + } + frame.render_widget(Paragraph::new(Line::from(spans)), heading); + if let Some(file) = file { + let bases = file.sources.iter().filter(|source| source.base).count(); + frame.render_widget( + Paragraph::new(format!( + "Input {}/{} · Base {bases} · :resolve.source N / :resolve.base N · Tab panes/files", + resolver.selected_source + 1, + file.sources.len(), + )) + .style(Style::default().fg(Color::DarkGray)), + selector, + ); + } +} + +fn resolver_document_panel(frame: &mut Frame, panel: Rect, title: &str, focused: bool) -> Rect { + let [heading, document] = Layout::vertical([ + Constraint::Length(u16::from(panel.height >= 2)), + Constraint::Fill(1), + ]) + .areas(panel); + frame.render_widget( + Paragraph::new(display_text(title)).style( + Style::default() + .fg(if focused { + Color::Cyan + } else { + Color::DarkGray + }) + .add_modifier(Modifier::BOLD), + ), + heading, + ); + document +} + +fn draw_resolver_choices(frame: &mut Frame, resolver: &ResolverWorkspace, area: Rect) { + let Some(file) = resolver.files.get(resolver.current_file) else { + return; + }; + let mut lines = vec![ + Line::from("Whole-file conflict · text editing is unavailable") + .style(Style::default().fg(Color::Yellow)), + Line::from( + file.whole_file_choice + .and_then(|index| file.sources.get(index)) + .map_or_else( + || "No whole-file choice selected.".to_owned(), + |source| format!("Selected: {}", display_text(&source.label)), + ), + ), + Line::from("Choose an explicit input, then :w to save and check jj."), + ]; + lines.extend( + file.sources + .iter() + .enumerate() + .filter(|(_, source)| !source.base) + .map(|(index, source)| { + Line::from(format!( + "{} :resolve.use_source {} · {}", + if file.whole_file_choice == Some(index) { + ">" + } else { + " " + }, + index + 1, + display_text(&source.label), + )) + }), + ); + frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), area); +} + +fn draw_resolver_files( + frame: &mut Frame, + resolver: &ResolverWorkspace, + panel: Rect, + navigation_hint: &str, + panels: &mut WheelPanels, +) { + if panel.is_empty() { + return; + } + let bordered = panel.width >= 4 && panel.height >= 4; + let block = Block::default() + .borders(if bordered { + Borders::ALL + } else { + Borders::NONE + }) + .border_style(Style::default().fg(if resolver.files_focused { + Color::Cyan + } else { + Color::DarkGray + })) + .title(" Conflicts "); + let content = block.inner(panel); + frame.render_widget(Clear, panel); + frame.render_widget(block, panel); + let [query, entries, help] = Layout::vertical([ + Constraint::Length(u16::from(content.height >= 2)), + Constraint::Fill(1), + Constraint::Length(u16::from(content.height >= 5)), + ]) + .areas(content); + let prompt = if resolver.filtering { + format!("> {}", display_text(&resolver.filter)) + } else if resolver.filter.is_empty() { + "/ filter".to_owned() + } else { + format!("filter: {}", display_text(&resolver.filter)) + }; + let prompt_width = display_column(&prompt, usize::MAX); + let prompt_offset = if resolver.filtering { + prompt_width.saturating_sub(usize::from(query.width.saturating_sub(1))) + } else { + 0 + }; + frame.render_widget( + Paragraph::new(visible_line( + &prompt, + prompt_offset, + usize::from(query.width), + )) + .style(Style::default().fg(Color::Yellow)), + query, + ); + let filter = resolver.filter.to_lowercase(); + let files: Vec<_> = resolver + .files + .iter() + .enumerate() + .filter(|(_, file)| file.path.to_string_lossy().to_lowercase().contains(&filter)) + .collect(); + let selected = files + .iter() + .position(|(index, _)| *index == resolver.selected_file) + .unwrap_or(0); + let top = PickerViewport::follow( + &mut panels.resolver_scroll, + 0, + selected, + usize::from(entries.height), + 1, + Some(0), + ); + let lines: Vec<_> = files + .iter() + .skip(top) + .take(usize::from(entries.height)) + .map(|(index, file)| { + let (status, color) = resolver_status(&file.status); + let status = match file.status { + ResolverStatus::Unsaved => "unsaved", + ResolverStatus::Checking => "checking", + ResolverStatus::Unconfirmed => "unconfirmed", + _ => status, + }; + Line::from(vec![ + Span::styled(format!("{status} "), Style::default().fg(color)), + Span::raw(display_path(&file.path)), + ]) + .style(if *index == resolver.selected_file { + Style::default() + .bg(Color::Blue) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + }) + }) + .collect(); + frame.render_widget( + Paragraph::new(if files.is_empty() { + vec![Line::from("No matching files")] + } else { + lines + }), + entries, + ); + frame.render_widget( + Paragraph::new(format!("{navigation_hint} · Enter open · Tab panes")) + .style(Style::default().fg(Color::DarkGray)), + help, + ); + if resolver.files_focused { + let position = if resolver.filtering && !query.is_empty() { + Some(Position::new( + query.x + + (prompt_width - prompt_offset).min(usize::from(query.width.saturating_sub(1))) + as u16, + query.y, + )) + } else if !entries.is_empty() + && selected >= top + && selected - top < usize::from(entries.height) + { + Some(Position::new( + entries.x, + entries.y + (selected - top) as u16, + )) + } else { + None + }; + if let Some(position) = position { + frame.set_cursor_position(position); + } + } +} + #[cfg(test)] fn draw( frame: &mut Frame, @@ -4019,6 +4431,8 @@ struct WheelPanels { diff_syntax: Option, previous_diff: Option, diff_wrap: Option, + resolver_files: Option, + resolver_scroll: Option, } struct PickerScroll { @@ -4129,6 +4543,7 @@ impl WheelPanels { self.completion = None; self.files = None; self.previous_diff = self.diff.take(); + self.resolver_files = None; } fn follow_files(&mut self) { @@ -4227,6 +4642,18 @@ impl WheelPanels { } return true; } + if self + .resolver_files + .is_some_and(|area| area.contains(position)) + { + if !horizontal { + session.editor_mut().focus_resolver_files(); + for _ in 0..3 { + session.handle(if down { Input::Down } else { Input::Up }); + } + } + return true; + } let pane = session.editor().pane_id(); let floating_files = editor.file_picker().is_some_and(|picker| { picker.presentation == FilePresentation::Floating @@ -6083,6 +6510,271 @@ mod tests { .join("\n") } + #[test] + fn resolver_renders_native_result_sources_explorer_and_narrow_focus() { + let directory = Directory::new(); + let jj = |args: &[&str]| { + let output = std::process::Command::new("jj") + .current_dir(&directory.0) + .env("JJ_CONFIG", "/dev/null") + .args([ + "--config=user.name=Resolver", + "--config=user.email=resolver@example.test", + "--config=debug.commit-timestamp=\"2001-01-01T00:00:00Z\"", + "--config=debug.operation-timestamp=\"2001-01-01T00:00:00Z\"", + ]) + .args(args) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout).unwrap().trim().to_owned() + }; + let text = |side: &str| { + format!( + "{side}\n{}", + (0..60) + .map(|row| format!("context {row:02}\n")) + .collect::() + ) + }; + jj(&["git", "init", "--no-colocate", "."]); + jj(&["config", "set", "--repo", "user.name", "Resolver"]); + jj(&[ + "config", + "set", + "--repo", + "user.email", + "resolver@example.test", + ]); + for path in ["first.txt", "second.txt"] { + directory.file(path, &text("base")); + } + fs::write(directory.0.join("binary.bin"), b"base\0").unwrap(); + jj(&["describe", "-m", "base"]); + let base = jj(&["log", "--no-graph", "-r", "@", "-T", "commit_id"]); + jj(&["new", &base]); + for path in ["first.txt", "second.txt"] { + directory.file(path, &text("left")); + } + fs::write(directory.0.join("binary.bin"), b"left\0").unwrap(); + jj(&["describe", "-m", "left"]); + let left = jj(&["log", "--no-graph", "-r", "@", "-T", "commit_id"]); + jj(&["new", &base]); + for path in ["first.txt", "second.txt"] { + directory.file(path, &text("right")); + } + fs::write(directory.0.join("binary.bin"), b"right\0").unwrap(); + jj(&["describe", "-m", "right"]); + let right = jj(&["log", "--no-graph", "-r", "@", "-T", "commit_id"]); + jj(&["new", &left, &right, "-m", "conflicts"]); + let operation = || { + jj(&[ + "--ignore-working-copy", + "op", + "log", + "--no-graph", + "-n", + "1", + "-T", + "id", + ]) + }; + let before_browsing = operation(); + let config = directory.file("config.rb", + "Vamp.configure do |config|\n config.clipboard_mode = 'separate'\n config.bind 'ctrl+w k', config.core.pane.focus_up\n config.bind 'ctrl+w j', config.core.pane.focus_down\nend\n"); + let mut session = EditorSession::open( + Some(directory.0.join("first.txt")), + vec!["text".into()], + Some(&config), + ) + .unwrap(); + let ready = |session: &mut EditorSession| { + for _ in 0..15_000 { + session.poll(); + if session.editor().resolver().is_some_and(|resolver| { + !resolver.loading + && !resolver + .files + .iter() + .any(|file| file.status == ResolverStatus::Checking) + }) { + assert!( + session.editor().resolver().unwrap().error.is_none(), + "{}", + session.editor().message() + ); + return; + } + std::thread::sleep(Duration::from_millis(1)); + } + panic!("Resolver did not settle: {}", session.editor().message()); + }; + session.execute("resolve"); + ready(&mut session); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 30)).unwrap(); + let mut views = BufferViewports::default(); + let render = |terminal: &mut Terminal, + views: &mut BufferViewports, + session: &mut EditorSession| { + terminal + .draw(|frame| views.draw_session(frame, session)) + .unwrap(); + terminal.get_cursor_position().unwrap() + }; + let caret = render(&mut terminal, &mut views, &mut session); + let resolver = session.editor().resolver().unwrap(); + assert_eq!(resolver.files.len(), 3); + let current_file = resolver.current_file; + let result_pane = resolver.result_pane; + let result = views + .rects + .iter() + .find(|(pane, _)| *pane == result_pane) + .unwrap() + .1; + assert!(result.contains(caret)); + assert_eq!(views.rects.len(), 3); + assert!( + views + .rects + .iter() + .filter(|(pane, _)| *pane != result_pane) + .all(|(_, source)| source.bottom() < result.y && source.width < result.width) + ); + let screen = screen_text(&terminal); + assert!(screen.contains("Result") && screen.contains("unresolved")); + assert!(screen.contains("Base 1") && screen.contains("readonly")); + assert!(views.panels.resolver_files.unwrap().x >= result.right()); + + session.handle(Input::Char('\t')); + assert_ne!(session.editor().pane_id(), result_pane); + session.handle(Input::BackTab); + assert_eq!(session.editor().pane_id(), result_pane); + session.handle(Input::Ctrl('w')); + session.handle(Input::Char('k')); + assert_ne!(session.editor().pane_id(), result_pane); + session.handle(Input::Ctrl('w')); + session.handle(Input::Char('j')); + assert_eq!(session.editor().pane_id(), result_pane); + session.execute("resolve.base"); + let resolver = session.editor().resolver().unwrap(); + assert!(resolver.files[current_file].sources[resolver.selected_source].base); + let selected_pane = resolver.source_panes[resolver.selected_source]; + terminal.backend_mut().resize(44, 20); + let caret = render(&mut terminal, &mut views, &mut session); + assert_eq!(views.rects.len(), 2); + let source = views + .rects + .iter() + .find(|(pane, _)| *pane == selected_pane) + .unwrap() + .1; + assert!(source.contains(caret)); + assert!(screen_text(&terminal).contains("base")); + let original = session.editor().lines().to_vec(); + session.handle(Input::Char('i')); + session.handle(Input::Char('X')); + session.handle(Input::Escape); + assert_eq!(session.editor().lines(), original); + session.execute("resolve.result"); + let caret = render(&mut terminal, &mut views, &mut session); + let cursor = session.editor().cursor(); + assert!(views.scroll( + &mut session, + document_wheel(MouseEventKind::ScrollDown, source.x, source.y), + )); + assert_eq!(session.editor().pane_id(), result_pane); + assert_eq!(session.editor().cursor(), cursor); + assert!( + views + .rects + .iter() + .find(|(pane, _)| *pane == result_pane) + .unwrap() + .1 + .contains(caret) + ); + session.execute("resolve.files"); + session.handle(Input::Down); + render(&mut terminal, &mut views, &mut session); + assert_eq!( + session.editor().resolver().unwrap().current_file, + current_file + ); + assert!(screen_text(&terminal).contains("Conflicts")); + session.handle(Input::Char('/')); + for ch in "second".chars() { + session.handle(Input::Char(ch)); + } + render(&mut terminal, &mut views, &mut session); + assert!(screen_text(&terminal).contains("second.txt")); + assert!(!screen_text(&terminal).contains("binary.bin")); + session.handle(Input::Escape); + session.execute("resolve.result"); + assert_eq!( + operation(), + before_browsing, + "Rendering/navigation must not snapshot jj" + ); + + session.handle(Input::Char('i')); + session.handle(Input::Char('X')); + session.handle(Input::Escape); + render(&mut terminal, &mut views, &mut session); + assert!(screen_text(&terminal).contains("unsaved")); + session.handle(Input::Char('u')); + let resolver = session.editor().resolver().unwrap(); + let side = resolver.files[current_file] + .sources + .iter() + .position(|source| !source.base) + .unwrap() + + 1; + session.execute(&format!("resolve.use_source {side}")); + session.execute("w"); + render(&mut terminal, &mut views, &mut session); + assert!(screen_text(&terminal).contains("checking")); + ready(&mut session); + render(&mut terminal, &mut views, &mut session); + let resolver = session.editor().resolver().unwrap(); + assert_eq!(resolver.files.len(), 3); + assert_eq!( + resolver.files[current_file].status, + ResolverStatus::Resolved + ); + assert!(screen_text(&terminal).contains("resolved")); + let binary = resolver + .files + .iter() + .position(|file| file.path == std::path::Path::new("binary.bin")) + .unwrap(); + session.editor_mut().select_resolver_file(binary); + terminal.backend_mut().resize(120, 30); + render(&mut terminal, &mut views, &mut session); + assert!(screen_text(&terminal).contains("Whole-file conflict")); + assert!(!views.rects.iter().any(|(pane, _)| *pane == result_pane)); + let resolver = session.editor().resolver().unwrap(); + let side = resolver.files[binary] + .sources + .iter() + .position(|source| !source.base) + .unwrap() + + 1; + session.execute(&format!("resolve.use_source {side}")); + render(&mut terminal, &mut views, &mut session); + assert!(screen_text(&terminal).contains("Selected:")); + for (width, height) in [(4, 4), (1, 1), (0, 0)] { + terminal.backend_mut().resize(width, height); + terminal + .draw(|frame| views.draw_session(frame, &mut session)) + .unwrap(); + } + } + fn diff_catalog() -> crate::diff::Catalog { use crate::diff::{Catalog, Category, RepoKind, Revision, RevisionTarget}; let mut entries = vec![Revision { diff --git a/tests/resolve.rs b/tests/resolve.rs new file mode 100755 index 0000000..0a954f8 --- /dev/null +++ b/tests/resolve.rs @@ -0,0 +1,262 @@ +#![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::() + ) + } + 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); + assert_eq!(session.editor().context().text, draft); + 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 + ); + let source = session.editor().resolver().unwrap().files[0] + .sources + .iter() + .position(|source| !source.base) + .unwrap() + + 1; + session.execute(&format!("resolve.use_source {source}")); + assert_ne!(session.editor().context().text, draft); + assert!(session.editor().context().text.starts_with("draft\n")); + session.handle(Input::Char('u')); + assert_eq!(session.editor().context().text, draft); + session.execute(&format!("resolve.use_source {source}")); + session.execute("resolve.next"); + session.execute(&format!("resolve.use_source {source}")); + let resolved_text = session.editor().context().text; + assert!(resolved_text.starts_with("draft\n")); + assert!(resolved_text.contains("context_15\n")); + session.execute("w"); + ready(&mut session); + let resolver = session.editor().resolver().unwrap(); + assert_eq!(resolver.files.len(), 2); + assert_eq!( + resolver.files[resolver.selected_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()); +}