diff --git a/README.md b/README.md index df0dd36..18352a8 100644 --- a/README.md +++ b/README.md @@ -40,7 +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 | +| Resolve jj working-copy conflicts | `:resolve`; edit marker-free Draft versus Alternative, `:resolve.history` for inputs/base, `: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/docs/architecture.md b/docs/architecture.md index 1136fb4..4ef26cd 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -78,7 +78,7 @@ 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. +The resolver owns a dedicated native tab with an editable Draft and frozen read-only Alternatives projected from the current raw working buffer. Projection replaces only conflict regions; automatically merged text and pre-existing outside edits remain identical across initial alternatives. Native undo includes initial seeding. Resolution reuses DiffState alignment, excerpts, highlighting and viewport mapping, including equal-text editing. A separate read-only History uses the original jj inputs/bases; native buffer identity and frontend offsets retain draft/view state. `resolve` reads all alternating Git-backed `jj:trees` terms. Background guarded saves retain checkout/disk validation and jj snapshot/status authority; no presentation change bypasses these write guards. `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. diff --git a/docs/configuration.md b/docs/configuration.md index 50fa9e8..1ce804d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -223,7 +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 | `resolve`, `resolve.next`, `resolve.previous`, `resolve.use_source`, `resolve.source`, `resolve.base`, `resolve.next_file`, `resolve.previous_file`, `resolve.files`, `resolve.draft`, `resolve.history`, `resolve.view.toggle`, `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 a5850cf..97d41ff 100644 --- a/docs/development.md +++ b/docs/development.md @@ -58,7 +58,7 @@ 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. +Resolver checks use isolated real jj repositories: read-only discovery, marker-free all-side/base projections preserving outside edits and EOF, initial seed undo, hunk choice/undo, retained resolved files, jj-authoritative status, checkout/external-write refusal, cancellation, nontext bytes/types/modes/deletion, and directory-child protection. Render checks cover aligned gaps/highlights, context folding, native Draft focus/search/selection, separate History, hidden/on-demand explorer, viewport retention and narrow layouts. Live PTY smoke exercises frozen marker-free alternatives, History/Draft switching, hunk choice and guarded save confirmation 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. diff --git a/docs/editing.md b/docs/editing.md index d5d75ec..3ea61f8 100644 --- a/docs/editing.md +++ b/docs/editing.md @@ -458,21 +458,24 @@ Binary data, missing final newlines, permission/type changes, symlink targets, a ## 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. +Run **`:resolve`** in Normal mode for the current jj working copy. It opens a dedicated tab and selects the current conflicted file (otherwise the first conflict). 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. +**Resolution** shows an editable **Draft** on the left and a read-only **Alternative** on the right, with aligned rows, line/character highlighting, context folds and synchronized scrolling. Both are projected from the current working buffer, including pre-existing unsaved edits: only conflict-marker regions are replaced by their selected alternatives. Already-merged text and edits outside those regions appear on both sides. The initial Draft is seeded from the first side, labelled in its border, and is a normal undoable buffer change; opening never writes it to disk. Alternatives remain frozen while later edits change only the Draft. | 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` / `resolve.previous` | Jump between differing decision hunks | +| `resolve.source [N]` | Select/focus an Alternative; in History, select an outer input; without N, cycle | +| `resolve.base [N]` | Open History and select a numbered base; default first base | +| `resolve.use_source [N]` | Take the current differing hunk from that Alternative as one undoable edit | | `resolve.next_file` / `resolve.previous_file` | Open another retained file | -| `resolve.files` / `resolve.result` | Focus the file list / editable Result | +| `resolve.files` / `resolve.draft` | Toggle/focus the file explorer / return to the editable Draft | +| `resolve.history` / `resolve.view.toggle` | Show read-only input/base/input History / switch views | | `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”. +**History** is separate from editing: it compares the original inputs and their base, with all sides and bases selectable for multi-sided conflicts. Draft text, undo, caret and viewport survive view switches. The file explorer is hidden by default and available on demand. Narrow Resolution panes retain side-by-side comparison where usable; narrow History shows the focused input. Native editing, copying, search and remapped pane navigation work normally; read-only Alternatives and History reject edits and saves. + +Input indexes are one-based and include bases. Commands appear in the palette and through corresponding `config.core.resolve` actions; no new global default bindings are installed. Normal-mode Tab/Shift+Tab cycles visible panes and an open file list. In the list, `/` filters and Enter opens a file. The complete seeded Draft can be saved even when intentional differences remain against the Alternative: those differences are not unresolved conflict markers. 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. diff --git a/src/diff.rs b/src/diff.rs index a93325b..51344a3 100644 --- a/src/diff.rs +++ b/src/diff.rs @@ -350,6 +350,87 @@ impl DiffState { } } + pub(crate) fn set_resolver_text(&mut self, draft: &[String], alternative: &[String]) { + fn content(lines: &[String]) -> (&[String], bool) { + let eof = lines.len() > 1 && lines.last().is_some_and(String::is_empty); + let content = if eof { + &lines[..lines.len() - 1] + } else { + lines + }; + ( + if !eof && content.len() == 1 && content[0].is_empty() { + &[] + } else { + content + }, + eof, + ) + } + let (before, before_eof) = content(draft); + let (after, after_eof) = content(alternative); + if self.comparison.as_ref().is_some_and(|comparison| { + let file = &comparison.files[0]; + file.before == before + && file.after == after + && file.before_eof == before_eof + && file.after_eof == after_eof + }) { + return; + } + let viewport = (self.top, self.left_before, self.left_after); + self.phase = Phase::View; + self.loading = false; + self.pending = None; + self.request_kind = None; + self.files_visible = false; + self.comparison_id = Some(self.comparison_id.unwrap_or(0).wrapping_add(1)); + self.file = 0; + self.live_files.clear(); + self.comparison = Some(Comparison { + before: "Draft".into(), + after: "Alternative".into(), + root: PathBuf::new(), + editable_paths: BTreeSet::new(), + files: vec![ChangedFile { + path: PathBuf::new(), + old_path: None, + status: FileStatus::Modified, + loaded: true, + before: before.to_vec(), + after: after.to_vec(), + before_eof, + after_eof, + hunks: line_hunks(before, before_eof, after, after_eof), + notice: None, + }], + }); + self.focus = if self.focus == Focus::After { + Focus::After + } else { + Focus::Before + }; + self.align_file(); + let before_terminal = (draft.len() > before.len()).then_some(before.len()); + let after_terminal = (alternative.len() > after.len()).then_some(after.len()); + if before_terminal.is_some() || after_terminal.is_some() { + self.rows.push(AlignedRow { + before: before_terminal, + after: after_terminal, + kind: RowKind::Equal, + changes: Vec::new(), + }); + self.reset_excerpts(); + } + if self.comparison.as_ref().unwrap().files[0].hunks.is_empty() { + self.excerpt_ranges = vec![(0, self.rows.len())]; + self.rebuild_display_rows(); + } + self.top = viewport.0.min(self.display_rows.len().saturating_sub(1)); + self.left_before = viewport.1.min(self.max_before); + self.left_after = viewport.2.min(self.max_after); + } + pub(crate) fn pull_requests(remote: String) -> Self { Self { pull_request_remote: Some(remote.clone()), @@ -2291,6 +2372,28 @@ mod tests { pub(super) static WIDTH_SCANS: std::cell::Cell = const { std::cell::Cell::new(0) }; } + #[test] + fn resolver_equal_text_retains_native_rows_and_cached_viewport() { + for text in ["", "\n", "same\n", "same"] { + let lines: Vec<_> = text.split('\n').map(str::to_owned).collect(); + let mut state = DiffState::new(); + state.set_resolver_text(&lines, &lines); + assert_eq!(state.pane_layout(), PaneLayout::Split); + assert!(state.comparison.as_ref().unwrap().files[0].hunks.is_empty()); + for row in 0..lines.len() { + assert!(state.source_display_row(Focus::Before, row).is_some()); + assert!(state.source_display_row(Focus::After, row).is_some()); + } + let id = state.comparison_id; + state.top = 1; + state.left_before = 3; + state.left_after = 4; + state.set_resolver_text(&lines, &lines); + assert_eq!(state.comparison_id, id); + assert_eq!((state.top, state.left_before, state.left_after), (1, 3, 4)); + } + } + fn excerpt_state(len: usize, changed: &[usize]) -> DiffState { let mut state = DiffState::new(); state.phase = Phase::View; diff --git a/src/editor.rs b/src/editor.rs index 8a0ca6e..9909763 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -843,8 +843,20 @@ core_actions![ true ), ( - "core.resolve.result", - Action::ResolveResult, + "core.resolve.draft", + Action::ResolveDraft, + &[Mode::Normal], + true + ), + ( + "core.resolve.history", + Action::ResolveHistory, + &[Mode::Normal], + true + ), + ( + "core.resolve.view.toggle", + Action::ResolveViewToggle, &[Mode::Normal], true ), @@ -1460,14 +1472,24 @@ pub struct ResolverSource { pub base: bool, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ResolverView { + Resolution, + History, +} + pub struct ResolverFile { pub path: PathBuf, - pub result_buffer: usize, + pub draft_buffer: usize, pub status: ResolverStatus, pub sources: Vec, pub text: bool, pub whole_file_choice: Option, pub error: Option, + pub seed_source: usize, + projection: Option, + diff: DiffState, + comparison_key: Option<(u64, usize)>, } pub struct ResolverWorkspace { @@ -1475,8 +1497,13 @@ pub struct ResolverWorkspace { pub selected_file: usize, pub current_file: usize, pub selected_source: usize, - pub result_pane: usize, - pub source_panes: Vec, + pub draft_pane: usize, + pub alternative_pane: Option, + pub history_panes: Vec, + pub view: ResolverView, + pub history_sources: [usize; 2], + pub selected_base: Option, + pub files_visible: bool, pub files_focused: bool, pub loading: bool, pub filter: String, @@ -2156,23 +2183,19 @@ impl Editor { } pub fn focus_resolver_pane(&mut self, pane: usize) { - let Some(resolver) = self.resolvers.get_mut(&self.tab_id) else { + if !self.resolver_visible_panes().contains(&pane) { 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.resolvers.get_mut(&self.tab_id).unwrap().files_focused = false; self.select_pane(pane); + self.refresh_resolver_comparison(); } pub fn focus_resolver_files(&mut self) { self.finish_pane_interaction(); if let Some(resolver) = self.resolvers.get_mut(&self.tab_id) { + resolver.files_visible = true; resolver.files_focused = true; } } @@ -2201,7 +2224,7 @@ impl Editor { .as_deref() .and_then(|path| path_identity(path).ok()); self.new_tab(); - let result_pane = self.pane_id; + let draft_pane = self.pane_id; self.resolvers.insert( self.tab_id, ResolverWorkspace { @@ -2209,8 +2232,13 @@ impl Editor { selected_file: 0, current_file: 0, selected_source: 0, - result_pane, - source_panes: Vec::new(), + draft_pane, + alternative_pane: None, + history_panes: Vec::new(), + view: ResolverView::Resolution, + history_sources: [0, 0], + selected_base: None, + files_visible: false, files_focused: false, loading: true, filter: String::new(), @@ -2237,10 +2265,10 @@ impl Editor { 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 { + let buffer = if active.id == file.draft_buffer { Some(active) } else { - parked.get(&file.result_buffer) + parked.get(&file.draft_buffer) }; if file.status != ResolverStatus::Checking && (buffer.is_some_and(Buffer::dirty) || file.whole_file_choice.is_some()) @@ -2306,7 +2334,7 @@ impl Editor { .text() .is_some_and(|text| validate_text(text).is_ok())) }); - let result_buffer = if let Some(id) = existing { + let draft_buffer = if let Some(id) = existing { id } else { let contents = if text { @@ -2340,10 +2368,38 @@ impl Editor { } } }; + let seed_source = file + .sources + .iter() + .position(|source| !source.base) + .unwrap_or(0); + let projection = if text { + let raw = self.buffer(draft_buffer).unwrap().lines.join("\n"); + match crate::resolve::Projection::new( + &raw, + &file.sources, + seed_source, + ) { + Ok(projection) => Some(projection), + Err(error) => { + let resolver = self.resolvers.get_mut(&tab).unwrap(); + resolver.error = + Some(format!("{}: {error}", file.path.display())); + resolver.loading = false; + return true; + } + } + } else { + None + }; files.push(ResolverFile { - path: file.path.clone(), result_buffer, + path: file.path.clone(), draft_buffer, status: ResolverStatus::Unresolved, text, whole_file_choice: None, error: file.notice.clone(), + seed_source, + projection, + diff: DiffState::new(), + comparison_key: None, 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(|| @@ -2351,6 +2407,37 @@ impl Editor { }).collect(), }); } + for file in &files { + let Some(projection) = &file.projection else { + continue; + }; + let draft_buffer = file.draft_buffer; + let buffer = if self.active.id == draft_buffer { + &mut self.active + } else { + self.parked.get_mut(&draft_buffer).unwrap() + }; + let seed = split_lines(projection.seed()); + if buffer.lines != seed { + buffer.undo.push(Snapshot { + lines: std::mem::replace(&mut buffer.lines, seed), + targets: if buffer.view.targets.is_empty() { + vec![Target::caret(Cursor::default())] + } else { + buffer.view.targets.clone() + }, + primary: buffer.view.primary, + }); + buffer.redo.clear(); + buffer.text_changed(); + buffer.view.clamp(&buffer.lines); + for pane in self.panes.values_mut() { + if let Some(view) = pane.views.get_mut(&draft_buffer) { + view.clamp(&buffer.lines); + } + } + } + } let resolver = self.resolvers.get_mut(&tab).unwrap(); resolver.files = files; resolver.selected_file = selected; @@ -2457,74 +2544,347 @@ impl Editor { let Some(selected) = resolver.files.get(file) else { return; }; - let result = selected.result_buffer; + let first_open = selected.diff.comparison.is_none(); + let draft = selected.draft_buffer; let sources: Vec<_> = selected .sources .iter() - .map(|source| (source.text.clone(), source.base)) + .map(|source| source.text.clone()) + .collect(); + let seed = selected.seed_source; + let side_indices: Vec<_> = selected + .sources + .iter() + .enumerate() + .filter(|(_, source)| !source.base) + .map(|(index, _)| index) + .collect(); + let base = selected.sources.iter().position(|source| source.base); + let draft_pane = resolver.draft_pane; + let old: Vec<_> = resolver + .history_panes + .iter() + .copied() + .chain(resolver.alternative_pane) .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); + self.select_pane(draft_pane); + self.switch_buffer(draft); 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 mut history_panes = Vec::new(); + for text in &sources { + history_panes.push(self.resolver_readonly_pane(text, draft_pane)); } + let alternative_pane = self.resolver_readonly_pane("", draft_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.selected_source = side_indices + .iter() + .copied() + .find(|index| *index != seed) + .unwrap_or(seed); + resolver.history_sources = [ + side_indices.first().copied().unwrap_or(0), + side_indices.get(1).copied().unwrap_or(seed), + ]; + resolver.selected_base = base; + resolver.history_panes = history_panes; + resolver.alternative_pane = Some(alternative_pane); + resolver.files[file].comparison_key = None; 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.refresh_resolver_comparison(); + if first_open + && let Some(row) = self + .resolver_diff() + .and_then(|diff| diff.comparison.as_ref()) + .and_then(|comparison| comparison.files[0].hunks.first()) + .map(|hunk| hunk.before_start) + { self.active.view.targets = vec![Target::caret(Cursor { - row: region.lines.start, + row: row.min(self.active.lines.len() - 1), column: 0, })]; self.active.view.primary = 0; self.reveal_caret(); + self.refresh_resolver_comparison(); + } + if self.resolver().unwrap().view == ResolverView::History { + self.set_resolver_view(ResolverView::History); + } + } + + fn resolver_readonly_pane(&mut self, text: &str, draft_pane: usize) -> usize { + let id = self.next_buffer_id; + let mut buffer = + Buffer::from_text(id, None, None, text, None).expect("resolver 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); + let pane = self.pane_id; + self.select_pane(draft_pane); + pane + } + + pub fn resolver_visible_panes(&self) -> Vec { + let Some(resolver) = self.resolver() else { + return Vec::new(); + }; + match resolver.view { + ResolverView::Resolution => std::iter::once(resolver.draft_pane) + .chain(resolver.alternative_pane) + .collect(), + ResolverView::History => { + let mut panes = Vec::new(); + for index in [ + Some(resolver.history_sources[0]), + resolver.selected_base, + Some(resolver.history_sources[1]), + ] + .into_iter() + .flatten() + { + if let Some(&pane) = resolver.history_panes.get(index) + && !panes.contains(&pane) + { + panes.push(pane); + } + } + panes + } + } + } + + fn set_resolver_view(&mut self, view: ResolverView) { + let Some(resolver) = self.resolvers.get_mut(&self.tab_id) else { + return; + }; + if view == ResolverView::History && resolver.history_panes.is_empty() { + return; + } + resolver.view = view; + resolver.files_focused = false; + let pane = match view { + ResolverView::Resolution => Some(resolver.draft_pane), + ResolverView::History => resolver + .history_panes + .get(resolver.history_sources[0]) + .copied(), + }; + if let Some(pane) = pane { + self.focus_resolver_pane(pane); + } + } + + pub fn resolver_diff(&self) -> Option<&DiffState> { + let resolver = self.resolver()?; + (resolver.view == ResolverView::Resolution) + .then(|| { + resolver + .files + .get(resolver.current_file) + .map(|file| &file.diff) + }) + .flatten() + } + + pub fn resolver_buffer_focus(&self) -> Option { + let resolver = self.resolver()?; + if resolver.view != ResolverView::Resolution || resolver.files_focused { + None + } else if self.pane_id == resolver.draft_pane { + Some(Focus::Before) + } else if Some(self.pane_id) == resolver.alternative_pane { + Some(Focus::After) + } else { + None + } + } + + fn resolver_diff_mut(&mut self) -> Option<&mut DiffState> { + let resolver = self.resolvers.get_mut(&self.tab_id)?; + resolver + .files + .get_mut(resolver.current_file) + .map(|file| &mut file.diff) + } + + pub(crate) fn refresh_resolver_comparison(&mut self) { + let Some(resolver) = self.resolver() else { + return; + }; + let file_index = resolver.current_file; + let Some(file) = resolver.files.get(file_index) else { + return; + }; + let Some(alternative_pane) = resolver.alternative_pane else { + return; + }; + let draft_buffer = file.draft_buffer; + let alternative_buffer = self.panes[&alternative_pane].buffer_id; + let source = resolver.selected_source; + let Some(buffer) = self.buffer(draft_buffer) else { + return; + }; + let key = (buffer.text_revision, source); + let changed = file.comparison_key != Some(key); + if changed { + if file.comparison_key.is_none_or(|(_, index)| index != source) { + let alternative = match &file.projection { + Some(projection) => projection.alternative(source), + None => Ok(file + .sources + .get(source) + .map(|source| source.text.as_str()) + .unwrap_or("")), + }; + let alternative = match alternative { + Ok(text) => split_lines(text), + Err(error) => { + let resolver = self.resolvers.get_mut(&self.tab_id).unwrap(); + resolver.files[file_index].error = Some(error); + return; + } + }; + let buffer = if self.active.id == alternative_buffer { + &mut self.active + } else { + self.parked.get_mut(&alternative_buffer).unwrap() + }; + if buffer.lines != alternative { + buffer.lines.clone_from(&alternative); + buffer.saved_lines = alternative; + buffer.text_changed(); + buffer.view.clamp(&buffer.lines); + for pane in self.panes.values_mut() { + if let Some(view) = pane.views.get_mut(&alternative_buffer) { + view.clamp(&buffer.lines); + } + } + } + } + let draft = if self.active.id == draft_buffer { + &self.active + } else { + &self.parked[&draft_buffer] + }; + let alternative = if self.active.id == alternative_buffer { + &self.active + } else { + &self.parked[&alternative_buffer] + }; + let file = &mut self.resolvers.get_mut(&self.tab_id).unwrap().files[file_index]; + file.diff + .set_context_options(self.diff_context_options.0, self.diff_context_options.1); + let previous_id = file.diff.comparison_id; + file.diff + .set_resolver_text(&draft.lines, &alternative.lines); + if file.diff.comparison_id != previous_id { + self.next_diff_request = self + .next_diff_request + .checked_add(1) + .expect("diff comparison IDs exhausted"); + file.diff.comparison_id = Some(self.next_diff_request); + } + file.comparison_key = Some(key); + } + let focus = self.resolver_buffer_focus(); + let row = self.cursor().row; + let follow = self.active.view.caret_reveal_revision; + if let Some(diff) = self.resolver_diff_mut() + && let Some(focus) = focus + { + let follow_caret = + changed || (diff.focus == focus && diff.live_revision != Some(follow)); + diff.focus = focus; + if follow_caret { + let top = diff.top; + diff.reveal_source_row(focus, row); + diff.top = top; + } + diff.follow_caret |= follow_caret; + diff.live_revision = Some(follow); + } + } + + pub(crate) fn set_resolver_viewport( + &mut self, + top: usize, + left_before: usize, + left_after: usize, + ) { + if let Some(diff) = self.resolver_diff_mut() { + diff.top = top; + diff.left_before = left_before; + diff.left_after = left_after; + diff.follow_caret = false; + } + } + + pub(crate) fn scroll_resolver_diff(&mut self, _focus: Focus, down: bool) { + if let Some(diff) = self.resolver_diff_mut() { + diff.top = if down { + diff.top.saturating_add(1) + } else { + diff.top.saturating_sub(1) + } + .min(diff.display_rows.len().saturating_sub(1)); + diff.follow_caret = false; + } + } + + pub(crate) fn scroll_resolver_diff_horizontal( + &mut self, + focus: Focus, + right: bool, + width: usize, + ) { + if let Some(diff) = self.resolver_diff_mut() { + diff.scroll_horizontal(focus, right, width); + } + } + + fn resolver_decision(&mut self, forward: bool) { + let Some(resolver) = self.resolver() else { + return; + }; + let pane = resolver.draft_pane; + self.set_resolver_view(ResolverView::Resolution); + self.focus_resolver_pane(pane); + let row = self.cursor().row; + let Some(diff) = self.resolver_diff() else { + return; + }; + let hunks = &diff.comparison.as_ref().unwrap().files[0].hunks; + let hunk = if forward { + hunks + .iter() + .find(|hunk| hunk.before_start > row) + .or(hunks.first()) + } else { + hunks + .iter() + .rev() + .find(|hunk| hunk.before_start < row) + .or(hunks.last()) + }; + if let Some(hunk) = hunk { + self.active.view.targets = vec![Target::caret(Cursor { + row: hunk.before_start.min(self.active.lines.len() - 1), + column: 0, + })]; + self.active.view.primary = 0; + self.reveal_caret(); + self.refresh_resolver_comparison(); + } else { + self.set_message("Draft and Alternative have no remaining differences"); } } @@ -2533,9 +2893,9 @@ impl Editor { self.finish_command_mode(); if action == Action::Resolve { if args.is_empty() { - self.open_resolver(); + self.open_resolver() } else { - self.set_message("Usage: :resolve"); + self.set_message("Usage: :resolve") } return; } @@ -2552,7 +2912,14 @@ impl Editor { return; } let source = if args.is_empty() { - Some(resolver.selected_source) + if action == Action::ResolveUseSource && resolver.view == ResolverView::History { + resolver + .history_panes + .iter() + .position(|pane| *pane == self.pane_id) + } else { + Some(resolver.selected_source) + } } else { args.parse::() .ok() @@ -2560,15 +2927,31 @@ impl Editor { }; match action { Action::ResolveNext | Action::ResolvePrevious => { - self.resolver_region(action == Action::ResolveNext, false) + self.resolver_decision(action == Action::ResolveNext) + } + Action::ResolveDraft => self.set_resolver_view(ResolverView::Resolution), + Action::ResolveHistory => self.set_resolver_view(ResolverView::History), + Action::ResolveViewToggle => { + self.set_resolver_view(if resolver.view == ResolverView::Resolution { + ResolverView::History + } else { + ResolverView::Resolution + }) + } + Action::ResolveFiles => { + if resolver.files_visible { + let resolver = self.resolvers.get_mut(&self.tab_id).unwrap(); + resolver.files_visible = false; + resolver.files_focused = false; + } else { + self.focus_resolver_files(); + } } - 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 + let next = (resolver.current_file + if action == Action::ResolveNextFile { 1 } else { @@ -2579,89 +2962,187 @@ impl Editor { } } Action::ResolveSource | Action::ResolveBase => { + let Some(file) = resolver.files.get(resolver.current_file) else { + return; + }; let index = if action == Action::ResolveBase { - let base = if args.is_empty() { Some(0) } else { source }; - base.and_then(|base| { + let bases: Vec<_> = file + .sources + .iter() + .enumerate() + .filter(|(_, source)| source.base) + .map(|(index, _)| index) + .collect(); + if args.is_empty() { + let next = resolver + .selected_base + .and_then(|base| bases.iter().position(|index| *index == base)) + .map_or(0, |index| (index + 1) % bases.len().max(1)); + bases.get(next).copied() + } else { + source.and_then(|index| bases.get(index).copied()) + } + } else if args.is_empty() { + let sides: Vec<_> = file + .sources + .iter() + .enumerate() + .filter(|(_, source)| !source.base) + .map(|(index, _)| index) + .collect(); + let current = if resolver.view == ResolverView::History { resolver - .files - .get(resolver.current_file)? - .sources + .history_panes .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()) + .position(|pane| *pane == self.pane_id) + .unwrap_or(resolver.selected_source) + } else { + resolver.selected_source + }; + let next = sides + .iter() + .position(|index| *index == current) + .map_or(0, |index| (index + 1) % sides.len().max(1)); + sides.get(next).copied() } else { source + .filter(|index| file.sources.get(*index).is_some_and(|source| !source.base)) }; - if let Some(pane) = index - .and_then(|index| resolver.source_panes.get(index)) - .copied() - { + let Some(index) = index else { + self.set_message("No such conflict source/base"); + return; + }; + if action == Action::ResolveBase { + self.resolvers.get_mut(&self.tab_id).unwrap().selected_base = Some(index); + self.set_resolver_view(ResolverView::History); + let pane = self.resolver().unwrap().history_panes[index]; self.focus_resolver_pane(pane); + } else if resolver.view == ResolverView::Resolution { + let pane = resolver.alternative_pane; + self.resolvers + .get_mut(&self.tab_id) + .unwrap() + .selected_source = index; + self.refresh_resolver_comparison(); + if let Some(pane) = pane { + self.focus_resolver_pane(pane) + } } else { - self.set_message("No such conflict source"); + let right = resolver.history_panes.get(resolver.history_sources[1]) + == Some(&self.pane_id); + let resolver = self.resolvers.get_mut(&self.tab_id).unwrap(); + let slot = usize::from(right); + if resolver.history_sources[1 - slot] == index { + resolver.history_sources[1 - slot] = resolver.history_sources[slot]; + } + resolver.history_sources[usize::from(right)] = index; + let pane = resolver.history_panes[index]; + self.focus_resolver_pane(pane); } } Action::ResolveUseSource => { - let Some(index) = source.filter(|index| *index < resolver.source_panes.len()) - else { + let Some(index) = source.filter(|index| { + resolver + .files + .get(resolver.current_file) + .is_some_and(|file| *index < file.sources.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 whole = !resolver.files[file].text || deleted; + let alternative_row = (Some(self.pane_id) == resolver.alternative_pane) + .then(|| { + self.resolver_diff() + .and_then(|diff| diff.comparison.as_ref()) + .and_then(|comparison| { + comparison.files[0].hunks.iter().find(|hunk| { + let row = self.cursor().row; + if hunk.after_len == 0 { + hunk.after_start == row + } else { + (hunk.after_start..hunk.after_start + hunk.after_len) + .contains(&row) + } + }) + }) + .map(|hunk| hunk.before_start) + }) + .flatten(); + self.resolvers + .get_mut(&self.tab_id) + .unwrap() + .selected_source = index; + self.set_resolver_view(ResolverView::Resolution); + if let Some(row) = alternative_row { + self.active.view.targets = vec![Target::caret(Cursor { + row: row.min(self.active.lines.len() - 1), + column: 0, + })]; + self.active.view.primary = 0; + } + if whole { 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; + self.refresh_resolver_comparison(); 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"); + let diff = self.resolver_diff().unwrap(); + let comparison = &diff.comparison.as_ref().unwrap().files[0]; + let Some(hunk) = comparison.hunks.iter().find(|hunk| { + if hunk.before_len == 0 { + hunk.before_start == row + } else { + (hunk.before_start..hunk.before_start + hunk.before_len).contains(&row) + } + }) else { + self.set_message( + "Move the Draft caret into a difference; :resolve.next navigates decisions", + ); 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(); + let mut text = String::new(); + for index in hunk.after_start..hunk.after_start + hunk.after_len { + text.push_str(&comparison.after[index]); + if index + 1 < comparison.after.len() || comparison.after_eof { + text.push('\n'); } - Err(error) => self.set_message(error), } + let offsets = line_offsets(&self.active.lines); + let length = self.active.lines.iter().map(String::len).sum::() + + self.active.lines.len() + - 1; + let start = offsets.get(hunk.before_start).copied().unwrap_or(length); + let end = offsets + .get(hunk.before_start + hunk.before_len) + .copied() + .unwrap_or(length); + let before = self.snapshot(); + self.apply_edits(vec![Edit { + target: Some(self.active.view.primary), + start, + end, + text: Cow::Owned(text), + caret: 0, + round_up: false, + }]); + self.active.view.targets = vec![Target::caret(Cursor { + row: row.min(self.active.lines.len() - 1), + column: 0, + })]; + self.active.view.primary = 0; + self.resolvers.get_mut(&self.tab_id).unwrap().files[file].whole_file_choice = None; + self.record(before); + self.reveal_caret(); + self.refresh_resolver_status(); + self.refresh_resolver_comparison(); } _ => unreachable!("resolver actions only"), } @@ -2672,14 +3153,14 @@ impl Editor { resolver .files .iter() - .position(|file| file.result_buffer == self.active.id) + .position(|file| file.draft_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"); + self.set_message("Resolution Draft must be written to its original conflict path"); return true; } let resolver = self.resolvers.get_mut(&tab).unwrap(); @@ -2739,7 +3220,7 @@ impl Editor { resolver.pending || resolver.files.iter().any(|file| { file.whole_file_choice.is_some() - || self.buffer(file.result_buffer).is_some_and(Buffer::dirty) + || self.buffer(file.draft_buffer).is_some_and(Buffer::dirty) }) }) } @@ -2756,12 +3237,25 @@ impl Editor { { return false; } + if !resolver.files_focused + && resolver.view == ResolverView::Resolution + && self.pending_sequence.keys.is_empty() + && self.pending_sequence.prefix.is_none() + && self.count.is_none() + && matches!(input, Input::Char('[' | ']')) + { + let focus = self.resolver_buffer_focus().unwrap_or(Focus::Before); + let row = self.cursor().row; + if let Some(diff) = self.resolver_diff_mut() { + diff.expand_source_excerpt(focus, *input == Input::Char(']'), row); + } + return true; + } 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 panes = self.resolver_visible_panes(); let current = if resolver.files_focused { panes.len() } else { @@ -2770,7 +3264,10 @@ impl Editor { .position(|pane| *pane == self.pane_id) .unwrap_or(0) }; - let count = panes.len() + 1; + let count = panes.len() + usize::from(resolver.files_visible); + if count == 0 { + return true; + } let next = (current + if *input == Input::BackTab { count - 1 @@ -2809,7 +3306,12 @@ impl Editor { } match input { Input::Char('/') => self.resolvers.get_mut(&self.tab_id).unwrap().filtering = true, - Input::Escape => self.focus_resolver_pane(resolver.result_pane), + Input::Escape => { + let pane = self.resolver_visible_panes().first().copied(); + if let Some(pane) = pane { + self.focus_resolver_pane(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); @@ -3181,31 +3683,27 @@ impl Editor { fn focus_pane(&mut self, direction: PaneDirection) { if let Some(resolver) = self.resolver() { + let panes = self.resolver_visible_panes(); + let current = panes + .iter() + .position(|pane| *pane == self.pane_id) + .unwrap_or(0); 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 => { + PaneDirection::Right if resolver.files_focused => None, + PaneDirection::Left if resolver.files_focused => panes.last().copied(), + PaneDirection::Right if current + 1 == panes.len() && resolver.files_visible => { 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() - } - } + PaneDirection::Right => panes.get(current + 1).copied(), + PaneDirection::Left => current + .checked_sub(1) + .and_then(|index| panes.get(index)) + .copied(), + PaneDirection::Up | PaneDirection::Down => None, }; if let Some(pane) = pane { - self.focus_resolver_pane(pane); + self.focus_resolver_pane(pane) } return; } @@ -3294,7 +3792,7 @@ impl Editor { if self .resolvers .values() - .any(|resolver| resolver.files.iter().any(|file| file.result_buffer == id)) + .any(|resolver| resolver.files.iter().any(|file| file.draft_buffer == id)) { return; } @@ -3328,12 +3826,12 @@ impl Editor { let mut buffers = self.closing_buffers(&panes); if let Some(resolver) = self.resolver() { for file in &resolver.files { - if !buffers.contains(&file.result_buffer) + if !buffers.contains(&file.draft_buffer) && !self.panes.iter().any(|(id, pane)| { - !panes.contains(id) && pane.buffer_id == file.result_buffer + !panes.contains(id) && pane.buffer_id == file.draft_buffer }) { - buffers.push(file.result_buffer); + buffers.push(file.draft_buffer); } } } @@ -3545,8 +4043,8 @@ impl Editor { } pub fn word_wrap(&self) -> bool { - self.diff - .as_ref() + self.resolver_diff() + .or(self.diff.as_ref()) .filter(|diff| diff.phase == Phase::View) .map_or(self.active.view.word_wrap, |diff| diff.word_wrap) } @@ -3973,7 +4471,7 @@ impl Editor { || self.resolvers.values().any(|resolver| { resolver.files.iter().any(|file| { (!file.text || file.whole_file_choice.is_some()) - && file.result_buffer == self.active.id + && file.draft_buffer == self.active.id }) }) } @@ -7044,6 +7542,7 @@ impl Editor { self.bind_diff_buffer(); self.refresh_live_diff(); self.refresh_resolver_status(); + self.refresh_resolver_comparison(); if self.diff_interacting() && let Some(diff) = &mut self.diff { @@ -7483,7 +7982,7 @@ impl Editor { resolver .files .iter() - .any(|file| file.result_buffer == self.active.id) + .any(|file| file.draft_buffer == self.active.id) })) { self.set_message("Input pane is read-only"); @@ -7594,7 +8093,9 @@ impl Editor { | Action::ResolveNextFile | Action::ResolvePreviousFile | Action::ResolveFiles - | Action::ResolveResult + | Action::ResolveDraft + | Action::ResolveHistory + | Action::ResolveViewToggle | Action::ResolveClose | Action::PullRequests | Action::DiffClose @@ -8131,9 +8632,9 @@ impl Editor { if self .resolvers .values() - .any(|resolver| resolver.files.iter().any(|file| file.result_buffer == id)) + .any(|resolver| resolver.files.iter().any(|file| file.draft_buffer == id)) { - self.set_message("Close the resolver workspace before deleting its Result buffer"); + self.set_message("Close the resolver workspace before deleting its Draft buffer"); return; } let buffer = if id == self.active.id { @@ -8327,6 +8828,7 @@ impl Editor { self.leave_diff_on_navigation(); self.refresh_live_diff(); self.refresh_resolver_status(); + self.refresh_resolver_comparison(); invocation } @@ -8391,7 +8893,9 @@ impl Editor { | Action::ResolveNextFile | Action::ResolvePreviousFile | Action::ResolveFiles - | Action::ResolveResult + | Action::ResolveDraft + | Action::ResolveHistory + | Action::ResolveViewToggle | Action::ResolveClose ) { self.resolver_command(entry.action, args); @@ -8582,7 +9086,11 @@ impl Editor { self.set_message("The word_wrap.toggle command takes no arguments"); return None; } - if let Some(diff) = &mut self.diff + if self.resolver_diff().is_some() { + let diff = self.resolver_diff_mut().unwrap(); + diff.word_wrap = !diff.word_wrap; + diff.follow_caret = true; + } else if let Some(diff) = &mut self.diff && diff.phase == Phase::View { diff.word_wrap = !diff.word_wrap; @@ -8836,7 +9344,7 @@ impl Editor { let resolver_buffers: HashSet<_> = self .resolvers .values() - .flat_map(|resolver| resolver.files.iter().map(|file| file.result_buffer)) + .flat_map(|resolver| resolver.files.iter().map(|file| file.draft_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) { @@ -11958,11 +12466,15 @@ mod tests { resolver.loading = false; resolver.files.push(ResolverFile { path: "file.txt".into(), - result_buffer: result, + draft_buffer: result, status: ResolverStatus::Unsaved, text: true, whole_file_choice: None, error: None, + seed_source: 0, + projection: None, + diff: DiffState::new(), + comparison_key: None, sources: vec![ResolverSource { label: "Side #1".into(), text: "input\n".into(), @@ -11970,7 +12482,7 @@ mod tests { }], }); editor.select_resolver_file(0); - let source = editor.resolver().unwrap().source_panes[0]; + let source = editor.resolver().unwrap().alternative_pane.unwrap(); editor.focus_resolver_pane(source); for command in ["w", "w! /tmp/vamp-readonly-must-not-write", "buffer_delete"] { editor.execute(command); @@ -11989,7 +12501,7 @@ mod tests { editor.handle(Input::Char('i')); editor.handle(Input::Enter); assert_eq!(editor.active.view.search_query, "i"); - editor.handle(Input::Char('\t')); + editor.execute("resolve.files"); assert!(editor.resolver().unwrap().files_focused); editor.execute("resolve.close"); assert_eq!(editor.tab_id(), tab); @@ -12001,6 +12513,198 @@ mod tests { assert!(editor.dirty()); } + fn resolver_editor(draft: &str, sources: &[(&str, bool)]) -> Editor { + let mut editor = editor(); + editor.apply(vec![Effect::Replace(draft.into())]).unwrap(); + let draft_buffer = editor.buffer_id(); + editor.open_resolver(); + editor.resolver_requests.clear(); + let resolver = editor.resolvers.get_mut(&editor.tab_id).unwrap(); + resolver.loading = false; + resolver.files.push(ResolverFile { + path: "conflict.txt".into(), + draft_buffer, + status: ResolverStatus::Unsaved, + text: true, + whole_file_choice: None, + error: None, + seed_source: sources.iter().position(|(_, base)| !base).unwrap_or(0), + projection: None, + diff: DiffState::new(), + comparison_key: None, + sources: sources + .iter() + .enumerate() + .map(|(index, (text, base))| ResolverSource { + label: format!("Source {}", index + 1), + text: (*text).into(), + base: *base, + }) + .collect(), + }); + editor.select_resolver_file(0); + editor + } + + #[test] + fn resolver_takes_only_current_diff_hunk_and_undo_preserves_native_newlines() { + for (draft, alternative, chosen) in [ + ( + "before\nleft\nshared\ntail\n", + "before\nright\nshared\nother\n", + "before\nright\nshared\ntail\n", + ), + ("a\n", "a", "a"), + ("a", "a\n", "a\n"), + ("a\nlast", "a\n", "a\n"), + ("", "new", "new"), + ("old", "", ""), + ("\n", "", ""), + ] { + let mut editor = resolver_editor(draft, &[(draft, false), (alternative, false)]); + editor.execute("resolve.use_source 2"); + assert_eq!(editor.context().text, chosen); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, draft); + } + } + + #[test] + fn resolver_history_selects_every_side_and_base_without_moving_draft_or_viewport() { + let draft = (0..40) + .map(|row| format!("draft {row}\n")) + .collect::(); + let mut editor = resolver_editor( + &draft, + &[ + ("base one", true), + (&draft, false), + ("base two", true), + ("alternative one", false), + ("alternative two", false), + ], + ); + editor.active.view.targets = vec![Target::caret(Cursor { row: 20, column: 3 })]; + editor.set_resolver_viewport(8, 2, 4); + let cursor = editor.cursor(); + let buffer = editor.buffer_id(); + let id = editor.resolver_diff().unwrap().comparison_id; + assert!(!editor.resolver().unwrap().files_visible); + editor.execute("word_wrap.toggle"); + assert!(editor.word_wrap()); + editor.execute("resolve.history"); + assert_eq!(editor.resolver().unwrap().view, ResolverView::History); + assert!( + editor + .apply(vec![Effect::Insert("blocked".into())]) + .is_err() + ); + editor.execute("resolve.source 5"); + assert_eq!(editor.lines(), ["alternative two"]); + assert_eq!(editor.resolver().unwrap().history_sources[0], 4); + editor.execute("resolve.base 2"); + assert_eq!(editor.lines(), ["base two"]); + assert_eq!(editor.resolver().unwrap().selected_base, Some(2)); + editor.execute("resolve.draft"); + assert_eq!(editor.context().text, draft); + assert_eq!(editor.cursor(), cursor); + assert_eq!(editor.buffer_id(), buffer); + let diff = editor.resolver_diff().unwrap(); + assert_eq!(diff.comparison_id, id); + assert_eq!((diff.top, diff.left_before, diff.left_after), (8, 2, 4)); + assert!(editor.word_wrap()); + editor.handle(Input::Char('\t')); + assert_eq!(editor.resolver_buffer_focus(), Some(Focus::After)); + editor.handle(Input::Char('\t')); + assert_eq!(editor.resolver_buffer_focus(), Some(Focus::Before)); + assert!(!editor.resolver().unwrap().files_focused); + editor.execute("resolve.files"); + assert!(editor.resolver().unwrap().files_focused); + editor.execute("resolve.files"); + assert!(!editor.resolver().unwrap().files_visible); + } + + #[test] + fn resolver_equal_choice_keeps_draft_editable_and_decision_navigation_wraps() { + let mut editor = resolver_editor( + "left\nsame\nlast\n", + &[ + ("left\nsame\nlast\n", false), + ("right\nsame\nother\n", false), + ], + ); + assert_eq!(editor.cursor().row, 0); + editor.execute("resolve.next"); + assert_eq!(editor.cursor().row, 2); + editor.execute("resolve.next"); + assert_eq!(editor.cursor().row, 0); + editor.execute("resolve.previous"); + assert_eq!(editor.cursor().row, 2); + editor.execute("resolve.use_source 2"); + editor.execute("resolve.next"); + editor.execute("resolve.use_source 2"); + let diff = editor.resolver_diff().unwrap(); + assert!(diff.comparison.as_ref().unwrap().files[0].hunks.is_empty()); + assert!( + diff.source_display_row(Focus::Before, editor.cursor().row) + .is_some() + ); + editor.handle(Input::Char('i')); + editor.handle(Input::Paste("native ".into())); + editor.handle(Input::Escape); + assert!(editor.context().text.starts_with("native right")); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, "right\nsame\nother\n"); + } + + #[test] + fn resolver_file_switches_restore_native_draft_and_refresh_readonly_alternative() { + let mut editor = resolver_editor( + "first\n", + &[("first\n", false), ("first alternative\n", false)], + ); + editor.active.view.targets = vec![Target::caret(Cursor { row: 0, column: 2 })]; + let cursor = editor.cursor(); + let comparison = editor.resolver_diff().unwrap().comparison_id; + let buffer = editor.next_buffer_id; + editor.next_buffer_id += 1; + editor.parked.insert( + buffer, + Buffer::from_text(buffer, None, None, "second\n", None).unwrap(), + ); + let resolver = editor.resolvers.get_mut(&editor.tab_id).unwrap(); + resolver.files.push(ResolverFile { + path: "second.txt".into(), + draft_buffer: buffer, + status: ResolverStatus::Unresolved, + text: true, + whole_file_choice: None, + error: None, + seed_source: 0, + projection: None, + diff: DiffState::new(), + comparison_key: None, + sources: ["second\n", "second alternative\n"] + .into_iter() + .map(|text| ResolverSource { + label: text.into(), + text: text.into(), + base: false, + }) + .collect(), + }); + editor.select_resolver_file(1); + assert_eq!(editor.context().text, "second\n"); + assert_ne!(editor.resolver_diff().unwrap().comparison_id, comparison); + editor.select_resolver_file(0); + assert_eq!(editor.context().text, "first\n"); + assert_eq!(editor.cursor(), cursor); + assert_eq!(editor.resolver_diff().unwrap().comparison_id, comparison); + let pane = editor.resolver().unwrap().alternative_pane.unwrap(); + editor.focus_resolver_pane(pane); + assert_eq!(editor.context().text, "first alternative\n"); + } + struct Directory(PathBuf); impl Directory { fn new() -> Self { diff --git a/src/main.rs b/src/main.rs index 6e84bea..02236b9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -84,10 +84,11 @@ Diff: :diff (Normal); choose Before then After; Working checkout After edits liv 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 + Editable marker-free Draft vs readonly Alternative; :resolve.history shows inputs/base + :resolve.next/previous navigates differing hunks; :resolve.use_source [N] takes a hunk + :resolve.source N selects Alternative; :resolve.base N selects History base + :resolve.draft returns to Draft; :resolve.view.toggle switches Resolution/History + :w saves and checks jj; :resolve.files toggles the explorer; :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 index edfb09e..4f1ff5c 100644 --- a/src/resolve.rs +++ b/src/resolve.rs @@ -858,7 +858,7 @@ pub struct Region { pub bases: Vec, } impl Region { - pub fn choose(&self, source: &Source) -> Result { + fn selected(&self, source: &Source) -> Result<&str, String> { if !matches!(source.kind, FileKind::Text | FileKind::Deleted) { return Err("Non-text sources require an explicit whole-file choice".into()); } @@ -870,7 +870,10 @@ impl Region { } else { &self.sides }; - terms.get(source.term).cloned().ok_or_else(|| "Conflict region no longer matches this source; edit it manually or choose a whole file".into()) + terms.get(source.term).map(String::as_str).ok_or_else(|| "Conflict region no longer matches this source; edit it manually or choose a whole file".into()) + } + pub fn choose(&self, source: &Source) -> Result { + self.selected(source).map(str::to_owned) } } fn marker(line: &str) -> Option<(u8, usize)> { @@ -941,7 +944,7 @@ fn parse_region(lines: &[&str], width: usize) -> Option<(Vec, Vec= 2 && bases.len() + 1 == sides.len()).then_some((sides, bases)) } /// Recognize jj snapshot/diff/diff-experimental and Git diff3 markers at any -/// marker width. This is navigation/selection only; jj alone decides resolution. +/// marker width. Recognition is local; jj alone decides saved resolution status. pub fn regions(text: &str) -> Vec { let lines = text.split_inclusive('\n').collect::>(); let mut regions: Vec = Vec::new(); @@ -986,6 +989,66 @@ pub fn regions(text: &str) -> Vec { regions } +/// Marker-free working text and the original choices, never historical full files. +/// Like jj-diffconflicts, replace marker sections in the working buffer, not +/// entire branch snapshots: https://github.com/rafikdraoui/jj-diffconflicts. +#[derive(Clone, Debug)] +pub struct Projection { + seed_index: usize, + alternatives: Vec, +} +impl Projection { + /// Freeze every choice from current working text. Indices are indices in + /// `ConflictFile::sources`, including bases. Later draft edits are independent. + pub fn new(raw: &str, sources: &[Source], seed_index: usize) -> Result { + if seed_index >= sources.len() { + return Err("No such conflict source".into()); + } + let mut offsets = vec![0]; + for line in raw.split_inclusive('\n') { + offsets.push(offsets.last().unwrap() + line.len()); + } + let mut alternatives = sources + .iter() + .map(|_| String::with_capacity(raw.len())) + .collect::>(); + let mut end = 0; + for region in regions(raw) { + let outside = &raw[end..offsets[region.lines.start]]; + for (alternative, source) in alternatives.iter_mut().zip(sources) { + alternative.push_str(outside); + alternative.push_str(region.selected(source)?); + } + end = offsets[region.lines.end]; + } + for alternative in &mut alternatives { + alternative.push_str(&raw[end..]); + } + Ok(Self { + seed_index, + alternatives, + }) + } + + pub fn seed(&self) -> &str { + &self.alternatives[self.seed_index] + } + + pub fn alternative(&self, index: usize) -> Result<&str, String> { + self.alternatives + .get(index) + .map(String::as_str) + .ok_or_else(|| "No such conflict source".into()) + } +} + +/// Project the selected side/base through current materialized working text. +/// Bytes outside recognized marker regions, including the final EOL, are retained. +pub fn project(text: &str, source: &Source) -> Result { + Projection::new(text, std::slice::from_ref(source), 0) + .map(|mut projection| projection.alternatives.pop().unwrap()) +} + #[cfg(all(test, unix))] mod tests { use super::*; @@ -1315,3 +1378,150 @@ mod tests { assert!(regions("<<<<<<< unfinished\nleft\n").is_empty()); } } + +#[cfg(test)] +mod projection_tests { + use super::*; + + fn test_sources(sides: usize) -> Vec { + (0..sides * 2 - 1) + .map(|index| Source { + label: format!("source {index}"), + base: index % 2 == 1, + kind: FileKind::Text, + bytes: b"historical full file; must never replace working text\n".to_vec(), + tree_id: String::new(), + term: index / 2, + sides, + bases: sides - 1, + node: Node::Absent, + }) + .collect() + } + + fn snapshot(left: &str, base: &str, right: &str) -> String { + format!( + "<<<<<<< conflict\n+++++++ side 1\n{left}------- base\n{base}+++++++ side 2\n{right}>>>>>>> conflict ends\n" + ) + } + + #[test] + fn current_working_projections_keep_partial_resolutions_and_multiple_regions() { + let sources = test_sources(2); + let raw = format!( + "unsaved heading\nalready manually resolved\n{}auto-merged middle\n<<<<<<< conflict\n%%%%%%% Changes from base to side 1\n\\\\\\\\\\\\\\ continuation\n-old two\n+left two\n+++++++ side 2\nright two\n>>>>>>> conflict ends\nunsaved tail", + snapshot("left one\n", "old one\n", "right one\n") + ); + for (index, first, second) in [ + (0, "left one", "left two"), + (1, "old one", "old two"), + (2, "right one", "right two"), + ] { + assert_eq!( + project(&raw, &sources[index]).unwrap(), + format!( + "unsaved heading\nalready manually resolved\n{first}\nauto-merged middle\n{second}\nunsaved tail" + ) + ); + } + } + + #[test] + fn alternatives_are_frozen_from_the_same_working_text_as_the_seed() { + let sources = test_sources(2); + let raw = format!( + "unsaved heading\n{}auto-merged middle\n{}unsaved tail", + snapshot("left-one\n", "old-one\n", "right-one\n"), + snapshot("left-two\n", "old-two\n", "right-two\n") + ); + let projection = Projection::new(&raw, &sources, 0).unwrap(); + let mut draft = projection.seed().to_owned(); + draft = draft.replace("unsaved heading", "later draft edit"); + draft.push_str("\nnew draft ending\n"); + assert_eq!( + projection.alternative(2).unwrap(), + "unsaved heading\nright-one\nauto-merged middle\nright-two\nunsaved tail" + ); + assert_eq!( + projection.alternative(1).unwrap(), + "unsaved heading\nold-one\nauto-merged middle\nold-two\nunsaved tail" + ); + assert_eq!( + projection.seed(), + "unsaved heading\nleft-one\nauto-merged middle\nleft-two\nunsaved tail" + ); + assert_eq!( + draft, + "later draft edit\nleft-one\nauto-merged middle\nleft-two\nunsaved tail\nnew draft ending\n" + ); + } + + #[test] + fn all_multiway_sides_and_bases_project_the_same_current_context() { + let sources = test_sources(3); + let raw = "unsaved α\n<<<<<<<<<<< conflict\n+++++++++++ side 1\none\n----------- base 1\nold one\n+++++++++++ side 2\ntwo\n----------- base 2\nold two\n+++++++++++ side 3\nthree\n>>>>>>>>>>> conflict ends\nunsaved ω"; + let projection = Projection::new(raw, &sources, 0).unwrap(); + for (index, choice) in ["one", "old one", "two", "old two", "three"] + .into_iter() + .enumerate() + { + let expected = format!("unsaved α\n{choice}\nunsaved ω"); + assert_eq!(project(raw, &sources[index]).unwrap(), expected); + assert_eq!(projection.alternative(index).unwrap(), expected); + } + assert_eq!( + Projection::new(raw, &sources, 4).unwrap().seed(), + "unsaved α\nthree\nunsaved ω" + ); + } + + #[test] + fn projections_preserve_mixed_eof_and_crlf_terms() { + let sources = test_sources(2); + for eol in ["\n", "\r\n"] { + let choices = [ + format!("left{eol}"), + "old".to_owned(), + format!("right{eol}{eol}"), + ]; + // jj appends one separator EOL to *each* term when any lacks an EOL, + // and omits the EOL on the final marker. Existing term EOLs remain. + let raw = format!( + "heading{eol}<<<<<<< conflict{eol}+++++++ side 1{eol}{}{eol}------- base{eol}{}{eol}+++++++ side 2{eol}{}{eol}>>>>>>> conflict ends", + choices[0], choices[1], choices[2] + ); + let projection = Projection::new(&raw, &sources, 0).unwrap(); + for (index, choice) in choices.iter().enumerate() { + assert_eq!( + project(&raw, &sources[index]).unwrap(), + format!("heading{eol}{choice}") + ); + assert_eq!( + projection.alternative(index).unwrap(), + format!("heading{eol}{choice}") + ); + } + } + } + + #[test] + fn empty_choices_markerfree_text_and_invalid_choices_are_handled() { + let mut sources = test_sources(2); + sources[1].kind = FileKind::Deleted; + let raw = format!("before\n{}after\n", snapshot("", "", "new\n")); + let projection = Projection::new(&raw, &sources, 0).unwrap(); + assert_eq!(projection.seed(), "before\nafter\n"); + assert_eq!(projection.alternative(1).unwrap(), "before\nafter\n"); + assert_eq!(projection.alternative(2).unwrap(), "before\nnew\nafter\n"); + let resolved = Projection::new("manually resolved, no final EOL", &sources, 0).unwrap(); + assert_eq!( + resolved.alternative(2).unwrap(), + "manually resolved, no final EOL" + ); + assert!(resolved.alternative(sources.len()).is_err()); + assert!(Projection::new(&raw, &sources, sources.len()).is_err()); + assert!(Projection::new(&raw, &test_sources(3), 0).is_err()); + sources[0].kind = FileKind::Binary; + assert!(project(&raw, &sources[0]).is_err()); + } +} diff --git a/src/session.rs b/src/session.rs index cdf728e..f174872 100644 --- a/src/session.rs +++ b/src/session.rs @@ -611,6 +611,25 @@ impl EditorSession { self.synchronize_documents(); } + pub(crate) fn set_resolver_viewport( + &mut self, + top: usize, + left_before: usize, + left_after: usize, + ) { + self.editor + .set_resolver_viewport(top, left_before, left_after); + } + + pub(crate) fn scroll_resolver(&mut self, focus: Focus, down: bool) { + self.editor.scroll_resolver_diff(focus, down); + } + + pub(crate) fn scroll_resolver_horizontal(&mut self, focus: Focus, right: bool, width: usize) { + self.editor + .scroll_resolver_diff_horizontal(focus, right, width); + } + pub fn handle(&mut self, input: Input) { if input == Input::Ctrl('r') && self @@ -904,6 +923,7 @@ impl EditorSession { } fn synchronize_documents(&mut self) -> bool { + self.editor.refresh_resolver_comparison(); let blame_changed = self.poll_blame(); // Leaving a request's source invalidates it permanently, even if history returns there. if let Some(requests) = self.requests.get_mut("core") diff --git a/src/tui.rs b/src/tui.rs index 1853f27..848f119 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -36,11 +36,12 @@ use unicode_width::UnicodeWidthStr; use crate::{ chrome::{ChromeLine, PopupAnchor, SegmentConfig}, diff::{ - ChangedFile, DiffState, DisplayRow, FileStatus, Focus, PaneLayout, Phase, RowKind, ViewMode, + AlignedRow, ChangedFile, DiffState, DisplayRow, FileStatus, Focus, PaneLayout, Phase, + RowKind, ViewMode, }, editor::{ - Completion, Cursor, Editor, Hover, Input, Mode, Palette, ResolverStatus, ResolverWorkspace, - Selection, StartupDiagnostic, WhitespaceScope, + Completion, Cursor, Editor, Hover, Input, Mode, Palette, ResolverStatus, ResolverView, + ResolverWorkspace, Selection, StartupDiagnostic, WhitespaceScope, }, files::{DockSide, FilePicker, FilePresentation, FileView}, panes::{PaneRect, SplitAxis, split_rect}, @@ -313,6 +314,8 @@ struct BufferViewports { rects: Vec<(usize, Rect)>, theme: Option, diff_visible: bool, + history: BTreeMap, + history_key: Option, } #[derive(Default)] @@ -323,6 +326,203 @@ struct PaneViewport { documents: BTreeMap, panels: WheelPanels, } +type HistoryKey = (usize, [usize; 2], Option); + +fn history_key(resolver: &ResolverWorkspace) -> Option { + Some(( + resolver.files.get(resolver.current_file)?.draft_buffer, + resolver.history_sources, + resolver.selected_base, + )) +} + +struct HistoryDiff { + columns: [DiffState; 3], + top: DiffWrapPosition, + left: [usize; 3], + widths: [usize; 3], + wrapping: [bool; 3], + wrap: [WrapCache; 3], + visible: [Vec; 3], + caret: Option<(usize, Cursor, u64, bool)>, +} + +impl HistoryDiff { + fn new(resolver: &ResolverWorkspace) -> Self { + let file = &resolver.files[resolver.current_file]; + let text = |index: Option| { + index + .and_then(|index| file.sources.get(index)) + .map_or("", |source| source.text.as_str()) + .split('\n') + .map(str::to_owned) + .collect::>() + }; + let base = text(resolver.selected_base); + let mut columns: [DiffState; 3] = std::array::from_fn(|_| DiffState::new()); + columns[0].set_resolver_text(&base, &text(Some(resolver.history_sources[0]))); + columns[1].set_resolver_text(&base, &base); + columns[2].set_resolver_text(&base, &text(Some(resolver.history_sources[1]))); + let mut left = std::mem::take(&mut columns[0].rows).into_iter().peekable(); + let mut right = std::mem::take(&mut columns[2].rows).into_iter().peekable(); + columns[1].rows.clear(); + // Stitch the existing pairwise alignments at their shared base rows. + while left.peek().is_some() || right.peek().is_some() { + let insertion = left.peek().is_some_and(|row| row.before.is_none()) + || right.peek().is_some_and(|row| row.before.is_none()); + let take_left = left + .peek() + .is_some_and(|row| !insertion || row.before.is_none()); + let take_right = right + .peek() + .is_some_and(|row| !insertion || row.before.is_none()); + let empty = || AlignedRow { + before: None, + after: None, + kind: RowKind::Equal, + changes: Vec::new(), + }; + let left_row = if take_left { + left.next().unwrap() + } else { + empty() + }; + let right_row = if take_right { + right.next().unwrap() + } else { + empty() + }; + let base = left_row.before.or(right_row.before); + let changed = left_row.kind != RowKind::Equal || right_row.kind != RowKind::Equal; + let changes = left_row + .changes + .iter() + .chain(&right_row.changes) + .map(|change| [change[0].clone(), change[0].clone()]) + .collect(); + columns[1].rows.push(AlignedRow { + before: base, + after: base, + kind: if changed { + RowKind::Changed + } else { + RowKind::Equal + }, + changes, + }); + columns[0].rows.push(left_row); + columns[2].rows.push(right_row); + } + for column in &mut columns { + column.display_rows = (0..column.rows.len()).map(DisplayRow::Line).collect(); + } + Self { + columns, + top: DiffWrapPosition::default(), + left: [0; 3], + widths: [0; 3], + wrapping: [false; 3], + wrap: std::array::from_fn(|_| WrapCache::default()), + visible: std::array::from_fn(|_| Vec::new()), + caret: None, + } + } + + fn focus(column: usize) -> Focus { + if column == 1 { + Focus::Before + } else { + Focus::After + } + } + + fn row_height(&mut self, row: usize) -> usize { + let mut height = 1; + for column in 0..3 { + if self.wrapping[column] + && self.widths[column] > 0 + && let Some(source) = + self.columns[column].display_source_row(Self::focus(column), row) + { + let file = &self.columns[column].comparison.as_ref().unwrap().files[0]; + let lines = if column == 1 { + &file.before + } else { + &file.after + }; + height = height.max( + self.wrap[column] + .segments(source, lines.get(source).map_or("", String::as_str)) + .len(), + ); + } + } + height + } + + fn step(&mut self, down: bool) { + if down { + if self.top.continuation + 1 < self.row_height(self.top.display) { + self.top.continuation += 1; + } else if self.top.display + 1 < self.columns[0].rows.len() { + self.top.display += 1; + self.top.continuation = 0; + } + } else if self.top.continuation > 0 { + self.top.continuation -= 1; + } else if self.top.display > 0 { + self.top.display -= 1; + self.top.continuation = self.row_height(self.top.display) - 1; + } + } + + fn fill(&mut self, height: usize) { + for rows in &mut self.visible { + rows.clear(); + } + let top = self.top; + for _ in 0..height { + for column in 0..3 { + let mut segments = [None; 2]; + let mut final_segments = [false; 2]; + let side = usize::from(column != 1); + if let Some(source) = + self.columns[column].display_source_row(Self::focus(column), self.top.display) + { + let file = &self.columns[column].comparison.as_ref().unwrap().files[0]; + let lines = if column == 1 { + &file.before + } else { + &file.after + }; + let text = lines.get(source).map_or("", String::as_str); + if self.wrapping[column] { + let parts = self.wrap[column].segments(source, text); + segments[side] = parts.get(self.top.continuation).copied(); + final_segments[side] = self.top.continuation + 1 == parts.len(); + } else if self.top.continuation == 0 { + segments[side] = Some(Segment { + start: self.left[column], + end: self.left[column].saturating_add(self.widths[column]), + }); + final_segments[side] = true; + } + } + self.visible[column].push(WrappedDiffRow { + position: self.top, + segments, + final_segments, + }); + } + let previous = self.top; + self.step(true); + if self.top == previous { + break; + } + } + self.top = top; + } +} fn pane_rect(area: Rect) -> PaneRect { PaneRect { @@ -444,7 +644,11 @@ impl BufferViewports { fn draw(&mut self, frame: &mut Frame, editor: &mut Editor) { let focused = editor.pane_id(); - self.activate(focused, editor); + let viewport_pane = editor + .resolver() + .filter(|resolver| resolver.view == ResolverView::Resolution) + .map_or(focused, |resolver| resolver.draft_pane); + self.activate(viewport_pane, editor); self.parked .retain(|id, _| editor.pane_buffer_id(*id).is_some()); self.syntax.retain(|id, _| editor.has_buffer(*id)); @@ -594,64 +798,51 @@ impl BufferViewports { 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); + draw_resolver_heading(frame, resolver, layout.heading); 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 resolution = resolver.view == ResolverView::Resolution; + let text = resolver + .files + .get(resolver.current_file) + .is_some_and(|file| file.text); + if resolution && text { + self.panels.begin_frame(); + self.panels.workspace_managed = true; + self.document.geometry = None; + if let Some(diff) = editor.resolver_diff() { + draw_diff( + frame, + diff, + layout.documents, + editor.selection_navigation_hint(), + editor.file_navigation_hint(), + &mut self.panels, + Some(editor), + ); + } + if let Some(geometry) = &self.panels.diff { + let resolver = editor.resolver().unwrap(); + for (pane, region) in [ + (Some(resolver.draft_pane), geometry.regions[1].0), + (resolver.alternative_pane, geometry.regions[2].0), + ] { + if let Some(pane) = pane.filter(|_| !region.is_empty()) { + self.rects.push((pane, region)); + } } } - 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); - } + } else if resolution { + self.panels.begin_frame(); + draw_resolver_choices(frame, resolver, layout.documents); + } else { + self.draw_resolver_history(frame, editor, &layout); } 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, - ); - }); - } + if !resolution { + self.activate(focused, editor); } - self.activate(focused, editor); self.panels.workspace_managed = false; - if !self.rects.iter().any(|(id, _)| *id == focused) { + if !resolution && self.rects.is_empty() { self.panels.begin_frame(); self.document.geometry = None; } @@ -667,7 +858,193 @@ impl BufferViewports { ); } draw_overlays(frame, editor, area, self.panels.caret, &mut self.panels); - self.diff_visible = false; + self.diff_visible = text; + } + + fn draw_resolver_history( + &mut self, + frame: &mut Frame, + editor: &mut Editor, + layout: &ResolverLayout, + ) { + self.panels.begin_frame(); + self.document.geometry = None; + let resolver = editor.resolver().unwrap(); + let Some(key) = history_key(resolver) else { + return; + }; + self.history_key = Some(key); + self.history + .retain(|(buffer, _, _), _| editor.has_buffer(*buffer)); + let history = self + .history + .entry(key) + .or_insert_with(|| HistoryDiff::new(resolver)); + let mut panes = [None; 3]; + for (slot, &(index, panel)) in layout.history.iter().enumerate() { + let column = if resolver.selected_base == Some(index) { + 1 + } else if resolver.history_sources[0] == index { + 0 + } else { + 2 + }; + let pane = resolver.history_panes[index]; + let source = &resolver.files[resolver.current_file].sources[index]; + let content = resolver_document_panel( + frame, + panel, + &format!("{} · readonly", source.label), + !resolver.files_focused && pane == editor.pane_id(), + ); + panes[slot] = Some((column, pane, content)); + self.rects.push((pane, content)); + } + let previous_widths = history.widths; + history.widths = [0; 3]; + for &(column, pane, content) in panes.iter().flatten() { + editor.with_render_pane(pane, |view| { + let diff = &history.columns[column]; + let file = &diff.comparison.as_ref().unwrap().files[0]; + let width = usize::from(content.width); + history.widths[column] = width.saturating_sub(diff_gutter( + diff, + file, + width, + HistoryDiff::focus(column), + )); + history.wrapping[column] = view.word_wrap(); + history.wrap[column].prepare((view.buffer_id(), 0), history.widths[column]); + }); + } + let height = panes + .iter() + .flatten() + .map(|(_, _, area)| usize::from(area.height)) + .max() + .unwrap_or(0); + history.top.continuation = history + .top + .continuation + .min(history.row_height(history.top.display) - 1); + history.fill(height); + if let Some(&(column, pane, content)) = panes + .iter() + .flatten() + .find(|(_, pane, _)| *pane == editor.pane_id()) + { + let cursor = editor.cursor(); + let identity = ( + pane, + cursor, + editor.caret_reveal_revision(), + history.wrapping[column], + ); + if !editor.resolver().unwrap().files_focused + && (history.caret != Some(identity) || previous_widths != history.widths) + { + let diff = &history.columns[column]; + if let Some(display) = + diff.source_display_row(HistoryDiff::focus(column), cursor.row) + { + let column_offset = display_column(&editor.lines()[cursor.row], cursor.column); + let continuation = if history.wrapping[column] { + caret_segment( + history.wrap[column].segments(cursor.row, &editor.lines()[cursor.row]), + column_offset, + ) + } else { + history.left[column] = scroll_offset( + history.left[column], + column_offset, + history.widths[column], + editor.scroll_distance(), + ); + 0 + }; + let caret = DiffWrapPosition { + display, + continuation, + }; + let screen = history.visible[column] + .iter() + .position(|row| row.position == caret); + let height = usize::from(content.height); + let margin = scroll_margin(height, editor.scroll_distance()); + let anchor = if height == 0 { + None + } else if caret < history.top || screen.is_some_and(|row| row < margin) { + Some(margin) + } else if screen.is_none_or(|row| row >= height.saturating_sub(margin)) { + Some(height.saturating_sub(margin + 1)) + } else { + None + }; + if let Some(anchor) = anchor { + history.top = caret; + for _ in 0..anchor { + history.step(false); + } + } + history.fill(height); + } + } + history.caret = Some(identity); + } + for (column, pane, content) in panes.into_iter().flatten() { + editor.with_render_pane(pane, |view| { + let diff = &history.columns[column]; + let file = &diff.comparison.as_ref().unwrap().files[0]; + let syntax = self.syntax.entry(view.buffer_id()).or_default(); + let source_rows = history.visible[column].iter().filter_map(|row| { + diff.display_source_row(HistoryDiff::focus(column), row.position.display) + }); + let first = source_rows + .clone() + .min() + .unwrap_or(0) + .min(view.lines().len() - 1); + let last = source_rows + .max() + .unwrap_or(first) + .min(view.lines().len() - 1); + syntax.update_visible( + view.lines(), + view.path(), + view.text_revision(), + std::slice::from_ref(&(first..last + 1)), + ); + let text_area = draw_diff_pane( + frame, + diff, + file, + content, + column == 1, + [&*syntax, &*syntax], + [view.semantic_tokens(), view.semantic_tokens()], + (history.top.display, &mut history.left[column], None), + Some(&history.visible[column]), + self.panels.diff_colors, + view.highlight_trailing_whitespace(), + Some(view.whitespace_characters()), + view.whitespace_scope(), + view.resolver(), + true, + ); + if view.render_pane_focused() && !view.resolver().unwrap().files_focused { + self.panels.caret = paint_diff_editor( + frame, + view, + diff, + text_area, + HistoryDiff::focus(column), + (history.top.display, history.left[column]), + Some(&history.visible[column]), + ) + .map(|caret| (text_area, caret)); + } + }); + } } fn draw_session(&mut self, frame: &mut Frame, session: &mut EditorSession) { @@ -678,7 +1055,14 @@ impl BufferViewports { .as_ref() .and_then(|geometry| geometry.viewport) { - session.set_diff_viewport(comparison_id, file, top, left_before, left_after); + if session.editor().resolver_diff().is_some() { + session.set_resolver_viewport(top, left_before, left_after); + } else { + session.set_diff_viewport(comparison_id, file, top, left_before, left_after); + } + } + if session.editor().resolver().is_some() { + return; } for (pane_id, _) in &self.rects { let Some(pane) = self.parked.get(pane_id) else { @@ -711,6 +1095,51 @@ impl BufferViewports { }) else { return false; }; + if let Some(resolver) = session + .editor() + .resolver() + .filter(|resolver| resolver.view == ResolverView::History) + && let Some(key) = self.history_key + && let Some(history) = self.history.get_mut(&key) + { + let source = resolver + .history_panes + .iter() + .position(|pane| *pane == target) + .unwrap(); + let column = if resolver.selected_base == Some(source) { + 1 + } else if resolver.history_sources[0] == source { + 0 + } else { + 2 + }; + match wheel_kind(&mouse).unwrap() { + MouseEventKind::ScrollDown | MouseEventKind::ScrollUp => { + let down = wheel_kind(&mouse) == Some(MouseEventKind::ScrollDown); + for _ in 0..3 { + history.step(down); + } + } + MouseEventKind::ScrollLeft | MouseEventKind::ScrollRight + if !history.wrapping[column] => + { + let right = wheel_kind(&mouse) == Some(MouseEventKind::ScrollRight); + let left = if right { + history.left[column].saturating_add(3) + } else { + history.left[column].saturating_sub(3) + }; + history.left[column] = history.columns[column].bound_horizontal( + HistoryDiff::focus(column), + left, + history.widths[column], + ); + } + _ => {} + } + return true; + } self.activate(target, session.editor()); let handled = if self.panels.diff.is_some() { self.panels.scroll_comparison(session, mouse, target); @@ -1580,20 +2009,18 @@ fn draw_bars(frame: &mut Frame, editor: &Editor, area: Rect, syntax: &mut Syntax struct ResolverLayout { heading: Rect, - selector: Rect, - sources: Vec<(usize, Rect)>, - result: Rect, + documents: Rect, + history: Vec<(usize, Rect)>, files: Option, } fn resolver_layout(area: Rect, resolver: &ResolverWorkspace, focused: usize) -> ResolverLayout { - let [heading, selector, workspace] = Layout::vertical([ + let [heading, 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) = if resolver.files_visible && area.width >= 76 { let [documents, files] = Layout::horizontal([ Constraint::Fill(1), Constraint::Length((area.width / 4).clamp(24, 36)), @@ -1607,49 +2034,43 @@ fn resolver_layout(area: Rect, resolver: &ResolverWorkspace, focused: usize) -> }; let mut layout = ResolverLayout { heading, - selector, - sources: Vec::with_capacity(2), - result: workspace, + documents, + history: Vec::with_capacity(3), files, }; + if resolver.view != ResolverView::History || documents.is_empty() { + return layout; + } 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 mut sources = Vec::with_capacity(3); + for index in [ + Some(resolver.history_sources[0]), + resolver.selected_base, + Some(resolver.history_sources[1]), + ] + .into_iter() + .flatten() { - 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)]); + if index < file.sources.len() && !sources.contains(&index) { + sources.push(index); + } + } + if documents.width < 60 { + let selected = sources + .iter() + .copied() + .find(|index| resolver.history_panes.get(*index) == Some(&focused)) + .or_else(|| sources.first().copied()); + if let Some(selected) = selected { + layout.history.push((selected, documents)); + } } else { - layout.sources.push((selected, sources)); + let columns = Layout::horizontal(vec![Constraint::Fill(1); sources.len()]).split(documents); + layout + .history + .extend(sources.into_iter().zip(columns.iter().copied())); } layout } @@ -1664,12 +2085,7 @@ fn resolver_status(status: &ResolverStatus) -> (&'static str, Color) { } } -fn draw_resolver_heading( - frame: &mut Frame, - resolver: &ResolverWorkspace, - heading: Rect, - selector: Rect, -) { +fn draw_resolver_heading(frame: &mut Frame, resolver: &ResolverWorkspace, heading: Rect) { let file = resolver.files.get(resolver.current_file); let status = if resolver.loading { ("loading", Color::Cyan) @@ -1680,7 +2096,15 @@ fn draw_resolver_heading( }; let mut spans = vec![ Span::styled( - format!("Resolve · {} ", status.0), + format!( + "{} · {} ", + if resolver.view == ResolverView::History { + "History" + } else { + "Resolution" + }, + status.0 + ), Style::default().fg(status.1), ), Span::raw(file.map_or_else(|| Cow::Borrowed(""), |file| display_path(&file.path))), @@ -1696,18 +2120,6 @@ fn draw_resolver_heading( )); } 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 { @@ -2933,6 +3345,19 @@ fn paint_editor_line( } } +fn diff_native_focus(editor: &Editor) -> Option { + editor + .resolver_buffer_focus() + .or_else(|| editor.diff_buffer_focus()) +} + +fn diff_native_interacting(editor: &Editor) -> bool { + editor.resolver().map_or_else( + || editor.diff_interacting(), + |resolver| !resolver.files_focused && editor.resolver_buffer_focus().is_some(), + ) +} + fn paint_diff_editor( frame: &mut Frame, editor: &Editor, @@ -2966,7 +3391,13 @@ fn paint_diff_editor( segment_paint_width(segment, final_segment, usize::from(area.width)) as u16; } paint_editor_line(frame, editor, source, line_area, left, &carets, &selections); - if source == source_cursor.row && editor.diff_interacting() { + if source == source_cursor.row + && editor.render_pane_focused() + && (diff_native_interacting(editor) + || editor + .resolver() + .is_some_and(|resolver| resolver.view == ResolverView::History)) + { let column = cursor_column; if column >= left && column < left.saturating_add(usize::from(line_area.width)) { cursor = Some(Position::new(area.x + (column - left) as u16, line_area.y)); @@ -3326,10 +3757,23 @@ fn draw_diff( } else { (workspace, None) }; - let pane_layout = diff.pane_layout(); + let resolver = editor + .and_then(Editor::resolver) + .filter(|resolver| resolver.view == ResolverView::Resolution); + let pane_layout = if resolver.is_some() { + PaneLayout::Split + } else { + diff.pane_layout() + }; let unified = diff.view_mode == ViewMode::Unified; let [before_area, after_area] = if unified { [panes, panes] + } else if resolver.is_some() && panes.width < 20 { + if diff.focus == Focus::After { + [Rect::default(), panes] + } else { + [panes, Rect::default()] + } } else { match pane_layout { PaneLayout::Split => split_rect(pane_rect(panes), diff.split_axis).map(terminal_rect), @@ -3355,7 +3799,7 @@ fn draw_diff( }) .and_then(|previous| { let editor = editor?; - let focus = editor.diff_buffer_focus()?; + let focus = diff_native_focus(editor)?; if previous.view_mode != diff.view_mode || previous.split_axis != diff.split_axis { return previous.caret_row; } @@ -3376,7 +3820,7 @@ fn draw_diff( } }); let center_row = editor.and_then(|editor| { - let focus = editor.diff_buffer_focus()?; + let focus = diff_native_focus(editor)?; let pane = if focus == Focus::Before { before_area } else { @@ -3384,7 +3828,7 @@ fn draw_diff( }; let height = usize::from(diff_inner(pane).height); let file = selected.filter(|file| file.loaded)?; - (editor.diff_interacting() + (diff_native_interacting(editor) && diff.follow_caret && height > 0 && diff_pane_text_width(diff, file, pane, focus) > 0 @@ -3397,12 +3841,8 @@ fn draw_diff( let toggle_row = center_row.or(toggle_row); if wrapping && let Some(file) = selected.filter(|file| file.loaded) { let caret = editor - .filter(|editor| editor.diff_interacting() && diff.follow_caret) - .and_then(|editor| { - editor - .diff_buffer_focus() - .map(|focus| (focus, editor.cursor())) - }); + .filter(|editor| diff_native_interacting(editor) && diff.follow_caret) + .and_then(|editor| diff_native_focus(editor).map(|focus| (focus, editor.cursor()))); let viewport = panels .diff_wrap .get_or_insert_with(DiffWrapViewport::default); @@ -3419,8 +3859,8 @@ fn draw_diff( panels.diff_wrap = None; } if let Some(editor) = - editor.filter(|editor| !wrapping && editor.diff_interacting() && diff.follow_caret) - && let Some(focus) = editor.diff_buffer_focus() + editor.filter(|editor| !wrapping && diff_native_interacting(editor) && diff.follow_caret) + && let Some(focus) = diff_native_focus(editor) { let (pane, left) = match focus { Focus::Before => (before_area, &mut before_left), @@ -3502,7 +3942,7 @@ fn draw_diff( && let Some(syntax) = &panels.diff_syntax { if pane_layout == PaneLayout::Empty { - diff_pane_panel(frame, diff, Some(file), before_area, true); + diff_pane_panel(frame, diff, Some(file), before_area, true, resolver); } else { if before_area != Rect::default() && (!unified || diff.focus == Focus::Before) { let text_area = draw_diff_pane( @@ -3526,10 +3966,12 @@ fn draw_diff( editor.is_some_and(Editor::highlight_trailing_whitespace), editor.map(Editor::whitespace_characters), editor.map_or(WhitespaceScope::All, Editor::whitespace_scope), + resolver, + false, ); text_widths[0] = text_area.width; if let Some(editor) = - editor.filter(|editor| editor.diff_buffer_focus() == Some(Focus::Before)) + editor.filter(|editor| diff_native_focus(editor) == Some(Focus::Before)) { document_caret = paint_diff_editor( frame, @@ -3565,10 +4007,12 @@ fn draw_diff( editor.is_some_and(Editor::highlight_trailing_whitespace), editor.map(Editor::whitespace_characters), editor.map_or(WhitespaceScope::All, Editor::whitespace_scope), + resolver, + false, ); text_widths[1] = text_area.width; if let Some(editor) = - editor.filter(|editor| editor.diff_buffer_focus() == Some(Focus::After)) + editor.filter(|editor| diff_native_focus(editor) == Some(Focus::After)) { document_caret = paint_diff_editor( frame, @@ -3584,7 +4028,7 @@ fn draw_diff( } } } else { - let content = diff_pane_panel(frame, diff, selected, panes, true); + let content = diff_pane_panel(frame, diff, selected, panes, true, resolver); frame.render_widget( Paragraph::new(if diff.loading { "Loading file…" @@ -3646,10 +4090,40 @@ fn diff_pane_panel( file: Option<&ChangedFile>, area: Rect, before: bool, + resolver: Option<&ResolverWorkspace>, ) -> Rect { if area.width < 4 || area.height < 4 { return area; } + if let Some(resolver) = resolver { + let label = if before { " Draft " } else { " Alternative " }; + let state = if before { " editable " } else { " readonly " }; + let source = resolver + .files + .get(resolver.current_file) + .and_then(|file| { + file.sources.get(if before { + file.seed_source + } else { + resolver.selected_source + }) + }) + .map_or("", |source| source.label.as_str()); + let revision = if before { + format!(" seeded from {} ", display_text(source)) + } else { + format!(" {} ", display_text(source)) + }; + let focus = if before { Focus::Before } else { Focus::After }; + return diff_panel( + frame, + area, + label, + state, + &revision, + !resolver.files_focused && diff.focus == focus, + ); + } let title = file.map_or_else( || format!(" Diff · {} ", diff.comparison_mode.label()), |file| { @@ -3722,12 +4196,18 @@ fn draw_diff_pane( highlight_trailing_whitespace: bool, whitespace_characters: Option<&BTreeMap>, whitespace_scope: WhitespaceScope, + resolver: Option<&ResolverWorkspace>, + content_only: bool, ) -> Rect { if area.width == 0 || area.height == 0 { return area; } let focus = if before { Focus::Before } else { Focus::After }; - let content = diff_pane_panel(frame, diff, Some(file), area, before); + let content = if content_only { + area + } else { + diff_pane_panel(frame, diff, Some(file), area, before, resolver) + }; if diff.display_rows.is_empty() { frame.render_widget( Paragraph::new(file.notice.as_deref().map(display_text).unwrap_or_else(|| { @@ -3788,8 +4268,10 @@ fn draw_diff_pane( } continue; }; - let Some(source_text) = source.get(index) else { - continue; + let source_text = match source.get(index) { + Some(text) => text.as_str(), + None if resolver.is_some() && index == source.len() => "", + None => continue, }; let segment = segments.and_then(|segments| segments[usize::from(!before)]); let left = segment.map_or(left, |segment| segment.start); @@ -3849,7 +4331,7 @@ fn draw_diff_pane( (None, None) => unreachable!("displayed diff line has a source"), } } else { - let digits = source.len().max(1).ilog10() as usize + 1; + let digits = diff_source_len(diff, file, before).max(1).ilog10() as usize + 1; format!("{:>digits$}{marker} ", index + 1) }; frame.render_widget( @@ -3914,7 +4396,7 @@ fn draw_diff_pane( .collect::(), ) } else { - Cow::Borrowed(source_text.as_str()) + Cow::Borrowed(source_text) }; paint_trailing_whitespace(frame, &text, text_area, left); } @@ -4033,9 +4515,22 @@ fn diff_text_width(area: Rect, source_len: usize) -> usize { width.saturating_sub(if width > digits + 2 { digits + 2 } else { 0 }) } +fn diff_source_len(diff: &DiffState, file: &ChangedFile, before: bool) -> usize { + let stored = if before { + file.before.len() + } else { + file.after.len() + }; + let last = diff + .rows + .last() + .and_then(|row| if before { row.before } else { row.after }); + stored.max(last.map_or(0, |row| row + 1)) +} + fn diff_gutter(diff: &DiffState, file: &ChangedFile, width: usize, focus: Focus) -> usize { - let old = file.before.len().max(1).ilog10() as usize + 1; - let new = file.after.len().max(1).ilog10() as usize + 1; + let old = diff_source_len(diff, file, true).max(1).ilog10() as usize + 1; + let new = diff_source_len(diff, file, false).max(1).ilog10() as usize + 1; let gutter = if diff.view_mode == ViewMode::Unified { old + new + 3 } else { @@ -4721,6 +5216,34 @@ impl WheelPanels { }) else { return false; }; + if let Some(diff) = session.editor().resolver_diff() { + let wrapping = diff.word_wrap; + let viewport = if !horizontal && wrapping { + diff.comparison.as_ref().and_then(|comparison| { + let file = comparison.files.get(diff.file)?; + let wrap = self.diff_wrap.as_mut()?; + for _ in 0..3 { + wrap.top = wrap.step(diff, file, wrap.top, down); + } + Some((wrap.top.display, diff.left_before, diff.left_after)) + }) + } else { + None + }; + if horizontal && !wrapping { + let width = + self.diff.as_ref().unwrap().text_widths[usize::from(focus == Focus::After)]; + session.scroll_resolver_horizontal(focus, down, usize::from(width)); + } else if let Some((top, before, after)) = viewport { + session.scroll_resolver(focus, down); + session.set_resolver_viewport(top, before, after); + } else if !horizontal { + for _ in 0..3 { + session.scroll_resolver(focus, down); + } + } + return true; + } let (wrapping, wrapped_viewport) = session.editor_mut().with_render_pane(pane, |editor| { let wrapping = editor.word_wrap(); let viewport = if !horizontal && wrapping && focus != Focus::Files { @@ -6511,7 +7034,7 @@ mod tests { } #[test] - fn resolver_renders_native_result_sources_explorer_and_narrow_focus() { + fn resolver_renders_aligned_native_draft_alternative_and_separate_history() { let directory = Directory::new(); let jj = |args: &[&str]| { let output = std::process::Command::new("jj") @@ -6535,7 +7058,12 @@ mod tests { }; let text = |side: &str| { format!( - "{side}\n{}", + "value = {side};\n{}{}last = {side};\n", + if side == "third" { + "third-only third-only third-only third-only third-only third-only third-only\n" + } else { + "" + }, (0..60) .map(|row| format!("context {row:02}\n")) .collect::() @@ -6570,7 +7098,14 @@ mod tests { 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"]); + jj(&["new", &base]); + for path in ["first.txt", "second.txt"] { + directory.file(path, &text("third")); + } + fs::write(directory.0.join("binary.bin"), b"third\0").unwrap(); + jj(&["describe", "-m", "third"]); + let third = jj(&["log", "--no-graph", "-r", "@", "-T", "commit_id"]); + jj(&["new", &left, &right, &third, "-m", "conflicts"]); let operation = || { jj(&[ "--ignore-working-copy", @@ -6584,8 +7119,10 @@ mod tests { ]) }; 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 config = directory.file( + "config.rb", + "Vamp.configure do |config|\n config.clipboard_mode = 'separate'\nend\n", + ); let mut session = EditorSession::open( Some(directory.0.join("first.txt")), vec!["text".into()], @@ -6613,10 +7150,20 @@ mod tests { } panic!("Resolver did not settle: {}", session.editor().message()); }; + session.handle(Input::Char('/')); + for ch in "context 00".chars() { + session.handle(Input::Char(ch)); + } + session.handle(Input::Enter); + session.handle(Input::Home); + session.handle(Input::Char('i')); + session.handle(Input::Paste("opening ".into())); + session.handle(Input::Escape); session.execute("resolve"); ready(&mut session); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 30)).unwrap(); let mut views = BufferViewports::default(); + views.set_theme(Some(DARK_DIFF_THEME)); let render = |terminal: &mut Terminal, views: &mut BufferViewports, session: &mut EditorSession| { @@ -6629,82 +7176,329 @@ mod tests { 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 + let draft_pane = resolver.draft_pane; + let alternative_pane = resolver.alternative_pane.unwrap(); + let alternative_index = resolver.selected_source; + let original_draft = session.editor().lines().to_vec(); + let geometry = views.panels.diff.as_ref().unwrap(); + let draft = geometry.regions[1].0; + let alternative = geometry.regions[2].0; + assert!(draft.contains(caret)); + assert_eq!(views.rects.len(), 2); + assert_eq!((draft.y, draft.height), (alternative.y, alternative.height)); + assert_eq!(draft.right(), alternative.x); + assert!(draft.height > 20); + assert!(views.panels.resolver_files.is_none()); + let screen = screen_text(&terminal); + assert!(screen.contains("Draft") && screen.contains("Alternative")); + assert!(screen.contains("unchanged lines hidden")); + assert!(!screen.contains("context 30")); + assert!(!screen.contains("<<<<<<<") && !screen.contains("%%%%%%%")); + let line_at = |terminal: &Terminal, area: Rect, y: u16| { + (area.x..area.right()) + .map(|x| terminal.backend().buffer()[(x, y)].symbol()) + .collect::() + }; + for (side, area) in [draft, alternative].into_iter().enumerate() { + let content = diff_inner(area); + let x = content.right() - geometry.text_widths[side]; + let buffer = terminal.backend().buffer(); + assert_eq!(buffer[(x, content.y)].symbol(), "v"); + assert_eq!( + buffer[(x, content.y)].bg, + views.panels.diff_colors.row[side] + ); + assert_eq!( + buffer[(x + 8, content.y)].bg, + views.panels.diff_colors.character[side] + ); + } + assert_ne!( + line_at(&terminal, diff_inner(draft), draft.y + 1), + line_at(&terminal, diff_inner(alternative), alternative.y + 1) + ); + for area in [draft, alternative] { + assert!( + line_at(&terminal, diff_inner(area), area.y + 2).contains("opening context 00"), + "Preexisting unsaved working-buffer edits must appear in both projections" + ); + } + let third_source = session.editor().resolver().unwrap().files[current_file] + .sources .iter() - .find(|(pane, _)| *pane == result_pane) - .unwrap() - .1; - assert!(result.contains(caret)); + .position(|source| !source.base && source.text.starts_with("value = third;")) + .unwrap(); + session.execute(&format!("resolve.source {}", third_source + 1)); + render(&mut terminal, &mut views, &mut session); + assert!( + line_at(&terminal, diff_inner(alternative), alternative.y + 1) + .contains("value = third;") + ); + let right_content = diff_inner(alternative); + assert!( + (right_content.y..right_content.bottom()) + .any(|y| { line_at(&terminal, right_content, y).contains("opening context 00") }) + ); + session.execute("resolve.draft"); + assert_eq!(session.editor().lines(), original_draft); + session.execute(&format!("resolve.source {}", alternative_index + 1)); + session.execute("resolve.draft"); + + session.handle(Input::Char('\t')); + let caret = render(&mut terminal, &mut views, &mut session); + assert_eq!(session.editor().pane_id(), alternative_pane); + assert!(alternative.contains(caret)); + let readonly = session.editor().lines().to_vec(); + session.handle(Input::Char('i')); + session.handle(Input::Char('X')); + session.handle(Input::Escape); + assert_eq!(session.editor().lines(), readonly); + session.execute("resolve.draft"); + session.handle(Input::Home); + for _ in 0..8 { + session.handle(Input::Right); + } + session.handle(Input::Char('i')); + session.handle(Input::Paste("\ndraft-only".into())); + session.handle(Input::Escape); + render(&mut terminal, &mut views, &mut session); + let geometry = views.panels.diff.as_ref().unwrap(); + let right = diff_inner(geometry.regions[2].0); + assert!( + (right.y..right.bottom()) + .any(|y| { line_at(&terminal, right, y).chars().all(|ch| ch == '╱') }), + "The shorter alternative must render an aligned gap" + ); + let context_rows: Vec<_> = geometry.regions[1..] + .iter() + .map(|(area, _)| { + let content = diff_inner(*area); + (content.y..content.bottom()) + .find(|&y| line_at(&terminal, content, y).contains("context 00")) + .unwrap() + }) + .collect(); + assert_eq!(context_rows[0], context_rows[1]); + session.handle(Input::Char('u')); + assert_eq!(session.editor().lines(), original_draft); + session.handle(Input::Down); + session.handle(Input::Home); + session.handle(Input::Char('i')); + session.handle(Input::Paste("manual ".into())); + session.handle(Input::Escape); + render(&mut terminal, &mut views, &mut session); + let geometry = views.panels.diff.as_ref().unwrap(); + let left = diff_inner(geometry.regions[1].0); + let right = diff_inner(geometry.regions[2].0); + assert!(line_at(&terminal, left, left.y + 1).contains("manual opening context 00")); + assert!(line_at(&terminal, right, right.y + 1).contains("opening context 00")); + assert!(!line_at(&terminal, right, right.y + 1).contains("manual")); + session.handle(Input::Char('u')); + assert_eq!(session.editor().lines(), original_draft); + session.handle(Input::Up); + session.execute(&format!("resolve.use_source {}", alternative_index + 1)); + render(&mut terminal, &mut views, &mut session); + let taken = session.editor().lines().to_vec(); + assert_ne!(taken[0], original_draft[0]); + assert_eq!( + taken[61], original_draft[61], + "Taking a decision must not replace another hunk" + ); + session.handle(Input::Char('u')); + assert_eq!(session.editor().lines(), original_draft); + + session.handle(Input::Char(']')); + render(&mut terminal, &mut views, &mut session); + assert!(screen_text(&terminal).contains("context 10")); + let cursor = session.editor().cursor(); + let before_scroll = views.panels.diff.as_ref().unwrap().viewport.unwrap().2; + assert!(views.scroll( + &mut session, + document_wheel( + MouseEventKind::ScrollDown, + alternative.x + 1, + alternative.y + 1, + ) + )); + render(&mut terminal, &mut views, &mut session); + assert!(views.panels.diff.as_ref().unwrap().viewport.unwrap().2 > before_scroll); + assert_eq!(session.editor().pane_id(), draft_pane); + assert_eq!(session.editor().cursor(), cursor); + let geometry = views.panels.diff.as_ref().unwrap(); + assert!( + line_at(&terminal, diff_inner(geometry.regions[1].0), draft.y + 1).contains("context") + ); + assert!( + line_at( + &terminal, + diff_inner(geometry.regions[2].0), + alternative.y + 1 + ) + .contains("context") + ); + + session.handle(Input::Char('/')); + for ch in "context 30".chars() { + session.handle(Input::Char(ch)); + } + session.handle(Input::Enter); + let searched_caret = render(&mut terminal, &mut views, &mut session); + assert!(screen_text(&terminal).contains("context 30")); + assert_eq!( + terminal.backend().buffer()[(searched_caret.x, searched_caret.y)].bg, + Color::Yellow + ); + session.handle(Input::Char('v')); + session.handle(Input::Right); + render(&mut terminal, &mut views, &mut session); + assert_eq!( + terminal.backend().buffer()[(searched_caret.x, searched_caret.y)].bg, + Color::Blue + ); + session.handle(Input::Escape); + session.handle(Input::Left); + let searched_caret = render(&mut terminal, &mut views, &mut session); + let draft_cursor = session.editor().cursor(); + let draft_viewport = views.panels.diff.as_ref().unwrap().viewport; + session.execute("resolve.history"); + render(&mut terminal, &mut views, &mut session); 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) + .all(|(pane, _)| *pane != draft_pane && *pane != alternative_pane) ); - 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 + let source_rows: Vec<_> = 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(); + .map(|(_, area)| line_at(&terminal, *area, area.y)) + .collect(); + assert!(source_rows.iter().any(|row| row.contains("base"))); + assert!(source_rows.iter().any(|row| row.contains("left"))); + assert!(source_rows.iter().any(|row| row.contains("right"))); + let history = &views.history[&views.history_key.unwrap()]; + for (column, (_, content)) in views.rects.iter().enumerate() { + let x = content.right() - history.widths[column] as u16; + let side = usize::from(column != 1); + let buffer = terminal.backend().buffer(); + assert_eq!(buffer[(x, content.y)].symbol(), "v"); + assert_eq!( + buffer[(x, content.y)].bg, + views.panels.diff_colors.row[side] + ); + assert_eq!( + buffer[(x + 8, content.y)].bg, + views.panels.diff_colors.character[side] + ); + assert_eq!( + buffer[(x, content.y + 1)].bg, + Color::Reset, + "Shared historical context must not be highlighted as changed" + ); + } + let readonly = 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_eq!(session.editor().lines(), readonly); + let resolver = session.editor().resolver().unwrap(); + let right_history = resolver.history_panes[resolver.history_sources[1]]; + session.editor_mut().focus_resolver_pane(right_history); + session.execute(&format!("resolve.source {}", third_source + 1)); + render(&mut terminal, &mut views, &mut session); assert!( views .rects .iter() - .find(|(pane, _)| *pane == result_pane) - .unwrap() - .1 - .contains(caret) + .any(|(_, area)| { line_at(&terminal, *area, area.y).contains("value = third;") }) ); - session.execute("resolve.files"); + let history_areas = [views.rects[0].1, views.rects[1].1, views.rects[2].1]; + for content in history_areas.into_iter().take(2) { + assert!( + line_at(&terminal, content, content.y + 1) + .chars() + .all(|ch| ch == '╱'), + "The historical input insertion must leave aligned gaps in input/base" + ); + } + assert!( + line_at(&terminal, history_areas[2], history_areas[2].y + 1).contains("third-only") + ); + let context_rows: Vec<_> = history_areas + .into_iter() + .map(|content| { + (content.y..content.bottom()) + .find(|&y| line_at(&terminal, content, y).contains("context 00")) + .unwrap() + }) + .collect(); + assert!(context_rows.iter().all(|row| *row == context_rows[0])); session.handle(Input::Down); - render(&mut terminal, &mut views, &mut session); - assert_eq!( - session.editor().resolver().unwrap().current_file, - current_file + session.handle(Input::End); + session.execute("word_wrap.toggle"); + let history_caret = render(&mut terminal, &mut views, &mut session); + assert!(history_areas[2].contains(history_caret)); + let history = &views.history[&views.history_key.unwrap()]; + assert!( + history.visible[2] + .iter() + .any(|row| row.position.continuation > 0) ); + let continuation = history.visible[2] + .iter() + .position(|row| row.position.continuation > 0) + .unwrap(); + for content in history_areas.into_iter().take(2) { + assert!( + line_at(&terminal, content, content.y + continuation as u16) + .chars() + .all(|ch| ch == '╱') + ); + } + session.execute("word_wrap.toggle"); + session.handle(Input::Home); + session.handle(Input::Up); + render(&mut terminal, &mut views, &mut session); + let history_pane = session.editor().pane_id(); + let history_cursor = session.editor().cursor(); + assert!(views.scroll( + &mut session, + document_wheel( + MouseEventKind::ScrollDown, + history_areas[1].x, + history_areas[1].y + ) + )); + render(&mut terminal, &mut views, &mut session); + for content in history_areas { + assert!(line_at(&terminal, content, content.y).contains("context 01")); + } + assert_eq!(session.editor().pane_id(), history_pane); + assert_eq!(session.editor().cursor(), history_cursor); + assert!(views.scroll( + &mut session, + document_wheel( + MouseEventKind::ScrollUp, + history_areas[1].x, + history_areas[1].y + ) + )); + session.execute("resolve.base"); + terminal.backend_mut().resize(44, 20); + let caret = render(&mut terminal, &mut views, &mut session); + assert_eq!(views.rects.len(), 1); + assert!(views.rects[0].1.contains(caret)); + assert!(screen_text(&terminal).contains("value = base;")); + terminal.backend_mut().resize(120, 30); + session.execute("resolve.draft"); + let restored_caret = render(&mut terminal, &mut views, &mut session); + assert_eq!(session.editor().cursor(), draft_cursor); + assert_eq!(views.panels.diff.as_ref().unwrap().viewport, draft_viewport); + assert_eq!(restored_caret, searched_caret); + + session.execute("resolve.files"); + render(&mut terminal, &mut views, &mut session); assert!(screen_text(&terminal).contains("Conflicts")); session.handle(Input::Char('/')); for ch in "second".chars() { @@ -6714,49 +7508,72 @@ mod tests { assert!(screen_text(&terminal).contains("second.txt")); assert!(!screen_text(&terminal).contains("binary.bin")); session.handle(Input::Escape); - session.execute("resolve.result"); + session.execute("resolve.files"); + session.execute("resolve.draft"); assert_eq!( operation(), before_browsing, "Rendering/navigation must not snapshot jj" ); + session.handle(Input::Home); session.handle(Input::Char('i')); - session.handle(Input::Char('X')); + session.handle(Input::Paste("wrapped ".repeat(12))); session.handle(Input::Escape); - render(&mut terminal, &mut views, &mut session); - assert!(screen_text(&terminal).contains("unsaved")); + session.execute("word_wrap.toggle"); + terminal.backend_mut().resize(44, 20); + let caret = render(&mut terminal, &mut views, &mut session); + let geometry = views.panels.diff.as_ref().unwrap(); + assert!(geometry.regions[1].0.contains(caret)); + assert!( + views + .panels + .diff_wrap + .as_ref() + .unwrap() + .rows + .iter() + .any(|row| row.position.continuation > 0) + ); + let content = diff_inner(geometry.regions[1].0); + assert!( + (content.y..content.bottom()) + .filter(|&y| { line_at(&terminal, content, y).contains("wrapped") }) + .count() + > 1 + ); + let right = diff_inner(geometry.regions[2].0); + assert!( + (right.y..right.bottom()).all(|y| !line_at(&terminal, right, y).contains("wrapped")) + ); 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("word_wrap.toggle"); session.execute("w"); - render(&mut terminal, &mut views, &mut session); - assert!(screen_text(&terminal).contains("checking")); ready(&mut session); + terminal.backend_mut().resize(120, 30); 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); + for (width, height) in [(19, 12), (4, 4), (1, 1), (0, 0)] { + terminal.backend_mut().resize(width, height); + render(&mut terminal, &mut views, &mut session); + for (_, area) in &views.rects { + assert!(area.right() <= width && area.bottom() <= height); + } + } terminal.backend_mut().resize(120, 30); + session.editor_mut().select_resolver_file(binary); render(&mut terminal, &mut views, &mut session); assert!(screen_text(&terminal).contains("Whole-file conflict")); - assert!(!views.rects.iter().any(|(pane, _)| *pane == result_pane)); + assert!(views.rects.is_empty()); let resolver = session.editor().resolver().unwrap(); let side = resolver.files[binary] .sources diff --git a/tests/resolve.rs b/tests/resolve.rs index 0a954f8..01f5540 100755 --- a/tests/resolve.rs +++ b/tests/resolve.rs @@ -146,7 +146,10 @@ fn resolver_keeps_unsaved_result_and_undo_then_saves_and_retains_resolved_file() ]); session.execute("resolve"); ready(&mut session); - assert_eq!(session.editor().context().text, draft); + let seeded = format!("draft\n{}", Fixture::text("left")); + assert_eq!(session.editor().context().text, seeded); + assert_ne!(seeded, draft); + assert!(session.editor().dirty()); assert_eq!( fs::read_to_string(fixture.root.join("first.txt")).unwrap(), original_disk @@ -164,23 +167,33 @@ fn resolver_keeps_unsaved_result_and_undo_then_saves_and_retains_resolved_file() ]), operation ); + session.handle(Input::Char('u')); + assert_eq!(session.editor().context().text, draft); + session.handle(Input::Char('U')); + assert_eq!(session.editor().context().text, seeded); let source = session.editor().resolver().unwrap().files[0] .sources .iter() - .position(|source| !source.base) + .enumerate() + .filter(|(_, source)| !source.base) + .nth(1) .unwrap() + .0 + 1; session.execute(&format!("resolve.use_source {source}")); - assert_ne!(session.editor().context().text, draft); - assert!(session.editor().context().text.starts_with("draft\n")); + let chosen = session.editor().context().text; + assert_ne!(chosen, seeded); + assert!(chosen.starts_with("draft\n")); + assert!(chosen.contains("context_15\n")); session.handle(Input::Char('u')); - assert_eq!(session.editor().context().text, draft); - session.execute(&format!("resolve.use_source {source}")); - session.execute("resolve.next"); + assert_eq!(session.editor().context().text, seeded); session.execute(&format!("resolve.use_source {source}")); + let cursor = session.editor().cursor(); let resolved_text = session.editor().context().text; - assert!(resolved_text.starts_with("draft\n")); - assert!(resolved_text.contains("context_15\n")); + session.execute("resolve.view.toggle"); + session.execute("resolve.view.toggle"); + assert_eq!(session.editor().context().text, resolved_text); + assert_eq!(session.editor().cursor(), cursor); session.execute("w"); ready(&mut session); let resolver = session.editor().resolver().unwrap();