Commit graph

7916 commits

Author SHA1 Message Date
Stefan Haller d887a41ad2 Don't flush the screen while suspended
When suspending with ctrl+z, the suspend keybinding handler disengages
the screen and then sends SIGSTOP to the process group, so the UI
thread freezes at the return from kill(2) with the handler's follow-up
flush still pending. When fg continues the process, that pending flush
races the SIGCONT handler's Resume. If the flush wins, Show() draws
against the disengaged screen, whose cell buffer tcell has released to
0x0 while its width/height still hold the old size; drawCell() then
reports width 0 for the out-of-range cell, the draw loop's
'x += width - 1' never advances, and the UI thread spins forever while
holding the tcell screen lock. Resume in turn blocks forever on that
lock, so the screen never re-engages and no input is ever read again:
the hard stall of #5309, only recoverable by killing the process.

Guard both flush paths with the suspended flag. For the flag to
guarantee that the screen is engaged whenever it is false, Resume must
clear it only after re-engaging (it used to clear it before); Suspend
already sets it before disengaging. This also covers the pre-existing
unsynchronized suspended check in draw(), which is subsumed by the
guards and can go.

The regression test cannot use the demonstrate-then-fix pattern: on
unfixed code the flush goroutine spins holding the screen lock, which
deadlocks any subsequent screen call including the test cleanup's
Close().
2026-07-20 14:23:09 +02:00
Stefan Haller 3ed6ce8f67 Add test showing that resuming after a suspend schedules no redraw
When lazygit is suspended with ctrl+z and brought back with fg, nothing
deliberately triggers a redraw. The screen only repaints because the UI
thread happens to have a flush pending from the suspend keybinding, and
that flush races the SIGCONT handler's resume; when it loses in the
right way, the terminal shows a blank screen until the next input event
arrives (#5309).
2026-07-20 14:23:09 +02:00
Stefan Haller b371411567 Recommend the gopls MCP tools for symbol navigation in AGENTS.md
Grep-based navigation needs manual filtering for the many colliding
method names in this codebase, while gopls answers reference and
implementation questions type-aware and exactly. Scope the guidance to
the symbol tools and keep grep for textual searches: gopls' own MCP
instructions prescribe running vulncheck at session start and
go_file_context after every file read, which costs more than it helps
here. The server is registered per user and machine, so sessions
without it must just fall back to grep rather than try to set it up.
2026-07-20 14:23:09 +02:00
Stefan Haller e91dcb7056
Suppress output from a few git commands that pollute the command log (#5834) 2026-07-19 19:29:08 +02:00
Stefan Haller 197916aafb Suppress command logs for git calls related to the ctrl+f command 2026-07-19 19:22:48 +02:00
Stefan Haller 33a2dc302e Suppress command logs for tag commands
The command log is supposed to show only commands initiated by the user;
these are commands that we run to get information for rendering, so they
pollute the log and are confusing.
2026-07-19 19:19:47 +02:00
Stefan Haller 15c83e6356
Don't block repo switching on slow network (#5829)
Fix two problems that would prevent switching repos or worktrees while a
background fetch was running, especially when the network is very slow
and the fetch takes long. See commit messages for details.

Labelling as ignore-for-release because it fixes a regression that was
introduced after the last release.
2026-07-17 17:47:27 +02:00
Stefan Haller a1561a5e69 Render the app status in a single background render loop
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>
2026-07-17 15:55:01 +02:00
Stefan Haller 7360a8459d Give the GitHub GraphQL requests a timeout
The http.Client used for fetching pull requests had no timeout, so on a
network that silently drops packets a request could stay in flight
until the OS-level TCP timeouts kick in, which can take many minutes.
The fetch has no visible status, so nothing tells the user it is still
running; bounding it keeps the refresh's worst case short, and the next
refresh simply tries again.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 13:51:38 +02:00
Stefan Haller 5055c4fb65 Fetch GitHub pull requests as a background task
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>
2026-07-17 13:37:23 +02:00
Stefan Haller 4cf12a5b7b
Synchronize async view rendering (#5791)
Several fixes for the last concurrency problems we still had: we used to
access view properties on worker threads, which isn't allowed (writing
to a view itself is fine and guarded by a mutex, but other fields are
not, and even that mutex had a few holes that are plugged here).

With this, our integration test suite seems to be fully deterministic
and race-free, so we also remove the retry safety net (MaxAttempts=2)
that we were using to address flakiness.
2026-07-17 12:38:47 +02:00
Stefan Haller 5769ab2190 Don't retry failed integration tests
Now that we solved all known concurrency issues and our tests should be
100% deterministic, reduce MaxAttempts to 1 so that tests fail
immediately. We don't want to paper over existing flakiness any more.
2026-07-17 12:35:54 +02:00
Stefan Haller e299de3270 Assert that Model() and Context() are only accessed on the UI thread
The bounce model requires that a worker never touch UI-thread-owned
state: it should capture what it needs on the UI thread and pass that
in. Guard the two central accessors -- Model() (the git model) and
Context() (the context manager, which owns the mutable
current-context/stack) -- with a debug-only panic when they're called
off the UI thread. Since the integration tests run with -debug, a stray
worker access now fails deterministically and points at itself, rather
than surfacing later as a probabilistic data race.

One supporting change make the assertion usable: the integration test
driver inspects gui state from the test goroutine, so
GuiDriver.CurrentContext reads the context manager directly rather than
through the now-guarded c.Context().

Contexts() (the registry of context objects) is deliberately left
unguarded: workers legitimately fetch a context to grab its mutex or
check identity, so a blanket assertion there would flag safe accesses.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 12:35:54 +02:00
Stefan Haller c23bcd6d94 Store the UI thread ID earlier 2026-07-17 12:35:54 +02:00
Stefan Haller 87ef96974e Check for exec todos on the UI thread
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>
2026-07-17 12:35:54 +02:00
Stefan Haller 9f2886f96f Hold the file-path suggestions trie outside the model
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>
2026-07-17 12:35:54 +02:00
Stefan Haller 25a3689c01 Refresh the merge conflicts state on the UI thread
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>
2026-07-17 12:35:54 +02:00
Stefan Haller 2a7b74d3f3 Don't access Model in refreshReflogCommits
This was old code that was supposed to make a race less likely, but now
that we capture model stuff on the UI thread we don't need it any more.
2026-07-17 12:35:54 +02:00
Stefan Haller d36ce51559 Capture suggestions inputs on the UI thread
RefreshSuggestions dispatched to an AsyncHandler worker that read
State.FindSuggestions and the prompt's TextArea (via GetPromptInput)
from the worker goroutine. The main thread rewrites both in
preparePromptPanel when it (re)creates a prompt panel, so an in-flight
suggestions worker races those writes -- two data races surfaced under
-race (filter_by_path/reword_commit_in_filtering_mode).

Capture both on the UI thread (RefreshSuggestions is only ever called
from UI-thread handlers) before dispatching to the worker. This is also
more correct: we search for the input as it was when dispatched, which
is what this request's AsyncHandler id corresponds to.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 12:35:54 +02:00
Stefan Haller 1b0cc02e1e Refresh once when dropping multiple stash entries
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>
2026-07-17 12:35:54 +02:00
Stefan Haller 1efcfcc148 Don't share a live view's buffer when copying its content
moveMainContextToTop copies the current top view's content into the view
it's promoting, to avoid a flicker. The source can be a main view with a
live streaming task (e.g. resolving a conflict promotes the merge-conflicts
view over a main view that's mid-diff), and CopyContent both read and
published that source's buffer unsafely:

  - it read the source's lines/viewLines while locking only the
    destination, racing the task's concurrent Write; and
  - it aliased the source's row slices into the destination, so the
    source's ongoing appends (growslice reading the shared array) and
    refreshViewLinesIfNeeded's in-place wrapping-cache writes (&lines[i])
    kept racing this view's rendering after the copy.

Lock the source for the read, and shallow-clone the row slices so the
destination gets its own arrays. The per-row cell data is immutable once
written, so it stays shared -- the clone cost is proportional to the
number of rows, not their contents.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 12:35:54 +02:00
Stefan Haller d48c8174d5 Refresh the patch-building panel on the UI thread
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>
2026-07-17 12:35:54 +02:00
Stefan Haller 33b8d497c2 Guard the patch builder against concurrent access
The custom-patch git operations (move/pull/delete patch, and their rebase
continuations) run on worker goroutines and call PatchBuilder.Reset when
they've consumed the patch, clearing To and the fileInfoMap. Meanwhile the
UI thread reads that state every layout — the options bar and the mode
indicator both call Active() — so the reset raced the render.

Add a mutex. The map's entries are only ever touched on the UI thread, so
the lock only has to serialize the To field and the fileInfoMap pointer:
readers snapshot the pointer under the lock and iterate the local, and
getFileInfo drops the lock across its git diff I/O rather than holding it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 12:35:54 +02:00
Stefan Haller 59ed1517bc Wait for the event loop to exit in integration tests
The test harness enqueued ErrQuit after a test finished, waited for the
program to go idle, then slept a fixed second and declared "gocui should
have already exited" if it hadn't. That fixed grace is fragile: under the
race detector the shutdown legitimately takes longer than a second, so
nearly every test failed with that message even though nothing was wrong.

Wait for the main loop to actually return instead. gocui now closes a
loopExited channel when MainLoop exits, and the harness blocks on it; the
existing 40s watchdog still fails a test whose loop genuinely never quits.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 12:35:54 +02:00
Stefan Haller 435e02efa8 Remove the now-dead PopupMutex
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>
2026-07-17 12:35:54 +02:00
Stefan Haller 9754a77b64 Create popups and menus on the UI thread
Raising a popup or menu pushes a context and mutates the popup views, so
it must happen on the UI thread. But it can be triggered from a worker
goroutine — for example a WithWaitingStatus handler that hits a merge
conflict and calls PromptForConflictHandling, or a worker that shows a
confirmation — where it raced the UI thread's layout and draw code.

Bounce the creation onto the UI thread at the one point where the popup
and menu producers are injected into the popup handler, so every caller
stays oblivious to the threading. For a caller that is already on the UI
thread this adds no delay: the main loop drains the enqueued closure in
the same event-processing cycle, before it draws.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 12:35:54 +02:00
Stefan Haller eda2151330 Handle a command task's end-of-input on the UI thread
When a command task reaches EOF it runs onEndOfInput, which reads the
view's line height (and thus its dimensions) to decide whether to scroll,
sets the view's origin, and flushes stale cells. Reading the dimensions
and setting the origin are UI-thread-only, but this ran on the task's own
goroutine, racing the UI thread. Bounce onEndOfInput onto the UI thread,
as we already do for the new-task origin reset. It's once per render, so
it doesn't add the per-line UI-thread churn that streaming the content
would.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 12:35:54 +02:00
Stefan Haller 65cb439076 Take the write mutex when clearing view lines and reading the buffer
A view's line buffer, its viewLines/tainted flags, and its hover state
are all written from the command-task goroutine (under writeMutex) as it
renders. But three accessors reached that same state from the UI thread
without the lock: SetView and the GUI-resize path cleared a view's lines
directly, viewsToRedrawContentOnly read the tainted flag, and Buffer read
the line buffer. Each raced a rendering task.

Guard them with writeMutex, matching the view's other buffer accessors.
These are reads/clears of state writeMutex already protects, not new
callers of it -- the view's geometry stays outside the mutex.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 12:35:54 +02:00
Stefan Haller f6eaed8cd4 Snapshot the view width for command-task rendering on the UI thread
A command task streams its output into a view from its own goroutine. To
track soft-wraps (so cursor-positioning escapes from a pager land on the
right line) the write path read the view's live InnerWidth, and the pty
setup read its InnerSize -- both off the UI thread, racing the UI thread
mutating the view's dimensions during layout.

Capture the width on the UI thread instead and hand it to the task: the
escape interpreter keeps a screenColMax it reads from, seeded in NewView
and refreshed per render via View.SetContentWidth (called from
newCmdTask/newPtyTask before the task's goroutine starts), and the pty
size is computed in the after-layout callback rather than in the task's
start func. The view's dimensions stay UI-thread-only; the task uses the
snapshot rather than reading them live.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 12:35:54 +02:00
Stefan Haller e75688c101 Make the ViewBufferManager throttle flag atomic
The throttle flag is set from the goroutine that watches a task for
being stopped, and read when the next task starts up -- two different
goroutines, so the plain bool field was a data race.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 12:35:54 +02:00
Stefan Haller 40868a9389 Hold the ViewBufferManager readLines channel in an atomic
The readLines channel, by which a running task is told to read more
lines as the user scrolls, is swapped out as tasks start and finish. It
was a plain field written from the task goroutines (when a task starts,
ends, or is replaced) and read from the UI thread in ReadLines/
ReadToEnd, so those accesses raced -- a longstanding data race (and a
plausible cause of the occasional "main view stops updating" hang, since
a torn read there could drop a scroll's read request).

Make the field an atomic.Pointer and give the running task a captured
local copy of the channel for its own send/receive, so the field itself
is only ever loaded/stored atomically. No lock is involved, so there's
nothing to untangle later.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 12:35:54 +02:00
Stefan Haller 99c1bcbf23 Reset the view origin for a new task on the UI thread
When a task renders different content to a view (a new task key), the
view's scroll origin is reset to the top via onNewKey. That ran on the
task's own goroutine, racing the UI thread, which reads the origin
(OriginY) while laying out and drawing the view -- the single largest
source of view-render data races.

Give ViewBufferManager a bounce primitive (onUIThread) that runs a
function on the UI thread and waits for it, and reset the origin through
it. This is the first use of the primitive; subsequent commits route the
rest of the task's view mutations through it too, so that the view is
only ever touched on the UI thread. It runs as background work
(OnUIThreadAndWaitBackground) so rendering doesn't count towards the app
being busy, matching how the render's gocui task is already created.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 12:35:54 +02:00
Stefan Haller 303372d917 Perform string-task view updates on the UI thread
The closures that render static content to a main view (newStringTask
and friends) ran on the ViewBufferManager's task goroutine, calling
SetViewContent/SetOrigin/ResetViewOrigin directly on the view. Those
touch view state (the line buffer, hover cells, the origin) that the UI
thread concurrently reads and mutates while laying out and drawing, so
they raced it -- e.g. a string task's SetContent clearing the view's
lines while the UI thread's CopyContent read them, or its SetOrigin
racing the layout's OriginY read.

Bounce the whole closure onto the UI thread instead, so the view is only
touched there. The bounce blocks (OnUIThreadAndWaitBackground) so the
task still completes only once the content has actually been rendered,
which the integration-test idle detection relies on; the background
variant keeps it from counting towards the app being busy, matching the
existing treatment of view rendering as work that must not block a repo
switch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 12:35:54 +02:00
Stefan Haller c58aa2a23b
Remove the BLOCK_UI refresh mode (#5790)
We used to run some refreshes with a BLOCK_UI mode that causes it to run
on the main thread, blocking it. The two main reasons for doing this
were that in some cases we want the UI to update all at once, rather
then each panel when it's done refreshing (and in particular also render
a selection change in sync with the list update, e.g. when checking out
a branch, where the checked-out branch moves to the top and gets
selected there), and that we wanted to block keyboard input while the
refresh was running (so that people who like to work fast can "type
ahead"; e.g. press `e` to start an interactive rebase, and then `up` and
`d` to set the next rebase todo to dropped).

There were two problems with this: it blocked UI updates, so for
checking out a branch the status spinner would spin while the git
checkout command is running, but then freeze while the refresh was
running, which looked ugly; and also, for the "type ahead" scenario it
isn't enough to block events only while refresh runs, but we'd have to
block them during the entire operation.

This PR makes several improvements to this mechanism:
- it separates the two concerns: updating the UI all at once is a
separate RefreshOptions flag that can be set regardless of refresh mode,
and blocking keyboard input is a separate mechanism independent of
refresh. The two will often be used together, but don't have to.
- remove the special WithWaitingStatusSync mode that we used for cases
where "type ahead" support is especially important (mainly moving a
commit up/down): this used to work synchronously on the UI thread to
ensure that input is blocked, and used a special mechanism to still draw
the spinner. We don't need this any more now that we can block input
while doing the work on a background thread.
- get rid of the refresh mode (SYNC/ASYNC) altogether. We have had
separate Refresh/RefreshFromWorker calls for a while now (since #5767),
and the rule now is that all RefreshFromWorker calls are sync and all
Refresh calls are async.
2026-07-17 12:35:28 +02:00
Stefan Haller 4ff161b48d Don't wait for pull requests to be fetched in refresh
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.
2026-07-17 12:32:32 +02:00
Stefan Haller 2f60280eb6 Log Refresh timing information for both sync/async
For async refreshes (from UI thread) it would only log the time it took
to schedule the refreshXxx calls, which is not useful.
2026-07-17 12:32:32 +02:00
Stefan Haller 9bb9fc8933 Run all refresh scopes on plain goroutines
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>
2026-07-17 12:32:32 +02:00
Stefan Haller a247dfd76d Retire WithWaitingStatusSync
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>
2026-07-17 12:32:32 +02:00
Stefan Haller a324f8aef1 Drop the now-unused UI-thread CheckMergeOrRebase path
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>
2026-07-17 12:32:32 +02:00
Stefan Haller d802cbdddf Block input during the worker commit-surgery ops
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.
2026-07-17 12:32:32 +02:00
Stefan Haller 352883c52b Run the sync commit-surgery ops on a worker with input blocked
Move, revert, squash-fixups, create-fixup and cherry-pick paste ran
their rebase synchronously on the UI thread via WithWaitingStatusSync,
which froze the UI for the duration but kept the user from disrupting the
operation with a stray keypress. Switch them to
WithWaitingStatusBlockingInput so the git work runs on a worker — the UI
keeps rendering and the spinner animates — while input stays blocked for
the whole operation, as before.

discard-patch-from-commit also moves off WithWaitingStatusSync, but as a
plain WithWaitingStatus: it's a custom-patch command, and those don't
block input.

The bodies now follow the worker conventions: model state they need is
captured on the UI thread before dispatching, self.c.Refresh becomes
RefreshFromWorker, and CheckMergeOrRebase uses the worker variant. An
operation that moves the selection does so in the refresh's Then, so it
lands in the same frame as the refreshed commit list; squash sets it as
an absolute index there, because the shorter list would clamp a relative
move.
2026-07-17 12:32:32 +02:00
Stefan Haller 62098ca603 Pass captured state to moveFixupCommitToOwnerStackedBranch
It reads the selected index and the commits and branches models to
decide where to move the fixup commit. Take those as parameters,
captured on the UI thread by the callers, so the function can run its
rebase on a worker without reading the model there. No behavior change;
the callers still run on the UI thread for now.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 12:32:32 +02:00
Stefan Haller 707b04a8c2 Add a WithWaitingStatusBlockingInput helper
Bracket gocui's BeginBlockingEvents/EndBlockingEvents around a
worker operation that shows a waiting status. The block is begun
synchronously on the UI thread, before the operation is dispatched to a
worker, so no keypress can slip through in between; it ends via
OnUIThread once the operation and its refresh have applied their UI
updates, so the replayed keys act on the refreshed state.

This composes what the retiring WithWaitingStatusSync did — show a
status and block input — but on a worker, so the UI keeps rendering
(spinner animates, model updates land) instead of freezing. Callers
follow in subsequent commits.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 12:32:32 +02:00
Stefan Haller 6893d9a759 Add gocui primitives to block input during an operation
Long-running operations that lazygit drives itself (rebases, and the
commit surgery built on them) can be corrupted by keys the user presses
while they run: pressing e to start an interactive rebase, then up+d
before it finishes, must act on the resulting todo list, not race the
rebase. WithWaitingStatusSync gets this today only as a side effect of
freezing the UI thread, which the rest of this branch is moving away
from.

Add a nestable counter, BeginBlockingEvents/EndBlockingEvents, that
withholds input at the event-dispatch layer without freezing anything:
while blocked, key events are buffered and replayed in order once the
count returns to zero (so they act on the now-current context), mouse
clicks and hover are dropped (replaying them against a changed layout
would target the wrong thing), and scrolling, resize, focus and all
rendering keep flowing. These are the reusable core; a gui-level helper
that brackets them around a worker operation follows.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 12:32:32 +02:00
Stefan Haller 88811e6795 Remove the RefreshMode field
With sync vs async now derived from the calling thread, the Mode field
and its SYNC/ASYNC constants no longer carry any information: Refresh is
always async, RefreshFromWorker always sync. Drop the field, the type,
and the Mode argument at every call site, and reduce the debug log's
mode name to a plain sync/async derived from calledFromWorker.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 12:32:32 +02:00
Stefan Haller 8580c78cc0 Derive sync vs async refresh from the calling thread
Whether a refresh should block or run in the background was controlled
by the Mode field, but that always lined up with the calling thread: a
UI-thread Refresh must not block the UI, while a RefreshFromWorker runs
on a worker where blocking is exactly what we want. Now that Then and
BatchUIUpdates work regardless of that choice, drop Mode from the
decision and key it off calledFromWorker instead:

  - Refresh (UI thread) runs its scopes and the finishing step (wait,
    batch flush, Then) on workers, so the caller returns immediately —
    what ASYNC used to mean.
  - RefreshFromWorker runs them on the calling worker, blocking it until
    everything is done — what SYNC used to mean.

Demos keep taking the blocking, inline path so everything still lands in
one deterministic frame.

In practice this flips the handful of RefreshFromWorker calls that
passed ASYNC — they now block their worker until the refresh finishes,
keeping the waiting-status spinner up until the UI actually updates —
and the many UI-thread refreshes that defaulted to SYNC, which no longer
freeze the UI thread while the git work runs. Mode now only feeds the
log line; the next commit removes it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 12:32:32 +02:00
Stefan Haller 63bd2d98c0 Show a waiting status while creating a branch
Creating a branch checks it out, and checking out a distant ref (a tag
or a commit far from HEAD) can take a noticeable while. NewBranch ran
that synchronously in the prompt's confirm handler, on the UI thread, so
the UI froze — no spinner, no repaint — until it finished.

Move the branch creation (and the autostash path) onto a worker with a
waiting status, mirroring CheckoutRef, and refresh from the worker so
the UI thread stays live and the spinner keeps animating.

Push the branches context from the refresh's Then rather than up front:
the refresh already batches its UI updates, so switching panels there
lands the switch in the same frame as the refreshed branch list instead
of flashing the pre-refresh list while the checkout is still running.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 12:32:32 +02:00
Stefan Haller bfd3b7b47e Allow Then and BatchUIUpdates to work with an async refresh
Then, and BatchUIUpdates, previously only worked for a SYNC refresh: the
calling goroutine blocked in wg.Wait until every scope had finished, and
only then flushed the batch and ran Then. An ASYNC refresh had no such
join point — it dispatched each scope onto its own worker and returned
right away — so Then was forbidden (it would have run before the scopes
finished) and a batch would never be drained.

Give the async path a join of its own. Both paths now register their
scopes in the WaitGroup, and the finishing work — wg.Wait, the batch
flush, and Then — moves into a closure. A SYNC refresh runs it inline as
before; an ASYNC refresh dispatches it to a worker, so the caller still
returns immediately but the batch and Then run once every scope is done.

Besides lifting the restriction, this makes SYNC and ASYNC differ only
in whether the finishing work blocks the caller, which is what lets a
later commit drop the mode entirely and key the choice off the calling
thread instead.
2026-07-17 12:32:32 +02:00
Stefan Haller f319522d5b Remove fRunsOnUIThread variable; use calledFromWorker directly
There is no f() function any more, so a variable named "f runs on"
doesn't make sense. And we also don't need it any more; it used to be
necessary when its meaning was not exactly the same as
`!calledFromWorker`, but also included the BLOCK_UI case, but that has
changed several commits ago.
2026-07-17 12:32:32 +02:00
Stefan Haller d70d70aad2 Get rid of pointless f() indirection
This was useful when there was a BLOCK_UI mode where f() was called
differently, but now we no longer need it. I'm making this change as a
separate commit because folding it into the previous one (which would
conceptually have made sense) would have made that diff unreadable
because of the indentation change.

The variable `fRunsOnUIThread` and its comment no longer make sense now;
we'll clean this up next.

The diff is best viewed with --ignore-all-space.
2026-07-17 12:32:32 +02:00