Blocking keyboard input and hiding the working tree state mode are two
separate concerns; they were fused into one helper because every caller
so far wanted both. A caller that blocks input for something other than a
rebase would then hide the "Rebasing" indicator for the duration of its
operation, which has nothing to do with it.
Make it an explicit option instead, so blocking input on its own doesn't
imply anything about the modes on display.
The mode it suppresses is active for any working tree state, not just a
rebase: merging, cherry-picking and reverting show through the same
indicator. Name it after what it hides.
Whether a graph can be drawn was read from the filtering mode, while the
graph itself is drawn over the commit list in the model. Those two only
agree once the list has been reloaded for the new mode, and a filtering
mode change reloads the list in the background, so in between we can be
asked to draw a graph over a list the graph makes no sense for.
That is not just cosmetic. Commits in a filtered list are almost never
each other's parents, so no pipe ever terminates: the pipe set grows by
one per row and every continuing pipe rescans it, which is cubic in the
length of the list. Escaping out of filtering mode with a filtered list
of 13000 commits — as you get once the 300 commit limit has been lifted,
which happens for good as soon as the selection passes COMMIT_THRESHOLD
— wedges the UI thread for around twenty minutes.
Record whether the list was loaded with a filter, right where the list
itself is stored, and decide from that. The graph now also stays up while
the pre-change list is still on display, rather than vanishing a moment
before the list it belongs to.
Having the cache in state.yml causes this file to be rewritten every
60s, which is annoying if you have a lazygit running in the background
somewhere without even realizing it, and it keeps overwriting the
foreground lazygit's newer command shell history and recent repos list
with its stale data. State.yml should only contain things that change in
response to user actions, not periodically.
In the branches list we show the checks icon (✓, ✗ etc) instead of the
gihub icon for branches that are open and have a state. It is a little
confusing, because the ✓ in front of the name means something very
different than the ✓ after it, but the checks status is just too useful
to see in the list.
In the main view we show it as a compact status before the PR title,
with a hyperlink that takes you directly to the checks tab in Github.
The branch controller should decide which pull request to show, not how its
header is styled and linked. Move the existing formatter and state badge next
to the branch presentation helpers so subsequent header changes stay in one
layer.
GitHub exposes a combined status for the head commit without requiring
individual check contexts. Include that rollup in the existing request
and startup cache so every consumer sees the same state without making a
second network request.
For a long time lazygit has used the term "custom pager" to refer to
what's really a "diff renderer". A pager is a program that allows you to
view output page by page (hence the name), e.g. less; lazygit's custom
diff renderers are not pagers. It used the term only because the feature
is implemented using git's GIT_PAGER env var, but that's an
implementation detail.
Rename the 'git.pagers' config to 'git.diffRenderers', and restructure
its elements while we're at it to make things clearer:
- Add a 'type' field to explicitly specify which type of diff renderer
it is (the two fundamentally different ones are 'stdinFilter' and
'extDiff').
- Add a third type, 'rawGit', which has an 'args' field that makes it
easy to use 'git --color-words' as a custom renderer
- Unify the old 'pager' and 'externalDiffCommand' fields to a single
'command' field for both types
Existing config files are migrated automatically.
We don't want callers to need any additional logic, so pass in the
translation set so that the function can decide what static text to
return. This allows us to get rid of the CurrentPagerUsesGitConfigDiff
method which is in the way for the refactoring we're about to do.
Moving commits runs a rebase, which can take a while. Instead of
letting the drop indicator vanish the moment the button is released,
keep it in place and turn it into a "moving commits here" spinner once
the move takes longer than a short grace period, so that quick moves
stay free of flicker. The indicator is cleared when the post-move
refresh lands.
While a commit drag is in progress, escape now aborts it: the drag
state and the drop indicator are discarded and the mouse capture is
released, so nothing happens when the button is eventually released.
Otherwise escape keeps its normal meaning.
Reuse the drag autoscroller for commit drags. Scrolling stops once the
insertion point reaches the end of the allowed range in the scroll
direction, so during a rebase the view doesn't keep scrolling once the
last insertion position among the todos has been reached.
Pressing the left button on the current selection now starts a drag
that moves the selected commits, both in the normal commits view and
for todos during an interactive rebase. A press anywhere else falls
through to the usual click handling, so dragging from an unselected
line still creates a range selection, and releasing without having
moved collapses the selection to the pressed commit like a plain click
would.
While dragging, the insertion point follows the pointer: rows below
the dragged block insert after the pointed-at commit, rows above it
insert before it, and during a rebase the destination is limited to
the contiguous block of movable todos around the selection. gocui
moves the view cursor along with the pointer, so each drag event moves
it back to keep the original selection highlighted.
The move happens on release. The model may have been refreshed during
the drag, so the dragged commits are located again by their identity
(hash, subject, todo action); if they no longer form a unique
contiguous block, the drop is ignored rather than guessing.
Let the todo-move primitives take a distance instead of hardcoding a
single row, by iterating the one-row move in memory. Dropping a commit
several rows away thus rewrites the todo file once and, outside of an
interactive rebase, runs a single rebase rather than one per row.
Merge the up/down variants of the move commands into one
direction-parameterized implementation. Dragging commits is about to
need moves over arbitrary distances, which we don't want to build twice.
Give the list views the same edge autoscroll during drag selection
that the staging view already has; the new mouse-release binding stops
the autoscroll when the drag ends.
Dragging with the left button held now extends the selection from the
pressed line, exactly like moving with shift+up/down does. We use the
non-sticky flavor so that the range collapses on the next plain cursor
movement, again matching the keyboard behavior.
The binding is only registered for contexts that support range selection
in the first place; dragging in other lists continues to do nothing.
When the pointer reaches the edge of the view during a drag (or leaves
the view entirely, which mouse capture makes possible), keep scrolling
and extending the selection: slowly on the innermost edge row, faster
on the outermost row, and very fast beyond. Scrolling starts after a
short delay so that a drag merely passing near the edge doesn't scroll.
When the view loses focus mid-drag (e.g. because a popup appeared),
cancel the autoscroll and the mouse capture.
The root item's path is ".", and the path of a file at top level is
"./file". When using GetPath, this gives us "." and "file",
respectively, and isDescendentOfSelectedCommitFileNodes would return
false for these.
Working with the internal paths (i.e. without stripping the leading
"./") fixes this.
There is no known breakage that is caused by this, that's why I'm not
adding an integration test that demonstrates a bug.
Equivalent to the change that was made to isDescendentOfSelectedNodes in
files_controller.go in 302b621b68.
It never changes inside this function, so there's no need to recompute
it with every loop iteration.
Equivalent to the change that was made to isDescendentOfSelectedNodes in
files_controller.go in d0c6e27fee.
This fixes the problem described in the previous commit; we no longer
capture the selection at the start of the refresh. There's no reason to
do that (we don't do it for branches either). It is enough to capture
the selection in the final bounce, before we assign the new model slice.
Popping or dropping a stash shifts the indices of the entries below it,
and renaming re-creates the stash at the top, shifting all the others.
The stash model is only rebuilt by the refresh, which finishes in the
background, so acting on the next entry in quick succession — pressing
the key, confirming the popup, and pressing again right away — reads the
stale pre-operation indices and targets the wrong stash. Note that the
confirmation popup is no protection here: the race starts when the
confirm handler runs, and the next keypress can easily beat the refresh.
Use RefreshBlockingInput so a quick follow-up keypress is buffered and
replayed once the refreshed stash list is in place.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Moving a todo rewrites the todo file and advances the selection
synchronously, but the commits model is only rebuilt by the refresh. A
second press arriving before that grabs the swapped-with todo from the
stale model at the advanced index and moves it back, so holding the key
to move a todo several slots misbehaved. Use RefreshBlockingInput so the
second press is buffered and replayed once the moved todo list is in
place.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A refresh from the UI thread returns immediately and applies its model
and view updates as queued UI-thread callbacks. A key pressed before
those have run is handled against the stale, pre-refresh state. For most
keys that's harmless, but some handlers turn that state into git
commands: pressing space twice in quick succession in the staging panel
builds the second patch from the already-applied diff and fails with
'patch does not apply', because the refresh after the first press is
what moves the selection to the next stageable hunk.
Notably, this is not just a regression of the recent change that made
UI-thread refreshes non-blocking; the window was merely much narrower
before. A blocking refresh parked the UI thread while the scopes'
bounces were queued, and the event loop drains pending keyboard input
with priority over queued user events, so a key pressed during the
blocked window still beat the queued state updates. The guarantee that
the next keypress sees post-refresh state had already ended when the
scopes' state updates moved from worker-side mutex-guarded writes to
UI-thread bounces.
Fix it with the input-blocking mechanism we already use for commit
surgery, exposed as a new RefreshBlockingInput entry point: it begins
blocking events synchronously in the calling handler, and ends the
block from a callback that the finishing step queues behind the
refresh's own updates. Keys pressed while the refresh is in flight are
buffered and replayed, in order, against the fully refreshed state;
since a replayed key's handler re-enters this same path, a burst of
keypresses applies sequentially, each one seeing the previous one's
refresh. Unlike the old blocking refreshes, this doesn't freeze the UI
thread: rendering, spinners, resizing, and mouse scrolling keep working
while input is withheld.
Blocking input is opt-in per call site rather than the default for all
UI-thread refreshes, because most refreshes (the focus-in and startup
refreshes, say) don't produce state that the next keypress depends on,
and blocking on them would delay typing for no reason. It should also be
limited to quick, narrow-scoped refreshes: a full refresh, or any scope
that pulls in COMMITS, can take very long in large repos and should
usually not hold up input.
The staging panel's stage/discard/edit-hunk refreshes use it now.
A few comments still reasoned in terms of SYNC vs ASYNC refreshes, a
distinction that no longer exists: sync vs async is now derived from the
calling thread. Restate them in terms of the current mechanisms
(RefreshFromWorker blocking its worker, model updates being enqueued on
the UI thread) without changing any behavior.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Checking out a remote branch that has no local counterpart creates the
local branch, refreshes, and then checks it out. The refresh exists so
that CheckoutRef finds the new branch in the model and attaches an inline
status to the branch item instead of showing a global waiting status. But
since UI-thread refreshes stopped blocking, the checkout started before
the refreshed branches had landed in the model, so the lookup failed and
we always got the waiting status. Run the checkout from the refresh's
Then, which is queued behind the model update.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
PostFetchRefresh's refresh is the only background refresh carrying a
Then callback, and Then callbacks are not generation-guarded: when the
background fetch's refresh crossed a repo switch, the callback still
ran — in the new repo — and auto-forwarded the new repo's branches
because the old repo's fetch had completed. That was harmless in
practice (the update-ref call compares against the expected old value,
and it only does what the next fetch's auto-forward would do anyway),
but mutating refs in a repo whose fetch never happened is not an action
the user took. Skip the auto-forward when the repo generation changed
since the fetch started.
The generation is captured by the fetch's callers before the fetch
runs, not by PostFetchRefresh itself: the background fetch doesn't
block repo switching and is a network call, so by the time
PostFetchRefresh runs a switch may already have happened — a capture
there (or the one the refresh itself takes) would compare against the
new repo's generation and let the auto-forward through. For the manual
fetch the capture point makes no difference, since a foreground
operation blocks repo switching for its entire duration.
This deliberately guards only this call site rather than making Then
callbacks generation-guarded in general: a Then is an arbitrary
callback, and whether it is safe to skip on a repo switch is a decision
for the author of the call site.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The refresh workers read a few files at paths relative to the process
working directory: the submodule config read of .gitmodules, the files
refresh's check for conflict markers, and the submodule stash's
existence check. Git commands are pinned to the repo their instance was
created for, but these Go file reads still followed the cwd, so a
background refresh crossing a repo switch would read the new repo's
files while computing data for the old one. Join them with the worktree
root of the instance they belong to. (Most git-state file reads —
working tree state, rebase todos, bisect info — already resolve
against RepoPaths and need no change.)
This also fixes the submodule stash's existence check for nested
submodules: it stat'ed submodule.Path, which is relative to the parent
module, against the repo root — now it uses the submodule's full path,
matching the stash command right below it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The refreshes on focus-in, right after a repo switch, and after
returning from a subprocess are full foreground refreshes, so their
tasks kept Busy() true for as long as the slowest scope took — and any
switch attempt in that window was refused with the "can't switch"
toast. The focus-in one is particularly annoying: focusing lazygit is
often precisely what the user does in order to switch repos, and right
after regaining focus is when a refresh takes longest.
Blocking the switch bought nothing there. The refusal exists for user
operations, whose follow-up work (e.g. a Then callback reading the
model) isn't covered by the switch-safety guards; but these refreshes
merely reload state, and a refresh by itself is now switch-safe: its
git commands run against the repo it was started for, and the
generation guard drops its updates when the repo changed.
We can't just mark them Background, because that flag also decides
whether the files refresh lets git take optional locks to persist its
refreshed stat cache — worth doing for an attended refresh, and the
focus-in refresh (typically running right after external changes) is
the case that profits most. So split the two meanings: a new
DontBlockRepoSwitch option dispatches the refresh's tasks as background
tasks (excluded from Busy()) while keeping the attended optional-locks
behavior. Combining it with Then panics, since Then is not
generation-guarded.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
An error returned from a gocui worker is shown to the user in an error
popup. For the branch loader's behind-counts worker that used to be the
"no such ref" popup when a background refresh crossed a repo switch:
the old repo's main branch didn't exist in the new repo. The previous
commits fix that scenario properly — the command now runs against the
repo the refresh was started for — but a stale worker can still fail
legitimately, most plausibly because that repo was deleted after
switching away from it (e.g. removing a worktree). Its results are
dropped anyway, so log the error instead of alarming the user about a
repo they already left.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A background refresh's model writes are dropped by the generation guard
when the repo is switched mid-flight, but its git commands kept running
— and because the refresh read the live git instance at each step, any
command issued after the switch ran against the new repo. Now that git
commands are pinned to the directory of the instance they were built
from, capture the instance once when the refresh starts and run every
scope's git work through it, so a switch-crossing refresh keeps
addressing the repo it was started for.
The instance is captured together with the repo generation, on the UI
thread (where repo switches run), so the pair can't straddle a switch:
an old instance paired with the new generation would compute data from
the old repo and write it into the new repo's model unguarded.
This also removes the refresh workers' unsynchronized reads of the live
instance pointer, which raced its reassignment on the UI thread when a
background refresh crossed a repo switch (foreground refreshes can't
cross one: they keep Busy() true, which refuses the switch).
Two reads keyed app-state by the live instance's repo path on a worker
and now use the captured instance, fixing which repo they file under
when crossing a switch: the pull-request cache, and the "user dismissed
the base-remote prompt" flag. The base-remote menu's handlers keep
reading the live instance: a switch dismisses any open popup, so they
can't run against the wrong repo (and the OnPress body runs under a
foreground task, which blocks switching anyway).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
When the user picks a base remote in the "select remote repository"
prompt, we called setGithubPullRequests directly, bypassing the refresh
machinery — which meant hand-rolling the refresh env that call needs
(with a comment explaining why), and fetching against the branches
captured when the prompt was created. Issue a PULL_REQUESTS-scoped
refresh instead: it re-reads branches and remotes (both fast even in
large repos), fetches against those fresh values, and gets the refresh
machinery's guarantees without any special-casing. The config write is
re-read by the refresh from git config, so it is guaranteed to be
picked up.
The waiting status now covers the config write and the branches/remotes
reload, while the GitHub request itself continues as a background task
— which is how every other pull-request fetch behaves.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Each status used to start a spinner render loop of its own, running on
a worker that inherited the foreground/background flavor of the
status's owner, and exiting only once the entire status stack was
empty. That shape had a real bug: a foreground operation's loop could
be kept alive by someone else's status. Finish a quick operation with
a waiting status while a background fetch's "Fetching..." status is
still showing, and the operation's render loop — a foreground worker
task — keeps ticking until the fetch ends. Busy() stays true for that
whole time, so repo switching is refused even though nothing is in
flight anymore; with a fetch hanging on a slow network, that means
minutes. The shape was also wasteful: overlapping statuses were each
drawn by their own loop (plus a duplicate whenever a task was paused
and resumed while another status was showing), all redundantly
redrawing the same top status.
Replace the per-status loops with a single loop owned by the status
stack as a whole: whoever shows the first status starts it, and it
exits after drawing a final empty frame once the last status is
removed. The claim/release methods on StatusManager keep the loop
flag's transitions atomic with the stack under the one mutex, so a
status added while the loop is about to exit starts a fresh loop
instead of going unrendered.
The loop always runs as a background task now: rendering issues no
git commands, so it never needs to block repo switching, and a
foreground operation's busy-ness is already carried by its own worker
task. This is what fixes the bug above, and it retires the need to
thread a foreground/background flag through the waiting-status
helpers altogether.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Every full refresh includes the PULL_REQUESTS scope, and the worker it
spawns inherited the refresh's foreground/background flag. Full
foreground refreshes happen at startup, after switching repos or
worktrees, and when the terminal regains focus, so the GitHub API
request ran as a foreground task there, keeping Busy() true until it
completed. On a healthy network that's a few hundred milliseconds and
nobody notices; on a very slow one the request can stall for minutes,
and every attempt to switch repos in that window was refused with
"Can't switch repositories while an operation is in progress" even
though lazygit looked completely idle. (The request has no visible
status; at most, a background fetch hanging on the same bad network was
showing its "Fetching..." spinner, pointing the blame at the wrong
operation.)
The switch-safety guard only needs to wait for operations whose
remaining git commands would run against the wrong repo after a switch.
The pull-request fetch runs no git commands at all, and its model
writes are dropped when the repo generation has changed in the
meantime, so there is no reason for it to block switching.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
hasExecTodos reads Model().Commits. genericMergeCommandImpl evaluates it
when deciding whether to use a subprocess, and on the recursive auto-skip
path that runs on a worker -- so the read raced the UI thread. Bounce it
onto the UI thread there, keyed off the calledFromWorker flag the function
already carries (on the UI-thread entry path the read stays inline).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The file-path suggestions trie is rebuilt asynchronously and then read by
the suggestions search, which runs on an AsyncHandler worker. It lived in
Model().FilesTrie, so that worker read the (UI-thread-only) model. Move
it to an atomic pointer on the SuggestionsHelper instead: it's the only
place that uses it, the helper is recreated per repo (so the cache still
resets on a repo switch), and an atomic pointer is safe to store from the
build and load from the search worker.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The "merge conflicts" refresh scope ran on a worker like the others, but
unlike them it does UI work rather than git work: RefreshMergeState reads
the current context and renders (or escapes) the merge-conflicts view.
Reading the context manager and rendering from a worker races the UI
thread. Bounce it onto the UI thread with onUIThreadUnlessRepoChanged,
exactly as the staging and patch-building scopes already do.
Running on the UI thread also lets EscapeMerge push the files context
directly instead of deferring the push to a separate UI task; it only
needs to drop the merge-conflicts mutex first, because the push
renders the newly focused file, which can take the mutex again. The
deferred push could lose a race against the same refresh's prompt to
continue the rebase/merge: if the prompt opened between
RefreshMergeState and the deferred push, the push declined to cover
the popup and was dropped, so closing the prompt landed the user in
the emptied merge conflicts view.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Dropping a range of stashes ran a refresh after each drop. A refresh
issued from the UI thread does its git work on a worker and applies the
model update in the background, so firing one per iteration let the
workers race: an earlier drop's refresh (which read a stash list that
still contained a later-dropped entry) could apply its result last,
leaving the stash view showing an entry that git had already removed.
Refresh once, after all the drops, so a single worker reads the final
stash list. The indices are captured up front and dropped highest-first,
so the remaining lower indices stay valid without an intervening
refresh. It's also cheaper: one `git stash list` instead of one per
entry.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The patch-building scope ran RefreshPatchBuildingPanel directly on the
refresh worker, where it read the commit-files selection and set the patch
view's origin off the UI thread — the latter raced the UI thread's draw.
Bounce it onto the UI thread, exactly as the staging panel just above
already does, guarded on the generation so a repo switch drops it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
PopupMutex guarded CurrentPopupOpts against a popup being created on a
worker goroutine while the UI thread deactivated it, or reset it on a
repo switch. Now that popup and menu creation is bounced onto the UI
thread, every access to CurrentPopupOpts — create, deactivate, and the
reset-on-switch (which already runs on the UI thread) — happens on the
one goroutine, so the mutex protects nothing.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fetching pull requests can take a long time, and we don't want to delay
the refresh by it; in particular, for a WithWaitingStatusBlockingInput
we want the UI thread to be unblocked again while pull requests are
still fetching in the background. This is similar to how we fetch the
behind values for branches in BranchLoader; this will update the UI
without much flicker when done, and doesn't have to block anything.
The two branches of the `refresh` closure ran the scope function
identically; they differed only in that the UI-thread path registered
each scope as its own gocui task while the worker/demo path used a bare
goroutine (and only the latter logged per-scope timing).
Those per-scope tasks were redundant. performRefresh always runs under a
task that stays busy until the wg.Wait in waitAndFinalize joins every
scope goroutine: the calling worker's own task when called from a worker,
or the waitAndFinalize worker task when called from the UI thread — and
that task is created (busy) before the triggering event's task goes Done,
so there is no window in which nothing is busy. Repo-switch safety and the
integration-test idle signal are therefore already covered without giving
each scope its own task.
Collapsing to the single goroutine path also means the timing log now
fires for UI-thread refreshes too, not just worker ones.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Nothing calls it anymore now that the commit-surgery operations run on a
worker with input blocked. Remove the helper, its bespoke synchronous
spinner loop (renderAppStatusSync/setAppStatusContent), the popup-handler
plumbing, and the interface method.
That loop was also the only thing suppressing the yellow "Rebasing" mode
indicator (and its reset button) while lazygit drives a rebase itself.
Move that suppression to WithWaitingStatusBlockingInput so it applies to
every input-blocking commit-surgery op — including the ones that already
ran on a worker (edit, drop, and so on) and previously let the indicator
flash on mid-operation. It's cleared after the refresh, so an operation
that legitimately leaves a rebase in progress still shows the mode.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
With the last synchronous commit-surgery callers moved to workers,
nothing runs CheckMergeOrRebase on the UI thread anymore, so
CheckMergeOrRebaseWithRefreshOptionsFromUIThread has no callers. Remove
it and fold the shared checkMergeOrRebaseImpl back into
CheckMergeOrRebaseWithRefreshOptions, which is now always on a worker.
The runAction closure loses its calledFromWorker parameter for the same
reason.
genericMergeCommandImpl keeps its calledFromWorker flag: the
merge/rebase-continue subprocess path still runs on the UI thread when
invoked straight from the menu, and on a worker for the recursive
auto-skip.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
edit, quick-start rebase, drop, reword, squash, fixup, amend
(including the amend-attribute author operations) and
discard-file-from-commit all run a rebase on a worker. A key pressed
while one is in flight could act on a stale commit or todo — pressing e
to start an interactive rebase, then up+d before it finishes, is the
motivating example. Switch them from WithWaitingStatus to
WithWaitingStatusBlockingInput so input is held and replayed against the
post-operation state, matching the commit-surgery ops that were already
sync.
Left alone: the custom-patch move/delete/pull-into-commit rebases (no
need to block input while building and applying a patch), the
loading-more-commits and patch-building toggle spinners (no rebase to
disrupt), and fetches and other non-surgery operations where blocking
navigation would only get in the way.