feat: resolve current jj working-copy conflicts
Add a dedicated native resolver workspace with all side/base inputs, shared editable Results, region choices, retained per-file status, responsive rendering, and explicit nontext file choices. Guard writes with jj working-copy locking, frozen checkout identity, exact disk baselines, native dirty-buffer protection, and asynchronous jj snapshot/status confirmation. Historical comparisons remain readonly. Verified all 916 tests and live isolated terminal workflows covering text regions, narrow layouts, binary bytes and symlink targets/types. Clippy completes with pre-existing warnings only. Size exception: 2614 implementation additions/deletions excluding tests and docs. Native workspace integration, explicit file choices and the safe save/check operation are retained as one complete behavioral slice.
This commit is contained in:
@@ -40,6 +40,7 @@ Vamp starts in Normal mode. Press `i`, type text, then Esc to return to Normal.
|
|||||||
| Floating/docked explorer in Normal or Visual | Space then `o` / Space then `e` |
|
| 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 |
|
| 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 |
|
| Diff layout in Normal | Space then `v` cycles split/unified; Space then `s` flips left/right vs above/below split |
|
||||||
|
| Resolve jj working-copy conflicts | `:resolve`; inspect readonly inputs, edit Result or `:resolve.use_source N`, then `:w` to save and confirm with jj |
|
||||||
|
|
||||||
Space is the default leader: press its suffix keys in order, not simultaneously. The [editing guide](docs/editing.md) contains the complete key table and behavior reference.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
- [ ] jj conflict resolution with ui akin to the diff viewer
|
- [x] jj conflict resolution with ui akin to the diff viewer
|
||||||
- [ ] make selecting a file in the diff view file picker focus the after buffer instead of the before buffer
|
- [ ] make selecting a file in the diff view file picker focus the after buffer instead of the before buffer
|
||||||
- [ ] improve the lsp hover floating window:
|
- [ ] improve the lsp hover floating window:
|
||||||
- it should be taller
|
- it should be taller
|
||||||
|
|||||||
@@ -78,6 +78,8 @@ Comparison views reserve no separate metadata header or native-buffer help foote
|
|||||||
|
|
||||||
Each comparison owns its split/unified mode and split axis; startup defaults apply only when opening a comparison. Source alignment and file-side availability stay independent of presentation. Unified display rows carry a side projection: equal context maps both sources, changed runs emit Before rows followed by After rows, and folds retain aligned coordinates. Native navigation/editing continues to bind source buffers rather than a synthetic unified document. The TUI reuses side-specific syntax/wrap/overlay pipelines in one unified region or the existing axis-based split geometry, exposing only the active logical side for unified hit-testing and directional focus.
|
Each comparison owns its split/unified mode and split axis; startup defaults apply only when opening a comparison. Source alignment and file-side availability stay independent of presentation. Unified display rows carry a side projection: equal context maps both sources, changed runs emit Before rows followed by After rows, and folds retain aligned coordinates. Native navigation/editing continues to bind source buffers rather than a synthetic unified document. The TUI reuses side-specific syntax/wrap/overlay pipelines in one unified region or the existing axis-based split geometry, exposing only the active logical side for unified hit-testing and directional focus.
|
||||||
|
|
||||||
|
The resolver owns a dedicated native tab with immutable input buffers and shared editable Result buffers, independent of historical comparison state. `resolve` reads all alternating jj merge-tree terms from immutable Git-backed `jj:trees` metadata; marker parsing only provides region navigation and draft replacement. Session background jobs load inputs and perform guarded saves. The write path holds jj's working-copy lock while validating checkout identity and on-disk data and installing the chosen result, then delegates snapshot/status authority to jj. Save aliases and shared Result tabs route through the same guards. TUI resolver geometry reuses native document rendering, with two inputs above Result or a single selected input at narrow widths.
|
||||||
|
|
||||||
`Editor::scroll_distance()` supplies the session-wide optional edge margin. The TUI applies it to document/diff visual rows and unwrapped text columns, and retains independent picker offsets across navigation frames. Picker windows exclude their fixed chrome and align whole multi-row entries. File projections carry a monotonic viewport revision, so catalog/filter/expansion changes invalidate picker identity without hashing the entire catalog on every redraw. Metadata-only status changes do not move the viewport. Explicit LSP/wrap-toggle anchors and detached wheel views retain precedence.
|
`Editor::scroll_distance()` supplies the session-wide optional edge margin. The TUI applies it to document/diff visual rows and unwrapped text columns, and retains independent picker offsets across navigation frames. Picker windows exclude their fixed chrome and align whole multi-row entries. File projections carry a monotonic viewport revision, so catalog/filter/expansion changes invalidate picker identity without hashing the entire catalog on every redraw. Metadata-only status changes do not move the viewport. Explicit LSP/wrap-toggle anchors and detached wheel views retain precedence.
|
||||||
|
|
||||||
Mouse wheel input uses the last rendered document/panel rectangles for hit-testing. Document offsets and explorer scroll state remain frontend-owned; three-row wheel steps never become document cursor motions. Manual document views persist until logical caret-reveal, caret/selection, mode, revision, or buffer changes; offscreen primary and browsing-selection cursors are not placed. Hover wheel input reuses the existing editor-owned popup scrolling.
|
Mouse wheel input uses the last rendered document/panel rectangles for hit-testing. Document offsets and explorer scroll state remain frontend-owned; three-row wheel steps never become document cursor motions. Manual document views persist until logical caret-reveal, caret/selection, mode, revision, or buffer changes; offscreen primary and browsing-selection cursors are not placed. Hover wheel input reuses the existing editor-owned popup scrolling.
|
||||||
|
|||||||
@@ -223,6 +223,7 @@ Normal `C` / `D` / `Y` change/delete/copy the cursor-to-line-end suffix through
|
|||||||
| Normal | `follow_link` |
|
| Normal | `follow_link` |
|
||||||
| Normal | `diff`, `diff.mode.toggle`, `diff.view.toggle`, `diff.orientation.toggle`, `diff.review.toggle` |
|
| Normal | `diff`, `diff.mode.toggle`, `diff.view.toggle`, `diff.orientation.toggle`, `diff.review.toggle` |
|
||||||
| Normal, Insert, Visual, Command, Search | `diff.close` |
|
| Normal, Insert, Visual, Command, Search | `diff.close` |
|
||||||
|
| Normal | `resolve`, `resolve.next`, `resolve.previous`, `resolve.use_source`, `resolve.source`, `resolve.base`, `resolve.next_file`, `resolve.previous_file`, `resolve.files`, `resolve.result`, `resolve.close` |
|
||||||
| Normal, Insert, Visual, Command, Search | `word_wrap.toggle` |
|
| Normal, Insert, Visual, Command, Search | `word_wrap.toggle` |
|
||||||
| Normal, Insert, Visual, Command, Search | `git_blame.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` |
|
| Normal | `pane.split_horizontal`, `pane.split_vertical`, `pane.focus_left`, `pane.focus_down`, `pane.focus_up`, `pane.focus_right`, `pane.close`, `pane.force_close` |
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ Diff checks cover staged category/filter navigation, stale request cancellation,
|
|||||||
|
|
||||||
Conflicted-revision regressions preserve unchanged symlinks, defer oversized unselected blobs until file selection, compare selected conflict text with jj's projection, and exclude unchanged conflicts when comparing later revisions. Live terminal smoke covers local bookmark selection and opening an actual conflicted file without changing the checkout.
|
Conflicted-revision regressions preserve unchanged symlinks, defer oversized unselected blobs until file selection, compare selected conflict text with jj's projection, and exclude unchanged conflicts when comparing later revisions. Live terminal smoke covers local bookmark selection and opening an actual conflicted file without changing the checkout.
|
||||||
|
|
||||||
|
Resolver checks use isolated real jj repositories: read-only discovery, all sides/bases, per-region choice and undo, unsaved-buffer reuse, retained resolved files, jj-authoritative status, checkout/external-write refusal (including forced save), cancellation, nontext bytes/types/modes/deletion, and directory-child protection. Render checks cover native Result caret/focus, remapped pane movement and full/narrow layouts. Live PTY smoke resolves multiple text regions and binary/symlink whole-file choices, saving and verifying jj status without touching user repositories.
|
||||||
|
|
||||||
Repository gutter checks cover LF/CRLF normalization, additions/replacements/deletion anchors, staged/disk/unsaved Git text, unborn and missing named files, ignored/unsupported paths, read-only colocated/non-colocated jj queries, stale results and per-buffer isolation, plus rendered modes, colors, current-line precedence, wrap/scroll and narrow/split geometry. Live PTY smoke verifies all four modes, edit/undo refresh, external Git HEAD changes, and jj parent changes without editor restart.
|
Repository gutter checks cover LF/CRLF normalization, additions/replacements/deletion anchors, staged/disk/unsaved Git text, unborn and missing named files, ignored/unsupported paths, read-only colocated/non-colocated jj queries, stale results and per-buffer isolation, plus rendered modes, colors, current-line precedence, wrap/scroll and narrow/split geometry. Live PTY smoke verifies all four modes, edit/undo refresh, external Git HEAD changes, and jj parent changes without editor restart.
|
||||||
|
|
||||||
Normal-picker checks cover staged/disk/unsaved status, binary and mode changes, exact and ambiguous renames, deleted paths, unborn repositories, aliases and unusual filenames, ignores and safe roots, linked worktrees, and read-only colocated/non-colocated/conflicted jj parents. Catalog/render checks retain unchanged rows, query/selection/expansion and Tree/List navigation, clear stale metadata, restore recreated files, and sanitize rename labels. Live PTY smoke checks A/M/D/R colors in floating and docked pickers with the line gutter disabled, Git HEAD/jj parent refresh, and unchanged jj operation state during polling.
|
Normal-picker checks cover staged/disk/unsaved status, binary and mode changes, exact and ambiguous renames, deleted paths, unborn repositories, aliases and unusual filenames, ignores and safe roots, linked worktrees, and read-only colocated/non-colocated/conflicted jj parents. Catalog/render checks retain unchanged rows, query/selection/expansion and Tree/List navigation, clear stale metadata, restore recreated files, and sanitize rename labels. Live PTY smoke checks A/M/D/R colors in floating and docked pickers with the line gutter disabled, Git HEAD/jj parent refresh, and unchanged jj operation state during polling.
|
||||||
|
|||||||
@@ -456,6 +456,30 @@ Both diff panes apply available semantic tokens from their own buffer, independe
|
|||||||
|
|
||||||
Binary data, missing final newlines, permission/type changes, symlink targets, and submodule pointers have explicit notices. Symlinks are not followed and submodule contents are not traversed. jj conflicts use jj's file-content projection and carry conflict notices; ordinary files remain readable. Identical-content moves are shown as renames; modified moves appear as an added and removed file.
|
Binary data, missing final newlines, permission/type changes, symlink targets, and submodule pointers have explicit notices. Symlinks are not followed and submodule contents are not traversed. jj conflicts use jj's file-content projection and carry conflict notices; ordinary files remain readable. Identical-content moves are shown as renames; modified moves appear as an added and removed file.
|
||||||
|
|
||||||
|
## Jujutsu conflict resolution
|
||||||
|
|
||||||
|
Run **`:resolve`** in Normal mode for the current jj working copy. It opens a dedicated tab, selects the current file if conflicted (otherwise the first conflict), and focuses its existing Result buffer at the first unresolved region. Already-open unsaved Result text and undo history are retained. Historical comparisons remain read-only; resolving never implicitly checks out or creates another change.
|
||||||
|
|
||||||
|
Two read-only inputs appear above the editable Result, with a conflicted-file list on the right. Narrow terminals show one selected input above Result. Input labels identify numbered sides/bases and contributing changes/bookmarks when an exact tree match is available; otherwise they identify the source tree. All merge sides and bases remain selectable, including multi-sided jj conflicts. Inputs support native navigation, search, selection and copying, but reject edits, saves and mutating plugin effects.
|
||||||
|
|
||||||
|
| Command | Behavior |
|
||||||
|
| --- | --- |
|
||||||
|
| `resolve.next` / `resolve.previous` | Jump to the next/previous unresolved region |
|
||||||
|
| `resolve.source [N]` | Focus a numbered input; without N, cycle inputs |
|
||||||
|
| `resolve.base [N]` | Focus a numbered base; default first base |
|
||||||
|
| `resolve.use_source [N]` | Replace the current region from that input as one undoable edit; default selected input |
|
||||||
|
| `resolve.next_file` / `resolve.previous_file` | Open another retained file |
|
||||||
|
| `resolve.files` / `resolve.result` | Focus the file list / editable Result |
|
||||||
|
| `resolve.close` | Close the resolver without discarding dirty drafts |
|
||||||
|
|
||||||
|
Input indexes are one-based and include bases. Commands also appear in the palette and through corresponding `config.core.resolve` action methods; no new global default bindings are installed. Normal-mode Tab/Shift+Tab cycles visible panes and files. In the list, `/` filters, ordinary selection navigation moves the highlight, and Enter opens it. Native Result editing, search, undo/redo and remapped pane navigation work normally. There is no automatic “accept both”.
|
||||||
|
|
||||||
|
**`:w` saves and checks jj**, asynchronously. Before writing, the resolver locks the working copy and validates the frozen checkout and exact on-disk file state. Checkout changes, external writes and unsafe filesystem ancestors refuse the write, including force-save. Successful writes trigger jj's normal snapshot operation, which may capture other on-disk changes. The list distinguishes unresolved, unsaved, checking, resolved and unconfirmed states. jj—not marker scanning or a manual checkbox—confirms resolution. A failed confirmation keeps saved contents and reports unconfirmed status. Resolved files remain in the session for review. Dirty drafts prevent destructive resolver/pane/tab closes.
|
||||||
|
|
||||||
|
Binary, symlink, deletion, executable-bit and file/directory conflicts use explicit whole-file input choices: choose an input with `resolve.use_source`, then save to apply its exact bytes and type. Symlinks are never followed. Directory replacement refuses unrelated/untracked or modified child data. Git submodule choices and legacy jj conflict storage without `jj:trees` metadata report unsupported errors instead of fabricating a resolution.
|
||||||
|
|
||||||
|
Opening, navigating and choosing draft resolutions do not snapshot jj; only saving does. If another jj operation changes the frozen checkout, retain your draft, close after preserving it, and reopen the resolver against the new state.
|
||||||
|
|
||||||
## Command palette
|
## Command palette
|
||||||
|
|
||||||
Ctrl+p opens a searchable overlay without leaving the current mode. It lists each core action once, plus registered plugin commands, with their owner, effective bindings in the current mode, and eligible modes. Disabled or overridden bindings are not advertised; actions with no key remain available through the palette. Mode-ineligible entries are dimmed and cannot execute. Retired plugin commands disappear.
|
Ctrl+p opens a searchable overlay without leaving the current mode. It lists each core action once, plus registered plugin commands, with their owner, effective bindings in the current mode, and eligible modes. Disabled or overridden bindings are not advertised; actions with no key remain available through the palette. Mode-ineligible entries are dimmed and cannot execute. Retired plugin commands disappear.
|
||||||
|
|||||||
+1109
-16
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,7 @@ mod markdown;
|
|||||||
pub mod panes;
|
pub mod panes;
|
||||||
pub mod plugin;
|
pub mod plugin;
|
||||||
pub mod repository;
|
pub mod repository;
|
||||||
|
pub mod resolve;
|
||||||
pub mod ruby;
|
pub mod ruby;
|
||||||
pub mod session;
|
pub mod session;
|
||||||
mod snippet;
|
mod snippet;
|
||||||
|
|||||||
@@ -83,6 +83,11 @@ Diff: :diff (Normal); choose Before then After; Working checkout After edits liv
|
|||||||
Changed files: normal picker/explorer bindings, Ctrl+w close; A/M/D/R status and rename paths
|
Changed files: normal picker/explorer bindings, Ctrl+w close; A/M/D/R status and rename paths
|
||||||
Pull requests: :pull_requests [REMOTE]; GitHub/GitLab/Gitea Open/Closed/All tabs, type to search
|
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
|
Enter opens the immutable PR head against its merge base; API auth uses provider tokens or gh/glab
|
||||||
|
Resolve jj conflicts: :resolve opens the current working-copy conflicts in a dedicated workspace
|
||||||
|
Native readonly inputs above editable Result; Tab focus, :resolve.source N / :resolve.base N
|
||||||
|
:resolve.next/previous navigates regions; :resolve.use_source [N] chooses one region
|
||||||
|
:w saves and checks jj status; :resolve.next_file/previous_file switches files
|
||||||
|
:resolve.files focuses the list; :resolve.result focuses Result; :resolve.close closes cleanly
|
||||||
|
|
||||||
Ruby config: $XDG_CONFIG_HOME/vamp/config.rb or ~/.config/vamp/config.rb
|
Ruby config: $XDG_CONFIG_HOME/vamp/config.rb or ~/.config/vamp/config.rb
|
||||||
Set VAMP_RUBY to choose the Ruby executable.
|
Set VAMP_RUBY to choose the Ruby executable.
|
||||||
|
|||||||
+1317
File diff suppressed because it is too large
Load Diff
+172
-1
@@ -14,7 +14,10 @@ use std::{
|
|||||||
use crate::{
|
use crate::{
|
||||||
blame::BlameWorker,
|
blame::BlameWorker,
|
||||||
diff::{Focus, Operation as DiffOperation, Outcome as DiffOutcome},
|
diff::{Focus, Operation as DiffOperation, Outcome as DiffOutcome},
|
||||||
editor::{BlameSource, Cursor, Editor, Effect, Input, Invocation, Mode},
|
editor::{
|
||||||
|
BlameSource, Cursor, Editor, Effect, Input, Invocation, Mode, ResolverOutcome,
|
||||||
|
ResolverRequest,
|
||||||
|
},
|
||||||
lsp::Lsp,
|
lsp::Lsp,
|
||||||
plugin::{
|
plugin::{
|
||||||
DocumentEvent, DocumentEventKind, DocumentSnapshot, PluginEvent, PluginUpdate,
|
DocumentEvent, DocumentEventKind, DocumentSnapshot, PluginEvent, PluginUpdate,
|
||||||
@@ -26,6 +29,72 @@ use crate::{
|
|||||||
|
|
||||||
type DiffResult = (u64, Result<DiffOutcome, String>);
|
type DiffResult = (u64, Result<DiffOutcome, String>);
|
||||||
|
|
||||||
|
struct ResolverWorker {
|
||||||
|
requests: Option<Sender<ResolverRequest>>,
|
||||||
|
results: Receiver<ResolverOutcome>,
|
||||||
|
stopped: Arc<AtomicBool>,
|
||||||
|
thread: Option<JoinHandle<()>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ResolverWorker {
|
||||||
|
fn start() -> Result<Self, String> {
|
||||||
|
let (requests, incoming) = mpsc::channel::<ResolverRequest>();
|
||||||
|
let (outgoing, results) = mpsc::channel();
|
||||||
|
let stopped = Arc::new(AtomicBool::new(false));
|
||||||
|
let stop = Arc::clone(&stopped);
|
||||||
|
let thread = thread::Builder::new()
|
||||||
|
.name("vamp-resolve".into())
|
||||||
|
.spawn(move || {
|
||||||
|
while let Ok(request) = incoming.recv() {
|
||||||
|
if stop.load(Ordering::Acquire) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let cancelled = || stop.load(Ordering::Acquire);
|
||||||
|
let outcome = match request {
|
||||||
|
ResolverRequest::Load { tab, root } => ResolverOutcome::Loaded {
|
||||||
|
tab,
|
||||||
|
result: crate::resolve::load(&root, &cancelled),
|
||||||
|
},
|
||||||
|
ResolverRequest::Save {
|
||||||
|
tab,
|
||||||
|
file,
|
||||||
|
buffer,
|
||||||
|
workspace,
|
||||||
|
resolution,
|
||||||
|
quit,
|
||||||
|
} => ResolverOutcome::Saved {
|
||||||
|
tab,
|
||||||
|
file,
|
||||||
|
buffer,
|
||||||
|
quit,
|
||||||
|
result: crate::resolve::save(&workspace, file, resolution, &cancelled),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if outgoing.send(outcome).is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.map_err(|error| format!("Cannot start resolver worker: {error}"))?;
|
||||||
|
Ok(Self {
|
||||||
|
requests: Some(requests),
|
||||||
|
results,
|
||||||
|
stopped,
|
||||||
|
thread: Some(thread),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for ResolverWorker {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.stopped.store(true, Ordering::Release);
|
||||||
|
self.requests.take();
|
||||||
|
if let Some(thread) = self.thread.take() {
|
||||||
|
let _ = thread.join();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
#[derive(Clone, Copy)]
|
||||||
struct PullRequestJob {
|
struct PullRequestJob {
|
||||||
page: u32,
|
page: u32,
|
||||||
@@ -367,6 +436,7 @@ pub struct EditorSession {
|
|||||||
pending_completion: bool,
|
pending_completion: bool,
|
||||||
hover_owner: Option<String>,
|
hover_owner: Option<String>,
|
||||||
diff_workers: BTreeMap<usize, DiffWorker>,
|
diff_workers: BTreeMap<usize, DiffWorker>,
|
||||||
|
resolver_worker: Option<ResolverWorker>,
|
||||||
blame_worker: Option<BlameWorker>,
|
blame_worker: Option<BlameWorker>,
|
||||||
blame_source: Option<BlameSource>,
|
blame_source: Option<BlameSource>,
|
||||||
blame_lines: Option<Vec<Option<Arc<crate::blame::LineBlame>>>>,
|
blame_lines: Option<Vec<Option<Arc<crate::blame::LineBlame>>>>,
|
||||||
@@ -487,6 +557,7 @@ impl EditorSession {
|
|||||||
pending_completion: false,
|
pending_completion: false,
|
||||||
hover_owner: None,
|
hover_owner: None,
|
||||||
diff_workers: BTreeMap::new(),
|
diff_workers: BTreeMap::new(),
|
||||||
|
resolver_worker: None,
|
||||||
blame_worker: None,
|
blame_worker: None,
|
||||||
blame_source: None,
|
blame_source: None,
|
||||||
blame_lines: None,
|
blame_lines: None,
|
||||||
@@ -557,6 +628,7 @@ impl EditorSession {
|
|||||||
self.synchronize_documents();
|
self.synchronize_documents();
|
||||||
}
|
}
|
||||||
self.start_diff_request();
|
self.start_diff_request();
|
||||||
|
self.start_resolver_requests();
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn execute(&mut self, command: &str) {
|
pub fn execute(&mut self, command: &str) {
|
||||||
@@ -568,6 +640,67 @@ impl EditorSession {
|
|||||||
self.synchronize_documents();
|
self.synchronize_documents();
|
||||||
}
|
}
|
||||||
self.start_diff_request();
|
self.start_diff_request();
|
||||||
|
self.start_resolver_requests();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start_resolver_requests(&mut self) {
|
||||||
|
while let Some(request) = self.editor.take_resolver_request() {
|
||||||
|
if self.resolver_worker.is_none() {
|
||||||
|
match ResolverWorker::start() {
|
||||||
|
Ok(worker) => self.resolver_worker = Some(worker),
|
||||||
|
Err(error) => {
|
||||||
|
self.fail_resolver_request(request, error);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Err(error) = self
|
||||||
|
.resolver_worker
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.requests
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.send(request)
|
||||||
|
{
|
||||||
|
self.fail_resolver_request(error.0, "Resolver worker stopped unexpectedly".into());
|
||||||
|
self.resolver_worker = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fail_resolver_request(&mut self, request: ResolverRequest, error: String) {
|
||||||
|
let outcome = match request {
|
||||||
|
ResolverRequest::Load { tab, .. } => ResolverOutcome::Loaded {
|
||||||
|
tab,
|
||||||
|
result: Err(error),
|
||||||
|
},
|
||||||
|
ResolverRequest::Save {
|
||||||
|
tab,
|
||||||
|
file,
|
||||||
|
buffer,
|
||||||
|
quit,
|
||||||
|
..
|
||||||
|
} => ResolverOutcome::Saved {
|
||||||
|
tab,
|
||||||
|
file,
|
||||||
|
buffer,
|
||||||
|
quit,
|
||||||
|
result: Err(error),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
self.editor.apply_resolver_outcome(outcome);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn poll_resolver(&mut self) -> bool {
|
||||||
|
self.start_resolver_requests();
|
||||||
|
let mut changed = false;
|
||||||
|
if let Some(worker) = &self.resolver_worker {
|
||||||
|
while let Ok(outcome) = worker.results.try_recv() {
|
||||||
|
changed |= self.editor.apply_resolver_outcome(outcome);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
changed
|
||||||
}
|
}
|
||||||
|
|
||||||
fn start_diff_request(&mut self) {
|
fn start_diff_request(&mut self) {
|
||||||
@@ -1603,6 +1736,7 @@ impl EditorSession {
|
|||||||
changed |= self.poll_battery();
|
changed |= self.poll_battery();
|
||||||
self.refresh_checkout_diffs_at(now);
|
self.refresh_checkout_diffs_at(now);
|
||||||
changed |= self.poll_diff();
|
changed |= self.poll_diff();
|
||||||
|
changed |= self.poll_resolver();
|
||||||
for update in self.lsp.poll() {
|
for update in self.lsp.poll() {
|
||||||
match self.apply_update("core", update) {
|
match self.apply_update("core", update) {
|
||||||
Ok(applied) => changed |= applied,
|
Ok(applied) => changed |= applied,
|
||||||
@@ -1664,6 +1798,43 @@ mod tests {
|
|||||||
sync::atomic::{AtomicU64, Ordering},
|
sync::atomic::{AtomicU64, Ordering},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolver_load_errors_return_through_real_session_without_touching_the_draft() {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
let path = fixture.file("buffer.txt", "disk\n");
|
||||||
|
let mut session = EditorSession::open(Some(path), vec![], None).unwrap();
|
||||||
|
session
|
||||||
|
.editor
|
||||||
|
.apply(vec![Effect::Replace("draft\n".into())])
|
||||||
|
.unwrap();
|
||||||
|
let buffer = session.editor.buffer_id();
|
||||||
|
session.execute("resolve");
|
||||||
|
assert!(session.editor.resolver().unwrap().loading);
|
||||||
|
let outcome = session
|
||||||
|
.resolver_worker
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.results
|
||||||
|
.recv_timeout(Duration::from_secs(5))
|
||||||
|
.unwrap();
|
||||||
|
session.editor.apply_resolver_outcome(outcome);
|
||||||
|
assert!(!session.editor.resolver().unwrap().loading);
|
||||||
|
assert!(session.editor.resolver().unwrap().error.is_some());
|
||||||
|
session.execute("resolve.close");
|
||||||
|
assert!(
|
||||||
|
session
|
||||||
|
.editor
|
||||||
|
.documents()
|
||||||
|
.any(|document| document.id == buffer
|
||||||
|
&& document.dirty
|
||||||
|
&& document.lines == ["draft", ""])
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
fs::read_to_string(fixture.0.join("buffer.txt")).unwrap(),
|
||||||
|
"disk\n"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn pull_request_pages_stream_concurrently_and_retry_only_the_failed_category() {
|
fn pull_request_pages_stream_concurrently_and_retry_only_the_failed_category() {
|
||||||
use crate::diff::{PullRequestPage, RepoKind};
|
use crate::diff::{PullRequestPage, RepoKind};
|
||||||
|
|||||||
+694
-2
@@ -39,8 +39,8 @@ use crate::{
|
|||||||
ChangedFile, DiffState, DisplayRow, FileStatus, Focus, PaneLayout, Phase, RowKind, ViewMode,
|
ChangedFile, DiffState, DisplayRow, FileStatus, Focus, PaneLayout, Phase, RowKind, ViewMode,
|
||||||
},
|
},
|
||||||
editor::{
|
editor::{
|
||||||
Completion, Cursor, Editor, Hover, Input, Mode, Palette, Selection, StartupDiagnostic,
|
Completion, Cursor, Editor, Hover, Input, Mode, Palette, ResolverStatus, ResolverWorkspace,
|
||||||
WhitespaceScope,
|
Selection, StartupDiagnostic, WhitespaceScope,
|
||||||
},
|
},
|
||||||
files::{DockSide, FilePicker, FilePresentation, FileView},
|
files::{DockSide, FilePicker, FilePresentation, FileView},
|
||||||
panes::{PaneRect, SplitAxis, split_rect},
|
panes::{PaneRect, SplitAxis, split_rect},
|
||||||
@@ -450,6 +450,10 @@ impl BufferViewports {
|
|||||||
self.syntax.retain(|id, _| editor.has_buffer(*id));
|
self.syntax.retain(|id, _| editor.has_buffer(*id));
|
||||||
let syntax = self.syntax.entry(editor.buffer_id()).or_default();
|
let syntax = self.syntax.entry(editor.buffer_id()).or_default();
|
||||||
let full_workspace = draw_bars(frame, editor, frame.area(), syntax);
|
let full_workspace = draw_bars(frame, editor, frame.area(), syntax);
|
||||||
|
if editor.resolver().is_some() {
|
||||||
|
self.draw_resolver(frame, editor, full_workspace);
|
||||||
|
return;
|
||||||
|
}
|
||||||
let multiple = editor.pane_layout().len() > 1;
|
let multiple = editor.pane_layout().len() > 1;
|
||||||
let (workspace, dock) = match editor.file_picker() {
|
let (workspace, dock) = match editor.file_picker() {
|
||||||
Some(picker) if picker.presentation == FilePresentation::Docked => dock_layout(
|
Some(picker) if picker.presentation == FilePresentation::Docked => dock_layout(
|
||||||
@@ -585,6 +589,87 @@ impl BufferViewports {
|
|||||||
.any(|geometry| geometry.viewport.is_some());
|
.any(|geometry| geometry.viewport.is_some());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn draw_resolver(&mut self, frame: &mut Frame, editor: &mut Editor, area: Rect) {
|
||||||
|
let focused = editor.pane_id();
|
||||||
|
let resolver = editor.resolver().expect("resolver workspace is active");
|
||||||
|
let layout = resolver_layout(area, resolver, focused);
|
||||||
|
frame.render_widget(Clear, area);
|
||||||
|
draw_resolver_heading(frame, resolver, layout.heading, layout.selector);
|
||||||
|
self.rects.clear();
|
||||||
|
if let Some(file) = resolver.files.get(resolver.current_file) {
|
||||||
|
for (index, panel) in &layout.sources {
|
||||||
|
if let (Some(pane), Some(source)) =
|
||||||
|
(resolver.source_panes.get(*index), file.sources.get(*index))
|
||||||
|
{
|
||||||
|
let content = resolver_document_panel(
|
||||||
|
frame,
|
||||||
|
*panel,
|
||||||
|
&format!("{} · readonly", source.label),
|
||||||
|
!resolver.files_focused && focused == *pane,
|
||||||
|
);
|
||||||
|
self.rects.push((*pane, content));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let result = resolver_document_panel(
|
||||||
|
frame,
|
||||||
|
layout.result,
|
||||||
|
&format!("Result · {}", resolver_status(&file.status).0),
|
||||||
|
!resolver.files_focused && focused == resolver.result_pane,
|
||||||
|
);
|
||||||
|
if file.text {
|
||||||
|
self.rects.push((resolver.result_pane, result));
|
||||||
|
} else {
|
||||||
|
draw_resolver_choices(frame, resolver, result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
editor.set_pane_bounds(pane_rect(area));
|
||||||
|
for active_pass in [false, true] {
|
||||||
|
for index in 0..self.rects.len() {
|
||||||
|
let (id, leaf) = self.rects[index];
|
||||||
|
if (id == focused) != active_pass {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
self.activate(id, editor);
|
||||||
|
self.panels.workspace_managed = true;
|
||||||
|
editor.with_render_pane(id, |view| {
|
||||||
|
self.activate_document(view.buffer_id());
|
||||||
|
let (top, left) = self.offsets.entry(view.buffer_id()).or_default();
|
||||||
|
let syntax = self.syntax.entry(view.buffer_id()).or_default();
|
||||||
|
draw_document(
|
||||||
|
frame,
|
||||||
|
view,
|
||||||
|
leaf,
|
||||||
|
area,
|
||||||
|
top,
|
||||||
|
left,
|
||||||
|
syntax,
|
||||||
|
&mut self.document,
|
||||||
|
&mut self.panels,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.activate(focused, editor);
|
||||||
|
self.panels.workspace_managed = false;
|
||||||
|
if !self.rects.iter().any(|(id, _)| *id == focused) {
|
||||||
|
self.panels.begin_frame();
|
||||||
|
self.document.geometry = None;
|
||||||
|
}
|
||||||
|
let resolver = editor.resolver().expect("resolver workspace is active");
|
||||||
|
self.panels.resolver_files = layout.files;
|
||||||
|
if let Some(files) = layout.files {
|
||||||
|
draw_resolver_files(
|
||||||
|
frame,
|
||||||
|
resolver,
|
||||||
|
files,
|
||||||
|
editor.file_navigation_hint(),
|
||||||
|
&mut self.panels,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
draw_overlays(frame, editor, area, self.panels.caret, &mut self.panels);
|
||||||
|
self.diff_visible = false;
|
||||||
|
}
|
||||||
|
|
||||||
fn draw_session(&mut self, frame: &mut Frame, session: &mut EditorSession) {
|
fn draw_session(&mut self, frame: &mut Frame, session: &mut EditorSession) {
|
||||||
self.draw(frame, session.editor_mut());
|
self.draw(frame, session.editor_mut());
|
||||||
if let Some((comparison_id, file, top, left_before, left_after)) = self
|
if let Some((comparison_id, file, top, left_before, left_after)) = self
|
||||||
@@ -1493,6 +1578,333 @@ fn draw_bars(frame: &mut Frame, editor: &Editor, area: Rect, syntax: &mut Syntax
|
|||||||
workspace
|
workspace
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct ResolverLayout {
|
||||||
|
heading: Rect,
|
||||||
|
selector: Rect,
|
||||||
|
sources: Vec<(usize, Rect)>,
|
||||||
|
result: Rect,
|
||||||
|
files: Option<Rect>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolver_layout(area: Rect, resolver: &ResolverWorkspace, focused: usize) -> ResolverLayout {
|
||||||
|
let [heading, selector, workspace] = Layout::vertical([
|
||||||
|
Constraint::Length(u16::from(area.height >= 4)),
|
||||||
|
Constraint::Length(u16::from(area.height >= 7)),
|
||||||
|
Constraint::Fill(1),
|
||||||
|
])
|
||||||
|
.areas(area);
|
||||||
|
let (workspace, files) = if area.width >= 76 {
|
||||||
|
let [documents, files] = Layout::horizontal([
|
||||||
|
Constraint::Fill(1),
|
||||||
|
Constraint::Length((area.width / 4).clamp(24, 36)),
|
||||||
|
])
|
||||||
|
.areas(workspace);
|
||||||
|
(documents, Some(files))
|
||||||
|
} else if resolver.files_focused {
|
||||||
|
(Rect::default(), Some(workspace))
|
||||||
|
} else {
|
||||||
|
(workspace, None)
|
||||||
|
};
|
||||||
|
let mut layout = ResolverLayout {
|
||||||
|
heading,
|
||||||
|
selector,
|
||||||
|
sources: Vec::with_capacity(2),
|
||||||
|
result: workspace,
|
||||||
|
files,
|
||||||
|
};
|
||||||
|
let Some(file) = resolver.files.get(resolver.current_file) else {
|
||||||
|
return layout;
|
||||||
|
};
|
||||||
|
if file.sources.is_empty() || workspace.is_empty() {
|
||||||
|
return layout;
|
||||||
|
}
|
||||||
|
let selected = resolver.selected_source.min(file.sources.len() - 1);
|
||||||
|
if workspace.height < 4 {
|
||||||
|
if resolver.source_panes.get(selected) == Some(&focused) {
|
||||||
|
layout.sources.push((selected, workspace));
|
||||||
|
layout.result = Rect::default();
|
||||||
|
}
|
||||||
|
return layout;
|
||||||
|
}
|
||||||
|
let [sources, result] =
|
||||||
|
Layout::vertical([Constraint::Ratio(1, 2), Constraint::Fill(1)]).areas(workspace);
|
||||||
|
layout.result = result;
|
||||||
|
if workspace.width >= 88
|
||||||
|
&& let Some(other) = file
|
||||||
|
.sources
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.find(|(index, source)| *index != selected && !source.base)
|
||||||
|
.or_else(|| {
|
||||||
|
file.sources
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.find(|(index, _)| *index != selected)
|
||||||
|
})
|
||||||
|
.map(|(index, _)| index)
|
||||||
|
{
|
||||||
|
let [left, right] =
|
||||||
|
Layout::horizontal([Constraint::Ratio(1, 2), Constraint::Fill(1)]).areas(sources);
|
||||||
|
layout
|
||||||
|
.sources
|
||||||
|
.extend([(selected.min(other), left), (selected.max(other), right)]);
|
||||||
|
} else {
|
||||||
|
layout.sources.push((selected, sources));
|
||||||
|
}
|
||||||
|
layout
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolver_status(status: &ResolverStatus) -> (&'static str, Color) {
|
||||||
|
match status {
|
||||||
|
ResolverStatus::Unresolved => ("unresolved", Color::Yellow),
|
||||||
|
ResolverStatus::Unsaved => ("edited · unsaved", Color::Yellow),
|
||||||
|
ResolverStatus::Checking => ("saved · checking jj", Color::Cyan),
|
||||||
|
ResolverStatus::Resolved => ("resolved", Color::Green),
|
||||||
|
ResolverStatus::Unconfirmed => ("unconfirmed · jj check failed", Color::Red),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_resolver_heading(
|
||||||
|
frame: &mut Frame,
|
||||||
|
resolver: &ResolverWorkspace,
|
||||||
|
heading: Rect,
|
||||||
|
selector: Rect,
|
||||||
|
) {
|
||||||
|
let file = resolver.files.get(resolver.current_file);
|
||||||
|
let status = if resolver.loading {
|
||||||
|
("loading", Color::Cyan)
|
||||||
|
} else {
|
||||||
|
file.map_or(("no conflicted files", Color::Green), |file| {
|
||||||
|
resolver_status(&file.status)
|
||||||
|
})
|
||||||
|
};
|
||||||
|
let mut spans = vec![
|
||||||
|
Span::styled(
|
||||||
|
format!("Resolve · {} ", status.0),
|
||||||
|
Style::default().fg(status.1),
|
||||||
|
),
|
||||||
|
Span::raw(file.map_or_else(|| Cow::Borrowed(""), |file| display_path(&file.path))),
|
||||||
|
];
|
||||||
|
if let Some(error) = resolver
|
||||||
|
.error
|
||||||
|
.as_deref()
|
||||||
|
.or_else(|| file.and_then(|file| file.error.as_deref()))
|
||||||
|
{
|
||||||
|
spans.push(Span::styled(
|
||||||
|
format!(" · {}", display_text(error)),
|
||||||
|
Style::default().fg(Color::Red),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
frame.render_widget(Paragraph::new(Line::from(spans)), heading);
|
||||||
|
if let Some(file) = file {
|
||||||
|
let bases = file.sources.iter().filter(|source| source.base).count();
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(format!(
|
||||||
|
"Input {}/{} · Base {bases} · :resolve.source N / :resolve.base N · Tab panes/files",
|
||||||
|
resolver.selected_source + 1,
|
||||||
|
file.sources.len(),
|
||||||
|
))
|
||||||
|
.style(Style::default().fg(Color::DarkGray)),
|
||||||
|
selector,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolver_document_panel(frame: &mut Frame, panel: Rect, title: &str, focused: bool) -> Rect {
|
||||||
|
let [heading, document] = Layout::vertical([
|
||||||
|
Constraint::Length(u16::from(panel.height >= 2)),
|
||||||
|
Constraint::Fill(1),
|
||||||
|
])
|
||||||
|
.areas(panel);
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(display_text(title)).style(
|
||||||
|
Style::default()
|
||||||
|
.fg(if focused {
|
||||||
|
Color::Cyan
|
||||||
|
} else {
|
||||||
|
Color::DarkGray
|
||||||
|
})
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
heading,
|
||||||
|
);
|
||||||
|
document
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_resolver_choices(frame: &mut Frame, resolver: &ResolverWorkspace, area: Rect) {
|
||||||
|
let Some(file) = resolver.files.get(resolver.current_file) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let mut lines = vec![
|
||||||
|
Line::from("Whole-file conflict · text editing is unavailable")
|
||||||
|
.style(Style::default().fg(Color::Yellow)),
|
||||||
|
Line::from(
|
||||||
|
file.whole_file_choice
|
||||||
|
.and_then(|index| file.sources.get(index))
|
||||||
|
.map_or_else(
|
||||||
|
|| "No whole-file choice selected.".to_owned(),
|
||||||
|
|source| format!("Selected: {}", display_text(&source.label)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Line::from("Choose an explicit input, then :w to save and check jj."),
|
||||||
|
];
|
||||||
|
lines.extend(
|
||||||
|
file.sources
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter(|(_, source)| !source.base)
|
||||||
|
.map(|(index, source)| {
|
||||||
|
Line::from(format!(
|
||||||
|
"{} :resolve.use_source {} · {}",
|
||||||
|
if file.whole_file_choice == Some(index) {
|
||||||
|
">"
|
||||||
|
} else {
|
||||||
|
" "
|
||||||
|
},
|
||||||
|
index + 1,
|
||||||
|
display_text(&source.label),
|
||||||
|
))
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), area);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_resolver_files(
|
||||||
|
frame: &mut Frame,
|
||||||
|
resolver: &ResolverWorkspace,
|
||||||
|
panel: Rect,
|
||||||
|
navigation_hint: &str,
|
||||||
|
panels: &mut WheelPanels,
|
||||||
|
) {
|
||||||
|
if panel.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let bordered = panel.width >= 4 && panel.height >= 4;
|
||||||
|
let block = Block::default()
|
||||||
|
.borders(if bordered {
|
||||||
|
Borders::ALL
|
||||||
|
} else {
|
||||||
|
Borders::NONE
|
||||||
|
})
|
||||||
|
.border_style(Style::default().fg(if resolver.files_focused {
|
||||||
|
Color::Cyan
|
||||||
|
} else {
|
||||||
|
Color::DarkGray
|
||||||
|
}))
|
||||||
|
.title(" Conflicts ");
|
||||||
|
let content = block.inner(panel);
|
||||||
|
frame.render_widget(Clear, panel);
|
||||||
|
frame.render_widget(block, panel);
|
||||||
|
let [query, entries, help] = Layout::vertical([
|
||||||
|
Constraint::Length(u16::from(content.height >= 2)),
|
||||||
|
Constraint::Fill(1),
|
||||||
|
Constraint::Length(u16::from(content.height >= 5)),
|
||||||
|
])
|
||||||
|
.areas(content);
|
||||||
|
let prompt = if resolver.filtering {
|
||||||
|
format!("> {}", display_text(&resolver.filter))
|
||||||
|
} else if resolver.filter.is_empty() {
|
||||||
|
"/ filter".to_owned()
|
||||||
|
} else {
|
||||||
|
format!("filter: {}", display_text(&resolver.filter))
|
||||||
|
};
|
||||||
|
let prompt_width = display_column(&prompt, usize::MAX);
|
||||||
|
let prompt_offset = if resolver.filtering {
|
||||||
|
prompt_width.saturating_sub(usize::from(query.width.saturating_sub(1)))
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(visible_line(
|
||||||
|
&prompt,
|
||||||
|
prompt_offset,
|
||||||
|
usize::from(query.width),
|
||||||
|
))
|
||||||
|
.style(Style::default().fg(Color::Yellow)),
|
||||||
|
query,
|
||||||
|
);
|
||||||
|
let filter = resolver.filter.to_lowercase();
|
||||||
|
let files: Vec<_> = resolver
|
||||||
|
.files
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter(|(_, file)| file.path.to_string_lossy().to_lowercase().contains(&filter))
|
||||||
|
.collect();
|
||||||
|
let selected = files
|
||||||
|
.iter()
|
||||||
|
.position(|(index, _)| *index == resolver.selected_file)
|
||||||
|
.unwrap_or(0);
|
||||||
|
let top = PickerViewport::follow(
|
||||||
|
&mut panels.resolver_scroll,
|
||||||
|
0,
|
||||||
|
selected,
|
||||||
|
usize::from(entries.height),
|
||||||
|
1,
|
||||||
|
Some(0),
|
||||||
|
);
|
||||||
|
let lines: Vec<_> = files
|
||||||
|
.iter()
|
||||||
|
.skip(top)
|
||||||
|
.take(usize::from(entries.height))
|
||||||
|
.map(|(index, file)| {
|
||||||
|
let (status, color) = resolver_status(&file.status);
|
||||||
|
let status = match file.status {
|
||||||
|
ResolverStatus::Unsaved => "unsaved",
|
||||||
|
ResolverStatus::Checking => "checking",
|
||||||
|
ResolverStatus::Unconfirmed => "unconfirmed",
|
||||||
|
_ => status,
|
||||||
|
};
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled(format!("{status} "), Style::default().fg(color)),
|
||||||
|
Span::raw(display_path(&file.path)),
|
||||||
|
])
|
||||||
|
.style(if *index == resolver.selected_file {
|
||||||
|
Style::default()
|
||||||
|
.bg(Color::Blue)
|
||||||
|
.add_modifier(Modifier::BOLD)
|
||||||
|
} else {
|
||||||
|
Style::default()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(if files.is_empty() {
|
||||||
|
vec![Line::from("No matching files")]
|
||||||
|
} else {
|
||||||
|
lines
|
||||||
|
}),
|
||||||
|
entries,
|
||||||
|
);
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(format!("{navigation_hint} · Enter open · Tab panes"))
|
||||||
|
.style(Style::default().fg(Color::DarkGray)),
|
||||||
|
help,
|
||||||
|
);
|
||||||
|
if resolver.files_focused {
|
||||||
|
let position = if resolver.filtering && !query.is_empty() {
|
||||||
|
Some(Position::new(
|
||||||
|
query.x
|
||||||
|
+ (prompt_width - prompt_offset).min(usize::from(query.width.saturating_sub(1)))
|
||||||
|
as u16,
|
||||||
|
query.y,
|
||||||
|
))
|
||||||
|
} else if !entries.is_empty()
|
||||||
|
&& selected >= top
|
||||||
|
&& selected - top < usize::from(entries.height)
|
||||||
|
{
|
||||||
|
Some(Position::new(
|
||||||
|
entries.x,
|
||||||
|
entries.y + (selected - top) as u16,
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
if let Some(position) = position {
|
||||||
|
frame.set_cursor_position(position);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
fn draw(
|
fn draw(
|
||||||
frame: &mut Frame,
|
frame: &mut Frame,
|
||||||
@@ -4019,6 +4431,8 @@ struct WheelPanels {
|
|||||||
diff_syntax: Option<DiffSyntaxCache>,
|
diff_syntax: Option<DiffSyntaxCache>,
|
||||||
previous_diff: Option<DiffGeometry>,
|
previous_diff: Option<DiffGeometry>,
|
||||||
diff_wrap: Option<DiffWrapViewport>,
|
diff_wrap: Option<DiffWrapViewport>,
|
||||||
|
resolver_files: Option<Rect>,
|
||||||
|
resolver_scroll: Option<PickerViewport>,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct PickerScroll {
|
struct PickerScroll {
|
||||||
@@ -4129,6 +4543,7 @@ impl WheelPanels {
|
|||||||
self.completion = None;
|
self.completion = None;
|
||||||
self.files = None;
|
self.files = None;
|
||||||
self.previous_diff = self.diff.take();
|
self.previous_diff = self.diff.take();
|
||||||
|
self.resolver_files = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
fn follow_files(&mut self) {
|
fn follow_files(&mut self) {
|
||||||
@@ -4227,6 +4642,18 @@ impl WheelPanels {
|
|||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if self
|
||||||
|
.resolver_files
|
||||||
|
.is_some_and(|area| area.contains(position))
|
||||||
|
{
|
||||||
|
if !horizontal {
|
||||||
|
session.editor_mut().focus_resolver_files();
|
||||||
|
for _ in 0..3 {
|
||||||
|
session.handle(if down { Input::Down } else { Input::Up });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
let pane = session.editor().pane_id();
|
let pane = session.editor().pane_id();
|
||||||
let floating_files = editor.file_picker().is_some_and(|picker| {
|
let floating_files = editor.file_picker().is_some_and(|picker| {
|
||||||
picker.presentation == FilePresentation::Floating
|
picker.presentation == FilePresentation::Floating
|
||||||
@@ -6083,6 +6510,271 @@ mod tests {
|
|||||||
.join("\n")
|
.join("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolver_renders_native_result_sources_explorer_and_narrow_focus() {
|
||||||
|
let directory = Directory::new();
|
||||||
|
let jj = |args: &[&str]| {
|
||||||
|
let output = std::process::Command::new("jj")
|
||||||
|
.current_dir(&directory.0)
|
||||||
|
.env("JJ_CONFIG", "/dev/null")
|
||||||
|
.args([
|
||||||
|
"--config=user.name=Resolver",
|
||||||
|
"--config=user.email=resolver@example.test",
|
||||||
|
"--config=debug.commit-timestamp=\"2001-01-01T00:00:00Z\"",
|
||||||
|
"--config=debug.operation-timestamp=\"2001-01-01T00:00:00Z\"",
|
||||||
|
])
|
||||||
|
.args(args)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
output.status.success(),
|
||||||
|
"{}",
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
String::from_utf8(output.stdout).unwrap().trim().to_owned()
|
||||||
|
};
|
||||||
|
let text = |side: &str| {
|
||||||
|
format!(
|
||||||
|
"{side}\n{}",
|
||||||
|
(0..60)
|
||||||
|
.map(|row| format!("context {row:02}\n"))
|
||||||
|
.collect::<String>()
|
||||||
|
)
|
||||||
|
};
|
||||||
|
jj(&["git", "init", "--no-colocate", "."]);
|
||||||
|
jj(&["config", "set", "--repo", "user.name", "Resolver"]);
|
||||||
|
jj(&[
|
||||||
|
"config",
|
||||||
|
"set",
|
||||||
|
"--repo",
|
||||||
|
"user.email",
|
||||||
|
"resolver@example.test",
|
||||||
|
]);
|
||||||
|
for path in ["first.txt", "second.txt"] {
|
||||||
|
directory.file(path, &text("base"));
|
||||||
|
}
|
||||||
|
fs::write(directory.0.join("binary.bin"), b"base\0").unwrap();
|
||||||
|
jj(&["describe", "-m", "base"]);
|
||||||
|
let base = jj(&["log", "--no-graph", "-r", "@", "-T", "commit_id"]);
|
||||||
|
jj(&["new", &base]);
|
||||||
|
for path in ["first.txt", "second.txt"] {
|
||||||
|
directory.file(path, &text("left"));
|
||||||
|
}
|
||||||
|
fs::write(directory.0.join("binary.bin"), b"left\0").unwrap();
|
||||||
|
jj(&["describe", "-m", "left"]);
|
||||||
|
let left = jj(&["log", "--no-graph", "-r", "@", "-T", "commit_id"]);
|
||||||
|
jj(&["new", &base]);
|
||||||
|
for path in ["first.txt", "second.txt"] {
|
||||||
|
directory.file(path, &text("right"));
|
||||||
|
}
|
||||||
|
fs::write(directory.0.join("binary.bin"), b"right\0").unwrap();
|
||||||
|
jj(&["describe", "-m", "right"]);
|
||||||
|
let right = jj(&["log", "--no-graph", "-r", "@", "-T", "commit_id"]);
|
||||||
|
jj(&["new", &left, &right, "-m", "conflicts"]);
|
||||||
|
let operation = || {
|
||||||
|
jj(&[
|
||||||
|
"--ignore-working-copy",
|
||||||
|
"op",
|
||||||
|
"log",
|
||||||
|
"--no-graph",
|
||||||
|
"-n",
|
||||||
|
"1",
|
||||||
|
"-T",
|
||||||
|
"id",
|
||||||
|
])
|
||||||
|
};
|
||||||
|
let before_browsing = operation();
|
||||||
|
let config = directory.file("config.rb",
|
||||||
|
"Vamp.configure do |config|\n config.clipboard_mode = 'separate'\n config.bind 'ctrl+w k', config.core.pane.focus_up\n config.bind 'ctrl+w j', config.core.pane.focus_down\nend\n");
|
||||||
|
let mut session = EditorSession::open(
|
||||||
|
Some(directory.0.join("first.txt")),
|
||||||
|
vec!["text".into()],
|
||||||
|
Some(&config),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let ready = |session: &mut EditorSession| {
|
||||||
|
for _ in 0..15_000 {
|
||||||
|
session.poll();
|
||||||
|
if session.editor().resolver().is_some_and(|resolver| {
|
||||||
|
!resolver.loading
|
||||||
|
&& !resolver
|
||||||
|
.files
|
||||||
|
.iter()
|
||||||
|
.any(|file| file.status == ResolverStatus::Checking)
|
||||||
|
}) {
|
||||||
|
assert!(
|
||||||
|
session.editor().resolver().unwrap().error.is_none(),
|
||||||
|
"{}",
|
||||||
|
session.editor().message()
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_millis(1));
|
||||||
|
}
|
||||||
|
panic!("Resolver did not settle: {}", session.editor().message());
|
||||||
|
};
|
||||||
|
session.execute("resolve");
|
||||||
|
ready(&mut session);
|
||||||
|
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 30)).unwrap();
|
||||||
|
let mut views = BufferViewports::default();
|
||||||
|
let render = |terminal: &mut Terminal<ratatui::backend::TestBackend>,
|
||||||
|
views: &mut BufferViewports,
|
||||||
|
session: &mut EditorSession| {
|
||||||
|
terminal
|
||||||
|
.draw(|frame| views.draw_session(frame, session))
|
||||||
|
.unwrap();
|
||||||
|
terminal.get_cursor_position().unwrap()
|
||||||
|
};
|
||||||
|
let caret = render(&mut terminal, &mut views, &mut session);
|
||||||
|
let resolver = session.editor().resolver().unwrap();
|
||||||
|
assert_eq!(resolver.files.len(), 3);
|
||||||
|
let current_file = resolver.current_file;
|
||||||
|
let result_pane = resolver.result_pane;
|
||||||
|
let result = views
|
||||||
|
.rects
|
||||||
|
.iter()
|
||||||
|
.find(|(pane, _)| *pane == result_pane)
|
||||||
|
.unwrap()
|
||||||
|
.1;
|
||||||
|
assert!(result.contains(caret));
|
||||||
|
assert_eq!(views.rects.len(), 3);
|
||||||
|
assert!(
|
||||||
|
views
|
||||||
|
.rects
|
||||||
|
.iter()
|
||||||
|
.filter(|(pane, _)| *pane != result_pane)
|
||||||
|
.all(|(_, source)| source.bottom() < result.y && source.width < result.width)
|
||||||
|
);
|
||||||
|
let screen = screen_text(&terminal);
|
||||||
|
assert!(screen.contains("Result") && screen.contains("unresolved"));
|
||||||
|
assert!(screen.contains("Base 1") && screen.contains("readonly"));
|
||||||
|
assert!(views.panels.resolver_files.unwrap().x >= result.right());
|
||||||
|
|
||||||
|
session.handle(Input::Char('\t'));
|
||||||
|
assert_ne!(session.editor().pane_id(), result_pane);
|
||||||
|
session.handle(Input::BackTab);
|
||||||
|
assert_eq!(session.editor().pane_id(), result_pane);
|
||||||
|
session.handle(Input::Ctrl('w'));
|
||||||
|
session.handle(Input::Char('k'));
|
||||||
|
assert_ne!(session.editor().pane_id(), result_pane);
|
||||||
|
session.handle(Input::Ctrl('w'));
|
||||||
|
session.handle(Input::Char('j'));
|
||||||
|
assert_eq!(session.editor().pane_id(), result_pane);
|
||||||
|
session.execute("resolve.base");
|
||||||
|
let resolver = session.editor().resolver().unwrap();
|
||||||
|
assert!(resolver.files[current_file].sources[resolver.selected_source].base);
|
||||||
|
let selected_pane = resolver.source_panes[resolver.selected_source];
|
||||||
|
terminal.backend_mut().resize(44, 20);
|
||||||
|
let caret = render(&mut terminal, &mut views, &mut session);
|
||||||
|
assert_eq!(views.rects.len(), 2);
|
||||||
|
let source = views
|
||||||
|
.rects
|
||||||
|
.iter()
|
||||||
|
.find(|(pane, _)| *pane == selected_pane)
|
||||||
|
.unwrap()
|
||||||
|
.1;
|
||||||
|
assert!(source.contains(caret));
|
||||||
|
assert!(screen_text(&terminal).contains("base"));
|
||||||
|
let original = session.editor().lines().to_vec();
|
||||||
|
session.handle(Input::Char('i'));
|
||||||
|
session.handle(Input::Char('X'));
|
||||||
|
session.handle(Input::Escape);
|
||||||
|
assert_eq!(session.editor().lines(), original);
|
||||||
|
session.execute("resolve.result");
|
||||||
|
let caret = render(&mut terminal, &mut views, &mut session);
|
||||||
|
let cursor = session.editor().cursor();
|
||||||
|
assert!(views.scroll(
|
||||||
|
&mut session,
|
||||||
|
document_wheel(MouseEventKind::ScrollDown, source.x, source.y),
|
||||||
|
));
|
||||||
|
assert_eq!(session.editor().pane_id(), result_pane);
|
||||||
|
assert_eq!(session.editor().cursor(), cursor);
|
||||||
|
assert!(
|
||||||
|
views
|
||||||
|
.rects
|
||||||
|
.iter()
|
||||||
|
.find(|(pane, _)| *pane == result_pane)
|
||||||
|
.unwrap()
|
||||||
|
.1
|
||||||
|
.contains(caret)
|
||||||
|
);
|
||||||
|
session.execute("resolve.files");
|
||||||
|
session.handle(Input::Down);
|
||||||
|
render(&mut terminal, &mut views, &mut session);
|
||||||
|
assert_eq!(
|
||||||
|
session.editor().resolver().unwrap().current_file,
|
||||||
|
current_file
|
||||||
|
);
|
||||||
|
assert!(screen_text(&terminal).contains("Conflicts"));
|
||||||
|
session.handle(Input::Char('/'));
|
||||||
|
for ch in "second".chars() {
|
||||||
|
session.handle(Input::Char(ch));
|
||||||
|
}
|
||||||
|
render(&mut terminal, &mut views, &mut session);
|
||||||
|
assert!(screen_text(&terminal).contains("second.txt"));
|
||||||
|
assert!(!screen_text(&terminal).contains("binary.bin"));
|
||||||
|
session.handle(Input::Escape);
|
||||||
|
session.execute("resolve.result");
|
||||||
|
assert_eq!(
|
||||||
|
operation(),
|
||||||
|
before_browsing,
|
||||||
|
"Rendering/navigation must not snapshot jj"
|
||||||
|
);
|
||||||
|
|
||||||
|
session.handle(Input::Char('i'));
|
||||||
|
session.handle(Input::Char('X'));
|
||||||
|
session.handle(Input::Escape);
|
||||||
|
render(&mut terminal, &mut views, &mut session);
|
||||||
|
assert!(screen_text(&terminal).contains("unsaved"));
|
||||||
|
session.handle(Input::Char('u'));
|
||||||
|
let resolver = session.editor().resolver().unwrap();
|
||||||
|
let side = resolver.files[current_file]
|
||||||
|
.sources
|
||||||
|
.iter()
|
||||||
|
.position(|source| !source.base)
|
||||||
|
.unwrap()
|
||||||
|
+ 1;
|
||||||
|
session.execute(&format!("resolve.use_source {side}"));
|
||||||
|
session.execute("w");
|
||||||
|
render(&mut terminal, &mut views, &mut session);
|
||||||
|
assert!(screen_text(&terminal).contains("checking"));
|
||||||
|
ready(&mut session);
|
||||||
|
render(&mut terminal, &mut views, &mut session);
|
||||||
|
let resolver = session.editor().resolver().unwrap();
|
||||||
|
assert_eq!(resolver.files.len(), 3);
|
||||||
|
assert_eq!(
|
||||||
|
resolver.files[current_file].status,
|
||||||
|
ResolverStatus::Resolved
|
||||||
|
);
|
||||||
|
assert!(screen_text(&terminal).contains("resolved"));
|
||||||
|
let binary = resolver
|
||||||
|
.files
|
||||||
|
.iter()
|
||||||
|
.position(|file| file.path == std::path::Path::new("binary.bin"))
|
||||||
|
.unwrap();
|
||||||
|
session.editor_mut().select_resolver_file(binary);
|
||||||
|
terminal.backend_mut().resize(120, 30);
|
||||||
|
render(&mut terminal, &mut views, &mut session);
|
||||||
|
assert!(screen_text(&terminal).contains("Whole-file conflict"));
|
||||||
|
assert!(!views.rects.iter().any(|(pane, _)| *pane == result_pane));
|
||||||
|
let resolver = session.editor().resolver().unwrap();
|
||||||
|
let side = resolver.files[binary]
|
||||||
|
.sources
|
||||||
|
.iter()
|
||||||
|
.position(|source| !source.base)
|
||||||
|
.unwrap()
|
||||||
|
+ 1;
|
||||||
|
session.execute(&format!("resolve.use_source {side}"));
|
||||||
|
render(&mut terminal, &mut views, &mut session);
|
||||||
|
assert!(screen_text(&terminal).contains("Selected:"));
|
||||||
|
for (width, height) in [(4, 4), (1, 1), (0, 0)] {
|
||||||
|
terminal.backend_mut().resize(width, height);
|
||||||
|
terminal
|
||||||
|
.draw(|frame| views.draw_session(frame, &mut session))
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn diff_catalog() -> crate::diff::Catalog {
|
fn diff_catalog() -> crate::diff::Catalog {
|
||||||
use crate::diff::{Catalog, Category, RepoKind, Revision, RevisionTarget};
|
use crate::diff::{Catalog, Category, RepoKind, Revision, RevisionTarget};
|
||||||
let mut entries = vec![Revision {
|
let mut entries = vec![Revision {
|
||||||
|
|||||||
Executable
+262
@@ -0,0 +1,262 @@
|
|||||||
|
#![cfg(unix)]
|
||||||
|
|
||||||
|
use std::{
|
||||||
|
fs,
|
||||||
|
path::PathBuf,
|
||||||
|
process::Command,
|
||||||
|
sync::atomic::{AtomicU64, Ordering},
|
||||||
|
thread,
|
||||||
|
time::{Duration, Instant},
|
||||||
|
};
|
||||||
|
use vamp::{
|
||||||
|
editor::{Input, ResolverStatus},
|
||||||
|
session::EditorSession,
|
||||||
|
};
|
||||||
|
|
||||||
|
static NEXT: AtomicU64 = AtomicU64::new(0);
|
||||||
|
struct Fixture {
|
||||||
|
root: PathBuf,
|
||||||
|
base: String,
|
||||||
|
}
|
||||||
|
impl Fixture {
|
||||||
|
fn jj(&self, args: &[&str]) -> String {
|
||||||
|
let result = Command::new("jj")
|
||||||
|
.args(["--no-pager", "--color=never"])
|
||||||
|
.args(args)
|
||||||
|
.current_dir(&self.root)
|
||||||
|
.env("JJ_CONFIG", "/dev/null")
|
||||||
|
.env("JJ_USER", "Resolver Test")
|
||||||
|
.env("JJ_EMAIL", "resolver@example.invalid")
|
||||||
|
.env("JJ_TIMESTAMP", "2000-01-01T00:00:00Z")
|
||||||
|
.env("GIT_CONFIG_NOSYSTEM", "1")
|
||||||
|
.env("GIT_CONFIG_GLOBAL", "/dev/null")
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
result.status.success(),
|
||||||
|
"{}",
|
||||||
|
String::from_utf8_lossy(&result.stderr)
|
||||||
|
);
|
||||||
|
String::from_utf8(result.stdout).unwrap().trim().into()
|
||||||
|
}
|
||||||
|
fn new() -> Self {
|
||||||
|
let root = std::env::temp_dir().join(format!(
|
||||||
|
"vamp-resolve-session-{}-{}",
|
||||||
|
std::process::id(),
|
||||||
|
NEXT.fetch_add(1, Ordering::Relaxed)
|
||||||
|
));
|
||||||
|
fs::create_dir(&root).unwrap();
|
||||||
|
let mut fixture = Self {
|
||||||
|
root,
|
||||||
|
base: String::new(),
|
||||||
|
};
|
||||||
|
fixture.jj(&["git", "init", "--no-colocate", "."]);
|
||||||
|
for name in ["first.txt", "second.txt"] {
|
||||||
|
fs::write(fixture.root.join(name), Self::text("base")).unwrap();
|
||||||
|
}
|
||||||
|
fixture.jj(&["describe", "-m", "base"]);
|
||||||
|
fixture.base = fixture.jj(&["log", "--no-graph", "-r", "@", "-T", "commit_id"]);
|
||||||
|
fixture.jj(&["new", &fixture.base]);
|
||||||
|
for name in ["first.txt", "second.txt"] {
|
||||||
|
fs::write(fixture.root.join(name), Self::text("left")).unwrap();
|
||||||
|
}
|
||||||
|
fixture.jj(&["describe", "-m", "left"]);
|
||||||
|
let left = fixture.jj(&["log", "--no-graph", "-r", "@", "-T", "commit_id"]);
|
||||||
|
fixture.jj(&["new", &fixture.base]);
|
||||||
|
for name in ["first.txt", "second.txt"] {
|
||||||
|
fs::write(fixture.root.join(name), Self::text("right")).unwrap();
|
||||||
|
}
|
||||||
|
fixture.jj(&["describe", "-m", "right"]);
|
||||||
|
let right = fixture.jj(&["log", "--no-graph", "-r", "@", "-T", "commit_id"]);
|
||||||
|
fixture.jj(&["new", &left, &right, "-m", "conflicted result"]);
|
||||||
|
fixture
|
||||||
|
}
|
||||||
|
fn text(side: &str) -> String {
|
||||||
|
format!(
|
||||||
|
"{side} first\n{}{side} last\n",
|
||||||
|
(0..30)
|
||||||
|
.map(|n| format!("context_{n}\n"))
|
||||||
|
.collect::<String>()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
fn open(&self) -> EditorSession {
|
||||||
|
let config = self.root.join("test-config.rb");
|
||||||
|
fs::write(
|
||||||
|
&config,
|
||||||
|
"Vamp.configure do |config|\n config.clipboard_mode = 'separate'\nend\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
EditorSession::open(
|
||||||
|
Some(self.root.join("first.txt")),
|
||||||
|
vec!["text".into()],
|
||||||
|
Some(&config),
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl Drop for Fixture {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = fs::remove_dir_all(&self.root);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn ready(session: &mut EditorSession) {
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(15);
|
||||||
|
loop {
|
||||||
|
session.poll();
|
||||||
|
if session.editor().resolver().is_some_and(|resolver| {
|
||||||
|
!resolver.loading
|
||||||
|
&& resolver
|
||||||
|
.files
|
||||||
|
.iter()
|
||||||
|
.all(|file| file.status != ResolverStatus::Checking)
|
||||||
|
}) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
Instant::now() < deadline,
|
||||||
|
"resolver did not settle: {}",
|
||||||
|
session.editor().message()
|
||||||
|
);
|
||||||
|
thread::sleep(Duration::from_millis(1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolver_keeps_unsaved_result_and_undo_then_saves_and_retains_resolved_file() {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
let original_disk = fs::read_to_string(fixture.root.join("first.txt")).unwrap();
|
||||||
|
let mut session = fixture.open();
|
||||||
|
for input in [
|
||||||
|
Input::Char('i'),
|
||||||
|
Input::Paste("draft\n".into()),
|
||||||
|
Input::Escape,
|
||||||
|
] {
|
||||||
|
session.handle(input);
|
||||||
|
}
|
||||||
|
let draft = session.editor().context().text;
|
||||||
|
let operation = fixture.jj(&[
|
||||||
|
"--ignore-working-copy",
|
||||||
|
"op",
|
||||||
|
"log",
|
||||||
|
"--no-graph",
|
||||||
|
"--limit",
|
||||||
|
"1",
|
||||||
|
"-T",
|
||||||
|
"id",
|
||||||
|
]);
|
||||||
|
session.execute("resolve");
|
||||||
|
ready(&mut session);
|
||||||
|
assert_eq!(session.editor().context().text, draft);
|
||||||
|
assert_eq!(
|
||||||
|
fs::read_to_string(fixture.root.join("first.txt")).unwrap(),
|
||||||
|
original_disk
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
fixture.jj(&[
|
||||||
|
"--ignore-working-copy",
|
||||||
|
"op",
|
||||||
|
"log",
|
||||||
|
"--no-graph",
|
||||||
|
"--limit",
|
||||||
|
"1",
|
||||||
|
"-T",
|
||||||
|
"id"
|
||||||
|
]),
|
||||||
|
operation
|
||||||
|
);
|
||||||
|
let source = session.editor().resolver().unwrap().files[0]
|
||||||
|
.sources
|
||||||
|
.iter()
|
||||||
|
.position(|source| !source.base)
|
||||||
|
.unwrap()
|
||||||
|
+ 1;
|
||||||
|
session.execute(&format!("resolve.use_source {source}"));
|
||||||
|
assert_ne!(session.editor().context().text, draft);
|
||||||
|
assert!(session.editor().context().text.starts_with("draft\n"));
|
||||||
|
session.handle(Input::Char('u'));
|
||||||
|
assert_eq!(session.editor().context().text, draft);
|
||||||
|
session.execute(&format!("resolve.use_source {source}"));
|
||||||
|
session.execute("resolve.next");
|
||||||
|
session.execute(&format!("resolve.use_source {source}"));
|
||||||
|
let resolved_text = session.editor().context().text;
|
||||||
|
assert!(resolved_text.starts_with("draft\n"));
|
||||||
|
assert!(resolved_text.contains("context_15\n"));
|
||||||
|
session.execute("w");
|
||||||
|
ready(&mut session);
|
||||||
|
let resolver = session.editor().resolver().unwrap();
|
||||||
|
assert_eq!(resolver.files.len(), 2);
|
||||||
|
assert_eq!(
|
||||||
|
resolver.files[resolver.selected_file].status,
|
||||||
|
ResolverStatus::Resolved
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
fs::read_to_string(fixture.root.join("first.txt")).unwrap(),
|
||||||
|
resolved_text
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!fixture
|
||||||
|
.jj(&["--ignore-working-copy", "resolve", "--list"])
|
||||||
|
.contains("first.txt")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
fixture
|
||||||
|
.jj(&["--ignore-working-copy", "resolve", "--list"])
|
||||||
|
.contains("second.txt")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolver_refuses_save_after_checkout_change_even_when_forced_and_preserves_draft() {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
let mut session = fixture.open();
|
||||||
|
session.execute("resolve");
|
||||||
|
ready(&mut session);
|
||||||
|
for input in [
|
||||||
|
Input::Char('i'),
|
||||||
|
Input::Paste("keep my draft\n".into()),
|
||||||
|
Input::Escape,
|
||||||
|
] {
|
||||||
|
session.handle(input);
|
||||||
|
}
|
||||||
|
let draft = session.editor().context().text;
|
||||||
|
fixture.jj(&["new", &fixture.base]);
|
||||||
|
let checkout_disk = fs::read_to_string(fixture.root.join("first.txt")).unwrap();
|
||||||
|
for save in ["w", "w!"] {
|
||||||
|
session.execute(save);
|
||||||
|
ready(&mut session);
|
||||||
|
assert_eq!(
|
||||||
|
fs::read_to_string(fixture.root.join("first.txt")).unwrap(),
|
||||||
|
checkout_disk
|
||||||
|
);
|
||||||
|
assert_eq!(session.editor().context().text, draft);
|
||||||
|
assert!(session.editor().dirty());
|
||||||
|
}
|
||||||
|
session.execute("resolve.close");
|
||||||
|
assert_eq!(session.editor().context().text, draft);
|
||||||
|
assert!(session.editor().dirty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolver_save_preserves_external_writes_and_keeps_the_unsaved_result() {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
let mut session = fixture.open();
|
||||||
|
session.execute("resolve");
|
||||||
|
ready(&mut session);
|
||||||
|
for input in [
|
||||||
|
Input::Char('i'),
|
||||||
|
Input::Paste("local draft\n".into()),
|
||||||
|
Input::Escape,
|
||||||
|
] {
|
||||||
|
session.handle(input);
|
||||||
|
}
|
||||||
|
let draft = session.editor().context().text;
|
||||||
|
fs::write(fixture.root.join("first.txt"), "external edit\n").unwrap();
|
||||||
|
session.execute("w");
|
||||||
|
ready(&mut session);
|
||||||
|
assert_eq!(
|
||||||
|
fs::read_to_string(fixture.root.join("first.txt")).unwrap(),
|
||||||
|
"external edit\n"
|
||||||
|
);
|
||||||
|
assert_eq!(session.editor().context().text, draft);
|
||||||
|
assert!(session.editor().dirty());
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user