feat: resolve conflicts through focused two-way drafts
Replace the marker-filled three-pane resolver with a marker-free native Draft versus frozen Alternative projected from current working text. Preserve preexisting outside edits, native undo, dirty-save guards, all sides/bases, and draft caret/viewports across separate History views. Reuse DiffState alignment/highlighting/excerpts for Resolution and merge its base-aligned rows for readonly three-way History. Hide explorer and source-selector chrome by default. Verified 926 tests plus actual terminal projection, History highlighting, hunk choice, optional explorer, and guarded jj-confirmed save workflows. Clippy completes with pre-existing warnings only. Smoke fixtures stay unresolved in this ancestry; remove that ancestry before publication. Size exception: approximately 1650 implementation added/deleted lines, excluding tests/docs, kept coherent because native draft lifecycle and aligned rendering must change together without parallel legacy modes.
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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` |
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
|
||||
+11
-8
@@ -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.
|
||||
|
||||
|
||||
+103
@@ -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<usize> = 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;
|
||||
|
||||
+875
-171
File diff suppressed because it is too large
Load Diff
+5
-4
@@ -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.
|
||||
|
||||
+213
-3
@@ -858,7 +858,7 @@ pub struct Region {
|
||||
pub bases: Vec<String>,
|
||||
}
|
||||
impl Region {
|
||||
pub fn choose(&self, source: &Source) -> Result<String, String> {
|
||||
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<String, String> {
|
||||
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<String>, Vec<String
|
||||
(sides.len() >= 2 && bases.len() + 1 == sides.len()).then_some((sides, bases))
|
||||
}
|
||||
/// Recognize jj snapshot/diff/diff-experimental and Git diff3 markers at any
|
||||
/// marker width. This is navigation/selection only; jj alone decides resolution.
|
||||
/// marker width. Recognition is local; jj alone decides saved resolution status.
|
||||
pub fn regions(text: &str) -> Vec<Region> {
|
||||
let lines = text.split_inclusive('\n').collect::<Vec<_>>();
|
||||
let mut regions: Vec<Region> = Vec::new();
|
||||
@@ -986,6 +989,66 @@ pub fn regions(text: &str) -> Vec<Region> {
|
||||
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<String>,
|
||||
}
|
||||
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<Self, String> {
|
||||
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::<Vec<_>>();
|
||||
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<String, String> {
|
||||
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<Source> {
|
||||
(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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
+1039
-222
File diff suppressed because it is too large
Load Diff
+22
-9
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user