Compare commits

...
Author SHA1 Message Date
Stefan Haller 8697412c1f WIP fix clipboard in WSL with non-UTF-8 locale
If this works we'll need to upstream it to atotto/clipboard; testing it
here first.
2026-07-18 22:49:51 +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 HallerandClaude Fable 5 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 HallerandClaude Fable 5 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 HallerandClaude Fable 5 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 HallerandClaude Opus 4.8 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 HallerandClaude Opus 4.8 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 HallerandClaude Opus 4.8 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 HallerandClaude Opus 4.8 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 HallerandClaude Opus 4.8 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 HallerandClaude Opus 4.8 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 HallerandClaude Opus 4.8 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 HallerandClaude Opus 4.8 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 HallerandClaude Opus 4.8 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 HallerandClaude Opus 4.8 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 HallerandClaude Opus 4.8 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 HallerandClaude Opus 4.8 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 HallerandClaude Opus 4.8 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 HallerandClaude Opus 4.8 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 HallerandClaude Opus 4.8 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 HallerandClaude Opus 4.8 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 HallerandClaude Opus 4.8 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 HallerandClaude Opus 4.8 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 HallerandClaude Opus 4.8 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 HallerandClaude Opus 4.8 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 HallerandClaude Opus 4.8 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 HallerandClaude Opus 4.8 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 HallerandClaude Opus 4.8 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 HallerandClaude Opus 4.8 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 HallerandClaude Opus 4.8 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 HallerandClaude Opus 4.8 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 HallerandClaude Opus 4.8 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 HallerandClaude Opus 4.8 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
Stefan HallerandClaude Opus 4.8 4acfc88065 Replace the BLOCK_UI refresh mode with a BatchUIUpdates flag
BLOCK_UI ran the whole refresh on the UI thread and parked it in a
wg.Wait for the duration, so the UI (and its spinner) froze while the
git work ran. Blocking the UI was never the point — the point was to
apply all the scopes' updates in one frame instead of a per-scope
cascade — and if we genuinely wanted to block input it should span the
whole operation, not just its refresh, which needs a gocui-level
mechanism we don't have.

So drop the mode and add a BatchUIUpdates option that achieves the
"one frame" effect without blocking: each scope's UI-thread bounce is
collected into a shared refreshBounceBatch during the refresh, and once
every scope has finished they're all applied inside a single OnUIThread
task. gocui drains every queued event before it redraws, so one task
means one repaint. The refresh itself now runs SYNC — on a worker when
issued from one (checkout, move-to-new-branch, the rebase-edit result
handling), so the UI thread stays live and the spinner keeps animating.

The batch needs a mutex because the scopes add concurrently from their
worker goroutines, and a closed flag so that any bounces enqueued after
the flush starts — the nested ones a flushed bounce produces in turn,
e.g. scrolling the selection into view — are dispatched immediately as
ordinary follow-ups rather than collected into a batch that nothing
will drain.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 12:32:32 +02:00
Stefan Haller 504e5b3f74 Remove the error return value from the onUIThreadUnlessRepoChanged lambda
All clients pass a function that returns nil.
2026-07-17 12:32:32 +02:00
Stefan Haller 36f193a2e8 Remove return value from PromptToContinueRebase
It always returned nil.
2026-07-17 12:32:32 +02:00
Stefan HallerandClaude Opus 4.8 2765147b71 Note in AGENTS.md that gocui lives in-tree
Agents (and humans new to the repo) repeatedly go looking for the gocui
sources in go.mod, go.sum, or the module cache and hit a dead end, because
gocui is a fork maintained in-tree under pkg/gocui rather than pulled in as
a dependency. Record that in AGENTS.md so the dead end is avoided.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 12:32:32 +02:00
Stefan Haller edec427746 Fix command log streaming race (#5789)
Serialize concurrent writes to the streamed command's output writer.
`runAndStreamAux` funnels a command's stdout and stderr into a single
`cmdWriter` (the command-log panel, or a buffer when output is
suppressed) from two separate goroutines: stderr through the MultiWriter
set on `cmd.Stderr`, and stdout through the `onRun` callback. Those
goroutines
wrote the shared writer without any synchronization, racing on the
prefixWriter's `prefixWritten` flag and interleaving the two streams.
Wrap the writer so its writes are serialized.
2026-07-17 12:32:00 +02:00
Stefan HallerandClaude Opus 4.8 a61be44e92 Wait for the streamed command's output goroutine before reading its buffers
runAndStreamAux reads the stdout buffer (and, when output is suppressed,
the combinedOutput buffer) for its error message after handler.wait()
returns, but the goroutine that fills those buffers by draining the
command's output isn't awaited, so the reads raced its final writes.

Own the goroutine here rather than letting the onRun callbacks spawn it,
and join it before reading the buffers. The pty reader reaches EOF on its
own once the process exits, but the non-pty pipe never does, so its
handler now closes the read end to unblock the reader; the pipe is
synchronous, so by the time the command has exited all of its output has
already been read and nothing is lost. This also plugs the goroutine that
the non-pty streaming path previously leaked on every command.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 12:19:14 +02:00
Stefan HallerandClaude Opus 4.8 d097519c05 Serialize concurrent writes to the streamed command's output writer
runAndStreamAux funnels a command's stdout and stderr into a single
cmdWriter (the command-log panel, or a buffer when output is suppressed)
from two separate goroutines: stderr through the MultiWriter set on
cmd.Stderr, and stdout through the onRun callback. Those goroutines
wrote the shared writer without any synchronization, racing on the
prefixWriter's prefixWritten flag and interleaving the two streams.
Wrap the writer so its writes are serialized.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 12:19:14 +02:00
Stefan Haller 4b97c2ba61 Fix side panel rendering when branches/commits are not their panel's first tab (#5825)
Side panel rendering was broken when 'branches' or 'commits' were not
the first tab in their respective side panel.

Fixes #5823.
2026-07-17 12:16:24 +02:00
Stefan HallerandClaude Fable 5 74a77e58be Assign the transient contexts' initial windows from the side panel config
The transient contexts (remoteBranches, subCommits, commitFiles) take
over the window of the context they are drilled into from, but until
then they carry a hardcoded initial window ("branches" or
"commits"). Under a gui.sidePanels config where those tabs aren't
their panel's first, no window of that name exists, leaving the
window-to-view map with entries for windows the layout never
produces. The previous commit made such entries harmless, but there's
no reason to have contexts point at nonexistent windows in the first
place; assign them the window hosting branches or commits instead,
which the config validation guarantees to exist.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 12:07:24 +02:00
Stefan HallerandClaude Fable 5 bf4f5827e7 Don't show a transient view whose window is not part of the layout
With gui.sidePanels, a panel's gocui window is named after its first
tab, so when branches is grouped behind, say, worktrees, there is no
window called "branches" at all. The transient contexts
(remoteBranches, subCommits, commitFiles) initially point at the
windows "branches" and "commits", and layout() showed their views
whenever the window-to-view map named them as their window's current
view — without checking that the window exists in the layout. Since
the map is seeded from the contexts themselves, a window that no
panel owns keeps naming a transient view as its current view, and
that view had just been parked at full screen size (the fallback for
views in unlaid-out windows), so it covered every side panel below it
in z-order.

Only show a transient view if its window actually received dimensions
in this layout.

Fixes #5823.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 12:07:24 +02:00
Stefan HallerandClaude Fable 5 38e1fe0493 Add tests showing ghost views when branches/commits are not their panel's first tab
With gui.sidePanels, a panel's gocui window is named after its first
tab. The transient contexts (remoteBranches, subCommits, commitFiles)
initially point at the windows "branches" and "commits"; when the
config gives no panel that name, their views end up visible at full
screen size, covering every side panel below them in z-order (issue
#5823).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 12:07:24 +02:00
Stefan Haller 0162839f0c Bump tcell to an unreleased snapshot to fix a shutdown race (#5824)
tcell's filterEvents goroutine sends events into eventQ with a plain
blocking send, while Fini (via finish/finalize) closes eventQ after
closing the quit channel. The goroutine can have already committed to
the ev = <-inQ select arm when quit is closed, so its send into eventQ
races with the close; the race detector flags this (send and close on
the same channel are unsynchronized), and if the close wins, the send
panics with "send on closed channel".

This was caught by the integration tests under the race detector, where
every test drives a real tScreen over a MockTerm and tears it down via
Fini, but it equally affects real-terminal shutdown.

Upstream fixed it in 243630d2 ("Fix screen Init/Fini races") by tracking
the filter goroutine in a WaitGroup that finalize waits for before
closing eventQ, and guarding the send with a select on quit. That commit
is not in a tagged release yet (latest is v3.4.0), so pin the
pseudo-version; the delta over v3.4.0 is just this fix, a Windows
key-release fix, a cell-rendering perf tweak, and dependency bumps.
2026-07-17 08:49:20 +02:00
Stefan HallerandClaude Fable 5 14d717d77f Bump tcell to an unreleased snapshot to fix a shutdown race
tcell's filterEvents goroutine sends events into eventQ with a plain
blocking send, while Fini (via finish/finalize) closes eventQ after
closing the quit channel. The goroutine can have already committed to
the ev = <-inQ select arm when quit is closed, so its send into eventQ
races with the close; the race detector flags this (send and close on
the same channel are unsynchronized), and if the close wins, the send
panics with "send on closed channel".

This was caught by the integration tests under the race detector,
where every test drives a real tScreen over a MockTerm and tears it
down via Fini, but it equally affects real-terminal shutdown.

Upstream fixed it in 243630d2 ("Fix screen Init/Fini races") by
tracking the filter goroutine in a WaitGroup that finalize waits for
before closing eventQ, and guarding the send with a select on quit.
That commit is not in a tagged release yet (latest is v3.4.0), so pin
the pseudo-version; the delta over v3.4.0 is just this fix, a Windows
key-release fix, a cell-rendering perf tweak, and dependency bumps.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 08:41:25 +02:00
Stefan Haller 07745afc57 Escape the merge conflicts view before prompting to continue the rebase (#5822)
When the last conflict of a file is resolved, a files refresh both
offers to continue the rebase/merge (if we started it ourselves) and,
via its merge-conflicts scope, escapes from the merge conflicts view
back to the files context. The two race: the prompt is bounced onto the
UI thread by the files worker, while the escape's context push is queued
separately by EscapeMerge, and it deliberately refuses to push the files
context over a popup. So if the prompt opens first, the escape does
nothing, and closing the prompt lands the user in the stale merge
conflicts view — usually already emptied by the escape's state reset —
instead of the files panel. No later refresh rescues this.

Fix this by escaping from the merge conflicts view right before opening
the prompt. This runs on the UI thread and doesn't hold the merge
conflicts mutex, so it can reset the state and push the files context
synchronously; whichever side runs first, the prompt now always opens on
top of the files context, and EscapeMerge's guarded push still does
nothing only when that's the right thing to do.

This is a timing race with no deterministic regression test; it showed
up as a rare flake in tests that cancel the continue prompt (e.g.
commit/amend_when_there_are_conflicts_and_continue) when looping the
integration tests under the race detector.
2026-07-16 14:44:20 +02:00
Stefan HallerandClaude Fable 5 d786c9d79b Escape the merge conflicts view before prompting to continue the rebase
When the last conflict of a file is resolved, a files refresh both
offers to continue the rebase/merge (if we started it ourselves) and,
via its merge-conflicts scope, escapes from the merge conflicts view
back to the files context. The two race: the prompt is bounced onto
the UI thread by the files worker, while the escape's context push is
queued separately by EscapeMerge, and it deliberately refuses to push
the files context over a popup. So if the prompt opens first, the
escape does nothing, and closing the prompt lands the user in the
stale merge conflicts view — usually already emptied by the escape's
state reset — instead of the files panel. No later refresh rescues
this.

Fix this by escaping from the merge conflicts view right before
opening the prompt. This runs on the UI thread and doesn't hold the
merge conflicts mutex, so it can reset the state and push the files
context synchronously; whichever side runs first, the prompt now
always opens on top of the files context, and EscapeMerge's guarded
push still does nothing only when that's the right thing to do.

This is a timing race with no deterministic regression test; it
showed up as a rare flake in tests that cancel the continue prompt
(e.g. commit/amend_when_there_are_conflicts_and_continue) when
looping the integration tests under the race detector.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 09:11:16 +02:00
Stefan Haller 080da5cacf Fix idle notification deadlock (#5821)
Running the integration tests in a loop under the race detector
eventually hung in demo/bisect. The goroutine dump shows the cycle: a
background worker's task.Done() held the task manager's mutex while
blocking on the unbuffered idle-listener channel send, and the test
runner goroutine — the only reader of that channel — was itself blocked
in NewTask on that same mutex, on its way to enqueueing a caption render
(SetCaption -> Render -> OnUIThread). Neither side could proceed: the
notification couldn't be delivered until the test goroutine got the
mutex, and the mutex couldn't be released until the notification was
delivered.

The root problem is that the busy-to-idle notification is a blocking
rendezvous performed while holding the mutex, so it needs the waiter's
cooperation at a moment where the waiter may legitimately need the mutex
first.

Make the notification fire-and-forget instead: WaitUntilIdle waits on a
condition variable and re-checks "is any task busy?" under the mutex,
and the busy-to-idle transition broadcasts, which never blocks. Waiting
is now level-triggered rather than edge-triggered, which is also more
robust: a wait can no longer be satisfied by a stale idle transition
produced by an unrelated background task, because the predicate is
evaluated against the current state. This relies on the previous commit
having made replayed input events carry their task from submission;
without that, the wait could return in the window where an event is in
flight but not yet picked up by the main loop.
2026-07-15 15:05:10 +02:00
Stefan HallerandClaude Fable 5 0ce857c717 Fix a deadlock between task.Done() and the integration test's idle wait
Running the integration tests in a loop under the race detector
eventually hung in demo/bisect. The goroutine dump shows the cycle: a
background worker's task.Done() held the task manager's mutex while
blocking on the unbuffered idle-listener channel send, and the test
runner goroutine — the only reader of that channel — was itself blocked
in NewTask on that same mutex, on its way to enqueueing a caption
render (SetCaption -> Render -> OnUIThread). Neither side could
proceed: the notification couldn't be delivered until the test
goroutine got the mutex, and the mutex couldn't be released until the
notification was delivered.

The root problem is that the busy-to-idle notification is a blocking
rendezvous performed while holding the mutex, so it needs the waiter's
cooperation at a moment where the waiter may legitimately need the
mutex first.

Make the notification fire-and-forget instead: WaitUntilIdle waits on a
condition variable and re-checks "is any task busy?" under the mutex,
and the busy-to-idle transition broadcasts, which never blocks. Waiting
is now level-triggered rather than edge-triggered, which is also more
robust: a wait can no longer be satisfied by a stale idle transition
produced by an unrelated background task, because the predicate is
evaluated against the current state. This relies on the previous commit
having made replayed input events carry their task from submission;
without that, the wait could return in the window where an event is in
flight but not yet picked up by the main loop.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 15:01:04 +02:00
Stefan HallerandClaude Fable 5 664a65d584 Track replayed test input as busy from the moment it is submitted
Integration tests synchronize with lazygit through the task manager:
after submitting an input event, the test driver waits until the
program goes idle before asserting. But a submitted event only got its
task once the main loop picked it up from the events channel; while it
was still in flight (handed to the poller goroutine, or sitting in the
channel), no task existed for it, so the program could look idle even
though input was still pending.

The edge-triggered idle protocol mostly papers over this: each wait is
satisfied by the *next* busy-to-idle transition, which in practice is
the one produced by processing the submitted event. It only goes wrong
when some other task (e.g. a background refresh) completes in that
window, producing an edge the waiting test mistakes for its own — a
rare source of test flakes. The next commit replaces that protocol
with a level-triggered one, for which the window would be fatal rather
than rare: a wait falling into the gap would return immediately.

Close the gap by creating the task on the test goroutine before the
event is submitted, and carrying it through the poller into the main
loop, which uses it instead of creating its own. The new Replay*
methods own this invariant, and the replayed-events channels are no
longer exported, so tests can't submit an untracked event.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 15:01:04 +02:00
Stefan HallerandClaude Fable 5 7e1073a0ee Extract the tcell-to-gocui event conversion out of pollEvent
A following commit needs pollEvent to attach information from the
replayed-event wrappers to the GocuiEvent it returns. With the
conversion inlined there is no seam to do that in, because every branch
of the type switch returns directly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 15:01:04 +02:00
Stefan Haller 4fea011021 Merge v0.63.1 to master (#5820) 2026-07-15 14:15:01 +02:00
Stefan HallerandClaude Opus 4.8 733c1a487f Merge v0.63.1 into master
Resolve the pkg/gocui/gui.go conflict by keeping master's background-task
structure (Update/update(background), taskManager) and applying the
unbounded user-event queue on top — the same end state as if the fix had
been written on master directly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 14:08:59 +02:00
Stefan Haller aafe61082e Allow releasing from a branch other than master (#5819)
This is useful for cutting a patch release for the previous version when
master already contains work that shouldn't be released yet.

Scheduled runs are unaffected: with no input provided, the ref is empty
and the checkout falls back to the default branch.
2026-07-15 13:32:02 +02:00
Stefan Haller bd8c06ddc0 Rename version_bump options to be extra clear
I keep getting slightly confused as to which is which, so make this
extra clear.

While at it, change the default to minor, this is the option that is
more often used now that we don't have regular scheduled releases any
more.
2026-07-15 13:23:11 +02:00
Stefan Haller 1d99ba56fc Allow releasing from a branch other than master
This is useful for cutting a patch release for the previous version
when master already contains work that shouldn't be released yet; for
example, v0.63.1 had to be tagged and released by hand from a v0.63.1
branch off the v0.63.0 tag because the workflow could only release
master.

Scheduled runs are unaffected: with no input provided, the ref is
empty and the checkout falls back to the default branch.
2026-07-15 13:23:11 +02:00
Stefan Haller dda0af0f48 Allow having branch and tag with the same name
When creating a patch release from a branch called `v0.63.1`, the new
tag would get the same name and pushing it would fail with `error: src
refspec v0.63.1 matches more than one`.
2026-07-15 13:23:11 +02:00
Stefan Haller a65d468cd3 Determine the latest tag from the checked-out commit's history
The Get Latest Tag step used to pick the most recently created tag in
the entire repo, regardless of whether it is reachable from the commit
being released. In preparation for supporting releases from branches
other than master, use the nearest tag that is an ancestor of the
checked-out commit instead. This way, a patch release cut from an
older release branch bumps that branch's own latest tag even when
master already carries a newer release, and the "changes since last
release" check compares against the release that actually precedes
this one in history.
2026-07-15 10:56:56 +02:00
Stefan Haller 4c78076730 Fix a deadlock on Windows when switching between longer diffs (#5815)
The Windows PTY support that was newly introduced in v0.63.0 had a
potential deadlock problem: when switching between longer diffs, lazygit
could lock up. This should hopefully be fixed with this PR.
2026-07-15 10:32:19 +02:00
Stefan HallerandClaude Fable 5 f116874f0a Fix a deadlock when a Windows pty task is stopped mid-output
winPty.Close could block indefinitely, and it is called while holding
the global PtyMutex and while the task's onDone sync.Once is
executing, so blocking there wedges the task's entire cleanup chain:
the next NewTask call blocks on <-notifyStopped while holding
waitingMutex, every later task for that view queues up behind it, and
onResize blocks on PtyMutex — a full UI freeze. (Reported by a user
via go-deadlock's 30s watchdog; a regression from the ConPTY support
introduced for v0.63.0.)

ClosePseudoConsole is what blocks; before Windows 11 24H2 it can do
so in two ways. It flushes the client's pending output into the out
pipe, but a stopped task's scanner goroutine has already quit
draining, so with a client that's still producing output the flush
never completes; this can also wedge the background waiter's
closeHpc, which runs with the pipes deliberately left open. And it
waits for the console host to exit, but closing only delivers
CTRL_CLOSE_EVENT to the attached client without terminating it, so a
client that keeps running (git still computing an expensive diff, a
pager waiting for input) keeps the host alive arbitrarily long.

Run the teardown on a background goroutine so Close returns
immediately no matter which of these strikes, and within it close our
pipe ends before the pseudoconsole, without taking p.mu: breaking the
pipes fails a pending flush fast, which also unblocks a waiter
already stuck in one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 10:31:11 +02:00
Stefan Haller c2489e1c13 Fix userEvents panic (#5793)
In #5756 we changed the userEvents channel to a fixed 256-slot channel
with a non-blocking send that panicked when the channel was full. It
turns out that this panic can happen in real use:

- Toggling a directory of several hundred files into a custom patch
(reliably): the operation runs on a worker behind a waiting status,
whose spinner enqueues a content-only render on every tick, and over the
long operation these outrun the UI loop and overflow the buffer.
- Editing the config in an editor that suspends lazygit: the editor
subprocess runs on the UI thread, so the loop drains nothing for the
whole editing session, and the full refresh fired on resume fans out
across every scope at once — a burst of updates that overflows before
the just-resumed loop catches up.
- Any time the UI thread blocks for a long time, the periodic refreshes
keep enqueuing and eventually overflow.

The 256-slot buffer was chosen deliberately, with the panic as a "should
never happen" guard, to preserve two properties: FIFO ordering of
same-goroutine Update calls (an earlier goroutine-per-Update design
reordered them), and no self-deadlock (a blocking send from the UI
thread would block against the loop that drains it). But a fixed channel
can only offer those by crashing on overflow.

Replace it with an unbounded, order-preserving queue: a mutex-guarded
slice plus a buffered(1) doorbell channel that wakes the main loop's
select. Enqueuing appends and rings the doorbell; the loop drains the
slice to empty on each wake. This keeps FIFO order and never blocks the
caller, so there is no self-deadlock and no overflow to panic on — under
a stall the queue just grows and then drains.

Fixes #5772.
2026-07-15 10:18:51 +02:00
Stefan HallerandClaude Opus 4.8 f0b139f3ab Log the user-event queue's high-water mark
Now that the queue is unbounded, its depth is a useful signal for
understanding how the event loop behaves under load — and we expect it
to look very different across builds (e.g. master, which carries the
bounce-state-updates-to-ui-thread work, versus the v0.63.0 release this
fix ships in). Track the deepest the queue has ever been and log an Info
line whenever that record is broken, so the numbers show up in the log
for later reasoning. The mark is session-wide and doesn't reset when the
queue drains.

gocui has no logger of its own, so it exposes the new depth through a
handler (matching the existing SetFocusHandler / SetOpenHyperlinkFunc
pattern) that the gui registers to log via its own logger.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 10:14:05 +02:00
Stefan HallerandClaude Opus 4.8 49eefbcf37 Make the user-event queue unbounded
Update and friends enqueued onto a fixed 256-slot channel with a
non-blocking send that panicked when the channel was full. That guard
was firing in real use:

 - Toggling a directory of several hundred files into a custom patch
   (reliably): the operation runs on a worker behind a waiting status,
   whose spinner enqueues a content-only render on every tick, and over
   the long operation these outrun the UI loop and overflow the buffer.
 - Editing the config in an editor that suspends lazygit: the editor
   subprocess runs on the UI thread, so the loop drains nothing for the
   whole editing session, and the full refresh fired on resume fans out
   across every scope at once — a burst of updates that overflows before
   the just-resumed loop catches up.
 - Any time the UI thread blocks for a long time, the periodic refreshes
   keep enqueuing and eventually overflow.

The 256-slot buffer was chosen deliberately, with the panic as a
"should never happen" guard, to preserve two properties: FIFO ordering
of same-goroutine Update calls (an earlier goroutine-per-Update design
reordered them), and no self-deadlock (a blocking send from the UI
thread would block against the loop that drains it). But a fixed
channel can only offer those by crashing on overflow.

Replace it with an unbounded, order-preserving queue: a mutex-guarded
slice plus a buffered(1) doorbell channel that wakes the main loop's
select. Enqueuing appends and rings the doorbell; the loop drains the
slice to empty on each wake. This keeps FIFO order and never blocks the
caller, so there is no self-deadlock and no overflow to panic on — under
a stall the queue just grows and then drains.

This also removes an inconsistency: updateContentOnly did a plain
blocking send while update panicked, so the two paths disagreed on what
happened when the queue was full. Both now share the same enqueue.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 10:14:05 +02:00
Stefan Haller 9f51f044fa Improve index.lock retry mechanism (#5788)
In v0.63.0 we made a change to no longer use `GIT_OPTIONAL_LOCKS=0` on
git commands that are part of a "foreground" refresh, meaning the
refresh after a lazygit command or the focus-in refresh. We do this on
purpose to keep git's mod date cache from becoming stale, which could
make lazygit become slower over time. However, this caused a problem for
users who work very fast: staging a file and then immediately pressing
shift-A to amend while the staging's refresh is still running would show
the dreaded index.lock error.

We already had a retry-on-index-lock-error mechanism in place, but it
wasn't used for commands like amend or commit; fix this so that the
retry loop works for these too, and also make the retry window a little
longer, and fix a problem where it wouldn't work in linked worktrees or
submodules.

Closes #5778.
2026-07-15 10:12:51 +02:00
Stefan HallerandClaude Opus 4.8 4052057eee Back off exponentially between lock-error retries
The retry budget was five fixed 50ms waits (250ms total). A foreground
`git status` refresh can hold index.lock for longer than that on a large
repo, so the retries could be exhausted before the lock clears. Wait 20ms
before the first retry and double each time, giving seven attempts over a
bit more than a second — enough to outlast a slow refresh while keeping
the common case (a lock that clears almost immediately) fast. The initial
delay is now a runner field so tests can zero it out instead of sleeping.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 10:08:44 +02:00
Stefan HallerandClaude Opus 4.8 e3ecb77939 Recognize index.lock contention in worktrees and submodules
The retry check matched the literal ".git/index.lock", which only ever
appears for the main worktree. A linked worktree's lock is at
.git/worktrees/<name>/index.lock and a submodule's is under its own git
dir, so contention there was never retried. Match the bare "index.lock"
fragment instead, which covers all of them.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 10:08:44 +02:00
Stefan HallerandClaude Opus 4.8 c1cd500fa7 Retry lock errors reported only through the command's error
Have isRetryableError also inspect the returned error, not just the
captured output. Streamed commands (amend, commit, and other operations
run through the gpg helper) don't capture output, so their index.lock
failures were slipping past the retry loop and surfacing to the user as
a hard "Git command failed". Now they retry like every other command.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 10:08:44 +02:00
Stefan HallerandClaude Opus 4.8 0902c5c058 Demonstrate that a lock error in a streamed command isn't retried
The gpg helper runs commands like amend with StreamOutput, so their
output isn't captured and a failed run returns an empty output string;
the index.lock message is carried by the error instead. isRetryableError
only inspects the output, so the retry loop never fires for these
commands. In practice this means a `shift-A` amend issued while a
foreground `git status` refresh briefly holds index.lock fails outright
instead of retrying.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 10:08:44 +02:00
Stefan HallerandClaude Opus 4.8 e90daaf812 Unify the git command lock-retry loops
RunWithOutput and RunWithOutputs each carried their own near-identical
copy of the index.lock retry loop. Extract the loop into a single
retryOnLockError helper so the retry policy lives in one place, ahead of
changing that policy. Behavior is unchanged; the added tests characterize
it (success and non-lock errors run once, a lock error in the output is
retried).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 10:08:44 +02:00
Stefan Haller bb2d6e8bbd Clarify contribution policy (#5809) 2026-07-14 14:58:32 +02:00
Stefan Haller 50122e6886 Don't invite for contributions at startup 2026-07-14 14:54:56 +02:00
Stefan Haller 76ad5a3552 Clarify the contribution policy 2026-07-14 14:54:40 +02:00
Stefan Haller bea025f5b7 Fix potential deadlock when switching repos (#5797)
See commit messages for details.

Labelling as ignore-for-release because it fixes a regression that was
introduced since the last release.
2026-07-10 17:22:21 +02:00
Stefan HallerandClaude Fable 5 3a0ba6bf4d Fix data race on the triggerFetch field
startBackgroundFetch assigned the field from its own goroutine, and
only after the initial fetch had completed, while the UI thread reads
it in triggerImmediateFetch on every repo switch, with no
synchronization.

Create the channel in startBackgroundRoutines instead, which runs on
the UI thread before the fetch goroutine is spawned; everything the UI
thread does afterwards is ordered after the write, so the read is
race-free without any locking. To make this possible, goEvery now
takes the retrigger channel as a parameter instead of creating and
returning it; callers that have no use for a retrigger channel pass
nil, and a nil channel in a select is simply never ready.

As a side effect, a repo switch that happens before the fetch loop has
started (during the intro popup or the initial fetch) now latches a
trigger and causes an immediate fetch once the loop is running, where
previously it was silently dropped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 17:16:46 +02:00
Stefan HallerandClaude Fable 5 58e121b933 Don't block the UI thread when triggering an immediate fetch on repo switch
Switching repos triggers an immediate background fetch by sending on
the goEvery retrigger channel. The send was blocking, but the goEvery
loop only receives between callbacks: while a fetch is in flight, it
waits for that fetch to finish before returning to its select. So a
repo switch that landed while a fetch was in flight would stall the UI
thread for the remainder of the fetch.

Worse, since worker refreshes capture state on the UI thread with a
blocking OnUIThreadAndWaitBackground call, the in-flight fetch's
post-fetch refresh can itself be waiting for the UI thread, turning
that stall into a deadlock cycle:

  UI thread: switchTo -> triggerImmediateFetch, blocking send
  goEvery loop: waiting for the in-flight fetch to finish
  fetch worker: PostFetchRefresh -> RefreshFromWorker, waiting for
    the UI thread

Make the send non-blocking, and give the channel a buffer of one so
that a trigger arriving while a fetch is in flight is latched rather
than dropped; that fetch is fetching the previous repo, so we still
need another one after it. The goEvery loop picks the trigger up as
soon as it returns to its select, and concurrent triggers coalesce.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 16:48:42 +02:00
Stefan Haller a61727cd5e Add a hint about how to use diff --color-words or --word-diff in lazygit (#5795)
Since this frequently comes up as a feature request (but there are
reasons why we don't want to add it), explain how to do this in lazygit
today.

See
https://github.com/jesseduffield/lazygit/pull/5784#issuecomment-4925324150.
2026-07-10 15:47:05 +02:00
Stefan Haller c81c08071f Add a hint about how to use diff --color-words or --word-diff in lazygit
Since this frequently comes up as a feature request (but there are
reasons why we don't want to add it), explain how to do this in lazygit
today.
2026-07-10 15:43:28 +02:00
Stefan Haller e59c1d1cb7 Make model<->view index conversions independent of rendering (#5785)
The model<->view index conversions were derived from arrays that only
renderLines populated. That made them depend on the list having been
rendered (so a conversion before the first render ignored the non-model
items), and it made them go stale whenever the model changed after a
render: converting an index then returned a wrong result, and once the
model had grown past the last rendered length the conversion indexed a
too-short array and panicked (seen in cherry_pick under -race).

The conversion is a pure function of the current list length and the
current non-model items, and needs none of the rendered display strings.
Compute it directly and drop the cached arrays, so the result is always
consistent with the current model and no longer depends on rendering.
2026-07-09 15:10:31 +02:00
Stefan HallerandClaude Opus 4.8 4e907c6b3e Compute list index conversions independently of rendering
The model<->view index conversions were derived from arrays that only
renderLines populated. That made them depend on the list having been
rendered (so a conversion before the first render ignored the non-model
items), and it made them go stale whenever the model changed after a
render: converting an index then returned a wrong result, and once the
model had grown past the last rendered length the conversion indexed a
too-short array and panicked (seen in cherry_pick under -race).

The conversion is a pure function of the current list length and the
current non-model items, and needs none of the rendered display strings.
Compute it directly and drop the cached arrays, so the result is always
consistent with the current model and no longer depends on rendering.

searchModelCommits converts every commit's index, and building the
non-model items can be O(len) mid-rebase, so it would now be quadratic;
snapshot the non-model items once via modelToViewIndexConverter instead
of rebuilding them per index.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 14:54:15 +02:00
Stefan HallerandClaude Opus 4.8 d4a606c685 Demonstrate that list index conversions depend on rendering
ModelIndexToViewIndex and ViewIndexToModelIndex read conversion arrays
that only renderLines populates. So converting an index before the list
has been rendered ignores the non-model items (e.g. section headers) and
returns a wrong result; the same staleness makes a conversion after the
model has grown index a too-short array and panic (seen in cherry_pick
under -race).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 14:54:15 +02:00
Stefan Haller d94ca63e6d Synchronize ViewBufferManager.Close with a starting task (#5786)
Fixes a race condition related to ViewBufferManager's stopCurrentTask
field.
2026-07-09 14:53:45 +02:00
Stefan HallerandClaude Opus 4.8 c21ce61729 Synchronize ViewBufferManager.Close with a starting task
Close read and called stopCurrentTask with no lock, while NewTask's
goroutine assigns it (and constructs the sync.Once it closes over) under
waitingMutex. On shutdown Close runs while a render task spawned by the
last layout is still starting, so the two raced on the field and the
once (three DATA RACE blocks under -race, e.g. cherry_pick).

Read stopCurrentTask once under waitingMutex and call the captured value
instead of re-reading the field, which establishes the happens-before
the once needs. This can't deadlock: no task holds waitingMutex across a
blocking UI-thread hop, so Close can always take it, and a task wedged in
such a hop is still bounded by the existing 3s timeout.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 14:28:29 +02:00
Stefan Haller bda505148b Make integration tests using commits more robust (#5782)
Some tests assert that a specific commit subject does or doesn't occur
in the main view; interactive_rebase/outside_rebase_range_select.go is
an example for this, it asserts `t.Views().Main().Content(
DoesNotContain("commit 06"))`. The problem with this kind of assertion
and our test commit naming scheme is that the diff view begins with a
"commit <hash>" line, and when that hash happens to start with "06" the
assertion matched it and failed spuriously. This was usually masked by
our MaxAttempts=2 that we currently use for integration tests (it's
quite unlikely that the commit gets a hash beginning with "06" twice in
a row). However, we want to get to a state where we can set MaxAttempts
to 1, so make this more robust by changing our naming scheme.
2026-07-09 12:02:25 +02:00
Stefan Haller d181615c31 Make integration tests using commits more robust
Some tests assert that a specific commit subject does or doesn't occur
in the main view; interactive_rebase/outside_rebase_range_select.go is
an example for this, it asserts `t.Views().Main().Content(
DoesNotContain("commit 06"))`. The problem with this kind of assertion
and our test commit naming scheme is that the diff view begins with a
"commit <hash>" line, and when that hash happens to start with "06" the
assertion matched it and failed spuriously. This was usually masked by
our MaxAttempts=2 that we currently use for integration tests (it's
quite unlikely that the commit gets a hash beginning with "06" twice in
a row). However, we want to get to a state where we can set MaxAttempts
to 1, so make this more robust by changing our naming scheme.
2026-07-09 11:56:27 +02:00
Stefan Haller 8d6d1f0908 Make scrolling down a very long diff with the scroll wheel much smoother (#5780)
When showing a very long diff (thousands of lines), scrolling down with
the mouse wheel was rather choppy; now it's very smooth and fast.
2026-07-09 09:51:15 +02:00
Stefan HallerandClaude Opus 4.8 585c7f126d Cache each line's wrapping so scrolling doesn't re-wrap the whole buffer
refreshViewLinesIfNeeded re-wrapped every line of the buffer whenever
the view was tainted. That's cheap for short content, but scrolling a
long diff calls it constantly: adjustDownwardScrollAmount queries
ViewLinesHeight on every scroll event, and each newly-read line taints
the view, so every notch re-wrapped the entire buffer. Wrapping measures
each cell's width (uniseg) and allocates per line, so once you'd scrolled
far enough down the diff, scrolling turned sluggish - the cost grew with
how much had been read. (A CPU profile of scrolling deep in a long diff
put 77% of the time in lineWrap, reached almost entirely via
ViewLinesHeight rather than draw.)

Cache each line's wrapped result on the lineType, keyed by the width it
was wrapped at, and only re-wrap lines that have actually changed since
the last refresh. A firstDirtyLine index, updated in the same three
places that set `tainted` (write, clearViewLines' callers, SetHighlight),
marks the lowest line that might have changed; lines below it with a
matching cached width reuse their cached wrapping. The cache lives on the
line, so it's freed with the line when the view's content is replaced
(e.g. selecting a different commit) - it doesn't accumulate across a
session.

The wrapping cost per scroll now scales with the number of lines just
read, not with the total size of the buffer, so scrolling stays smooth
no matter how far down you are.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 09:32:35 +02:00
Stefan HallerandClaude Opus 4.8 73d7b443ec Render content-only when a task reads more lines into a view
Reading more lines into a lazy-loaded view (e.g. a diff being scrolled)
never changes the window layout, and after the first screenful it
doesn't even change the visible content - the new lines land below the
viewport, so the only thing that changes on screen is the scrollbar
thumb. Yet each read triggered a full render: a layout pass plus a
redraw of every view. On a slow terminal that full-screen repaint on
every read is a big part of why scrolling through a not-yet-fully-read
diff stutters.

Route the task's refresh through a content-only render instead. It
skips the layout pass and only redraws the views whose content changed,
leaving tcell's cell-level dirty tracking to emit just the cells that
actually differ (in the steady state, the scrollbar column).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 09:32:35 +02:00
Stefan HallerandClaude Opus 4.8 cbf220c497 Read lines based on scroll position instead of a fixed per-notch delta
When scrolling a lazy-loaded view (a diff in the main view, the command
log, etc.), we top up the view's line buffer by reading more lines from
the still-running task. This was driven by asking the task to read a
fixed number of *additional* lines on every scroll event, which had two
problems:

- It was decoupled from the scroll position. Scrolling down, back up,
  and down again re-read lines that had already been read, so the buffer
  crept towards the end of the input regardless of where the user
  actually scrolled.

- A single wheel notch only bought a single notch worth of runway, so
  fast scrolling constantly outran the reader and had to wait for the
  next read (and re-render) on every notch.

Make ReadLines take an absolute target total instead of a delta: the
task tracks how many lines it has read and only reads the shortfall, so
requests are idempotent. Callers now ask to fill the viewport at the
current scroll position plus a few screenfuls of read-ahead, which gives
scrolling enough runway to stay smooth.

The four call sites all wanted the same "fill this view" computation, so
consolidate them into a single ReadLinesToFillView helper.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 09:32:35 +02:00
Stefan Haller 3491a15f6e Fix data race with command log (#5779)
LogAction and LogCommand are called from git worker goroutines (every
command a worker runs logs itself, and controllers log an action before
kicking off their worker), where they set the Extras view's Autoscroll
flag and append to GuiLog while the UI thread reads both when it lays
out and draws the view. Bounce the writes onto the UI thread instead.

This doesn't fix any user-visible issue that I know of; labelling it as
"maintenance" rather than "bug" for that reason. It is one of many steps
that gets us closer to running our test suite with `-race`.
2026-07-09 08:45:59 +02:00
Stefan HallerandClaude Opus 4.8 1268a589d6 Write the command log on the UI thread
LogAction and LogCommand are called from git worker goroutines (every
command a worker runs logs itself, and controllers log an action before
kicking off their worker), where they set the Extras view's Autoscroll
flag and append to GuiLog while the UI thread reads both when it lays out
and draws the view. Bounce the writes onto the UI thread instead.

Use the background variant so the bounce doesn't count towards lazygit
being busy: writing the command log is incidental display work, and a
foreground task would let an in-flight log write refuse a concurrent repo
switch (the same reason view-buffer renders and toasts are backgrounded).
Ordering between successive log calls is preserved by the bounce FIFO.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 08:41:10 +02:00
Stefan Haller 73714a3b38 Fix data race with status string (#5777)
GetStatusString and HasStatus read the statuses slice without holding
the mutex that addStatus and removeStatus take when they mutate it. The
readers run on the spinner-render worker (which polls GetStatusString
every frame) while removeStatus fires from the waiting-status and
toast-expiry goroutines, so the unguarded reads race the concurrent
writes. Take the mutex in the readers too.

This doesn't fix any user-visible issue that I know of; labelling it as
"maintenance" rather than "bug" for that reason. It is one of many steps
that gets us closer to running our test suite with `-race`.
2026-07-09 07:06:46 +02:00
Stefan HallerandClaude Opus 4.8 2420fc7b76 Lock the status list when reading it
GetStatusString and HasStatus read the statuses slice without holding
the mutex that addStatus and removeStatus take when they mutate it. The
readers run on the spinner-render worker (which polls GetStatusString
every frame) while removeStatus fires from the waiting-status and
toast-expiry goroutines, so the unguarded reads race the concurrent
writes. Take the mutex in the readers too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 18:24:18 +02:00
Stefan Haller fe4c195370 Perform refresh model and view updates on the UI thread instead of using mutexes (#5767)
Refresh workers do their git work on background goroutines and then
mutate the model (`Model().Commits`, `.Branches`, …) and re-render views
directly from those goroutines, racing the UI thread's own cursor and
render code. This has been a long-standing source of flaky integration
tests, and it's what prevents us from running the e2e suite under the
race detector.

This PR removes that class of races by updating refresh state only on
the UI thread, and drops the mutexes that were standing in for that
discipline. It's an internal concurrency change with no intended
difference in normal use (the one small user-facing addition is noted at
the end).

- Each refresh scope does its git work on a worker, then enqueues its
model write onto the UI thread ("bouncing") through a single primitive,
so all model mutations are serialized on the one UI goroutine alongside
the cursor/render code they used to race.
- That primitive is generation-guarded: if you switch repos while a
refresh is in flight, the queued write is dropped instead of being
applied to the new repo.
- The inputs a refresh worker reads (model fields, selection, modes) are
now captured on the UI thread up front, so the worker computes from an
immutable snapshot. Worker-issued refreshes use a dedicated entry point,
and a debug-only assertion checks that the entry point matches the
calling goroutine.
- A few flags written from workers are made atomic rather than bounced.
- All six refresh mutexes are removed as redundant; the branches mutex
is replaced by a small branch-load sequence guard so the recency-sorted
result still wins at startup.
- Repo switching now runs on the UI thread rather than a worker,
removing a race on the shared gui state.

This is one step toward being able to run the test suite under `-race`
in CI — the remaining view-buffer rendering races are left for a
follow-up.

The one user-facing addition: switching repositories while a foreground
git operation is still running is now refused with a toast, instead of
running the operation's remaining commands against the newly-switched
repo.
2026-07-07 18:14:58 +02:00
Stefan Haller 4d33d9df8b Mention the Then rule in AGENTS.md 2026-07-07 18:10:45 +02:00
Stefan HallerandClaude Opus 4.8 19b34851ff Guard the view-render and prompt-dismiss bounces on the generation
The model-update bounces already drop themselves when the repo is
switched mid-refresh (onUIThreadUnlessRepoChanged), but three bounces
that touch the UI without writing the model did not: refreshView's
render, the staging-panel refresh, and the stale continue-rebase prompt
dismissal. All three ran unconditionally on the UI thread, so a
background refresh in flight across a repo switch could render the old
repo's data (through a context object belonging to the now-replaced
context tree), or pop the new repo's popup based on the old repo's
prompt state.

Route them through onUIThreadUnlessRepoChanged too, so they're dropped
alongside the model writes they accompany. This also fixes the dismiss
bounce using the raw foreground OnUIThread, which ignored the background
flag every other bounce in a background refresh respects.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 18:10:45 +02:00
Stefan HallerandClaude Opus 4.8 2c3a6acafa Thread a refreshEnv through the refresh scopes
Every refresh scope needs two ambient values to bounce its model and
view updates back to the UI thread safely: the background flag (which
picks the dispatch variant that doesn't count towards lazygit being
busy) and the repo generation that guards the bounce against a repo
switch. These were threaded separately — background as a parameter on
every refreshXxx function, generation re-read from the model inside each
one. Bundle them into a single refreshEnv passed through instead, so the
guard has a home to grow into (the next commit needs the generation in
refreshView, which currently has no access to it).

Capturing the generation once, at the start of the refresh, is also more
correct than the previous per-function re-read. The baseline should
reflect the repo whose inputs the refresh snapshotted (all captured up
front on the UI thread), not whenever each scope's worker happens to
wake. With the per-function read, a background refresh whose worker woke
after a repo switch would read the new generation and let its bounce
through, writing data computed from the old repo's inputs into the new
repo; capturing up front makes that bounce drop instead.

No behavior change for foreground refreshes, where the UI thread is held
for the whole refresh and the generation can't move under it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 18:10:45 +02:00
Stefan Haller fefb3b632e Clear the preserved commit message on the UI thread
The commit's gpg onSuccess runs on a worker when the command output is
streamed, so its ClearPreservedCommitMessage wrote commit-message
context state off the UI thread. Bounce that write through OnUIThread.
2026-07-07 18:10:45 +02:00
Stefan Haller 12757e2723 Swap the file-path suggestions trie on the UI thread
GetFilePathSuggestionsFunc builds the trie on a worker (the slow
AllRepoFiles walk) and then assigned Model().FilesTrie and refreshed the
suggestions panel from there, racing the UI thread that reads the trie.
Keep the build on the worker but bounce just the model assignment and
the refresh through OnUIThread.
2026-07-07 18:10:45 +02:00
Stefan Haller 6cd93de5b9 Cancel the commit-file range selection on the UI thread after discard
The discard handler cancelled the commit-files range selection from its
WithWaitingStatus worker. Bounce it through OnUIThread, keeping it after
the successful CheckMergeOrRebase as before.
2026-07-07 18:10:45 +02:00
Stefan Haller e7105a3138 Collapse the branch range selection on the UI thread after a delete
The three branch-delete handlers and the two worktree-removal
continuations collapsed the Branches/RemoteBranches range selection from
their worker goroutine, racing the UI thread. Wrap each collapse in
OnUIThread, keeping it in the same spot relative to the refresh (FIFO
preserves the collapse-then-refresh order the name-restore depends on).
2026-07-07 18:10:45 +02:00
Stefan Haller 67b0a6b1a4 Move the pull-patch panel close and focus off the worker
The pull-patch-into-new-commit handlers closed the commit-message panel
and, on success, pushed the local-commits context from inside the
WithWaitingStatus worker. Close the panel in OnConfirm before
dispatching (UI thread), and bounce the post-rebase context push through
OnUIThread, keeping it on the success path.
2026-07-07 18:10:45 +02:00
Stefan Haller f07e94afe0 Keep RebaseOntoRef's marked-base access on the UI thread
The three rebase-onto menu items read Modes().MarkedBaseCommit.GetHash()
(a bare string field) and, on success, cleared it via
ResetMarkedBaseCommit and pushed the commits context — all from the
WithWaitingStatus worker, racing the UI thread. Read the marked base
hash before dispatching, and bounce the post-rebase reset and context
push through OnUIThread, still guarded by the success check so they
don't run on the conflict path.
2026-07-07 18:10:45 +02:00
Stefan Haller 5d8c893497 Capture commits and set selection on the UI thread for squash/fixup/drop
interactiveRebaseWithFlag and dropMergeCommit ran inside the
WithWaitingStatus worker but read Model().Commits and wrote the
selection (SetSelection(startIdx)) there, racing the UI thread. Thread
the commits slice in from each caller, and hoist the pre-rebase
selection into a UI-thread helper (selectRebaseResultCommit) called
before dispatching — squash/fixup unconditionally, drop only on the
non-merge path, matching the previous action guard.
2026-07-07 18:10:45 +02:00
Stefan Haller 6c38ddc9a7 Set ResetToRef's post-reset selection via refresh intents
ResetToRef ran on a worker and wrote the local-commits and reflog
selection directly (SetSelection(0) on both) before its refresh, racing
the UI thread. Fold those into the refresh's selection intents:
SelectHeadCommit for the commits (after a reset HEAD is the top commit,
and mid-interactive-rebase it correctly picks the real head over the
first todo entry) and SelectTopReflogCommit for the reflog. The
now-atomic SetLimitCommits stays where it is.
2026-07-07 18:10:45 +02:00
Stefan Haller 6d21efb515 Make the local-commits limit-commits flag atomic
CheckoutRef and ResetToRef set this flag from their worker goroutine
(to load fewer commits for speed) while the commits refresh reads it on
the UI thread in captureCommitsState to decide how many to load — a data
race. Make it an atomic.Bool so those writes are safe where they are,
rather than routing the flag through a refresh intent. Precedent:
Branch.BehindBaseBranch.
2026-07-07 18:10:45 +02:00
Stefan Haller 2edfeac538 Capture the commit-file discard and patch-toggle reads on the UI thread
discard reads Model().Commits and the selected commit index from its
WithWaitingStatus worker; read them in HandleConfirm instead.

toggleForPatch reads the commit-files ref name from the worker, and its
startPatchBuilder call reads the context's canRebase and diff range from
there too. Capture the ref name and run startPatchBuilder in
HandleConfirm before dispatching; PatchBuilder.Start only assigns
fields, so moving it off the worker changes no timing.

discard still collapses the range selection from the worker; that write
is a separate concern, left for a follow-up.
2026-07-07 18:10:45 +02:00
Stefan Haller 462d75232b Look up the submodule file and branch worktree on the UI thread
ResetSubmodule and fastForward each call a helper that reads the model
from inside their worker: FileForSubmodule reads Model().Files and
worktreeForBranch reads Model().Worktrees, racing the UI thread's model
writes. Hoist both lookups above the worker dispatch.
2026-07-07 18:10:45 +02:00
Stefan Haller fceba31212 Capture moveCommitsToNewBranch's model reads on the UI thread
The two move helpers run inside the WithWaitingStatus worker that
withNewBranchNamePrompt dispatches to, but read Model().Files/Submodules
(to decide whether to auto-stash) and Model().Commits (the unpushed
commits to cherry-pick off the base branch) from there, racing the UI
thread's model writes. Compute mustStash — needed by both paths — at the
top, and the unpushed commits in the off-of-main menu item, on the UI
thread, and pass them into the helpers.
2026-07-07 18:10:45 +02:00
Stefan Haller b4a976834f Capture reword/amend/author commit reads on the UI thread
handleReword, amendTo, and the reset/set/add-co-author handlers pass
Model().Commits (and the selected line index) to a git rebase from
inside the WithWaitingStatus worker, racing the UI thread's model
writes. Read them on the UI thread before dispatching.

The author handlers index the full commit list by absolute start/end, so
the range sub-slice withItemsRange hands amendAttribute is not what they
need; capture the full Model().Commits there and thread it through.
2026-07-07 18:10:45 +02:00
Stefan Haller eb95ae15f3 Capture the custom-patch handlers' commit reads on the UI thread
These handlers dispatch their rebase to a worker via WithWaitingStatus
but read Model().Commits (and, for move-to-selected-commit, the selected
line index) from inside that worker, racing the UI thread's model
writes. Read them on the UI thread before dispatching and close over the
results.

getPatchCommitIndex stays as-is: moving its call out of the worker makes
its own Model().Commits read UI-thread-bound too, so the identical copy
in patch_building_controller.go needs no matching signature change.

The two pull-patch-into-new-commit handlers still push a context and
close the commit-message panel from the worker; those writes are a
separate concern, left for a follow-up.
2026-07-07 18:10:45 +02:00
Stefan HallerandClaude Opus 4.8 5162a768eb Guard every refresh's entry point, not just the commits scope
With every scope's worker reads now captured on the UI thread and every
worker caller on RefreshFromWorker, the debug entry-point assertion no longer
needs to be scoped to the commits refresh. Move it to the top of
performRefresh so it guards every refresh regardless of which scopes it
touches, and drop the per-scope gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 18:10:45 +02:00
Stefan HallerandClaude Opus 4.8 fd6b20847a Capture the files, reflog, branches and stash refresh inputs
The remaining refresh scopes each still read model, context, and mode state
directly on their worker, racing the UI thread — the same class of race the
commits refresh had:

- files reads Model.Files (to detect resolved conflicts and drive the
  auto-stage) and the Files context's ForceShowUntracked;
- reflog reads the existing reflog slices (for the incremental fetch),
  Model.HashPool and the filtering path/author;
- branches reads Model.MainBranches and the previous branches (for the
  BehindBaseBranch carry-over);
- stash reads the filtering path.

Gather each scope's inputs into an immutable snapshot on the UI thread (via
captureOnUIThread) before dispatching the git work, and have the refresh
compute from the snapshot — for branches, threaded through both the immediate
and the recency-sorted startup loads, which share one snapshot (the
BehindBaseBranch carry-over is identical either way). Status, tags and
worktrees read nothing UI-owned, so they're left alone.

For the snapshots to actually run on the UI thread, the worker callers that
reach these scopes must announce themselves: convert the submodule
operations, the submodule stash-and-reset, and the background files poller
to RefreshFromWorker.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 18:10:45 +02:00
Stefan HallerandClaude Opus 4.8 3e5c99e1e4 Make the started-in-lazygit and startup-stage flags atomic
GuiRepoState.mergeOrRebaseStartedInLazygit and StartupStage are plain
fields, but they're written and read from worker goroutines: the former
from both the files refresh and the merge/rebase result path (which runs on
a worker for the async callers), the latter from the reflog/branches load as
it transitions the startup stage. Those are data races.

Make both atomic, like Branch.BehindBaseBranch. They're leaf flags, not
mutexes guarding model or view state, so an atomic is the natural fit and
keeps the merge/rebase result path out of this change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 18:10:45 +02:00
Stefan HallerandClaude Opus 4.8 56989922e4 Capture the remotes/sub-commits/commit-files/rebase-commits inputs
These four refreshes each read model, context, and mode state directly on
their worker — the same class of race the commits refresh had:

- remotes reads the selected remote (Contexts().Remotes.GetSelected), needed
  to keep the remote-branches selection valid;
- sub-commits reads the SubCommits ref/limit/divergence, the filtering
  path/author, and Model.MainBranches/HashPool;
- commit-files reads the diff endpoints (CommitFiles from/to and the diffing
  args);
- rebase-commits reads Model.HashPool/Commits.

Give each the same treatment as commits: gather its inputs into an
immutable snapshot on the UI thread (via captureOnUIThread, inline for a
UI-thread refresh, hopped for a worker one) before dispatching the git work,
and have the refresh compute from the snapshot. The commit-files re-init
inside the commits refresh captures its endpoints in the bounce, right after
ReInit sets them, before dispatching to the worker.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 18:10:45 +02:00
Stefan HallerandClaude Opus 4.8 988d04bda9 Assert a refresh uses the entry point matching its goroutine
Now that every commits-reaching refresh issued from a worker goes through
RefreshFromWorker, guard the choice: in debug builds, panic if a refresh was
issued from the UI thread as RefreshFromWorker or from a worker as Refresh.

The caller's own goroutine is recorded at the top of performRefresh, before
a BLOCK_UI refresh dispatches onto the UI thread, so the check holds for
every mode rather than being fooled by BLOCK_UI. It's scoped to the commits
refresh for now, the only converted scope; once the rest are converted the
guard can move up to cover every refresh unconditionally.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 18:10:45 +02:00
Stefan HallerandClaude Opus 4.8 558fd2c9d3 Route merge/rebase result handling to the right refresh entry point
CheckMergeOrRebaseWithRefreshOptions refreshes after a merge/rebase step,
and until now always via the UI-thread Refresh. Most of its callers are on a
worker (the WithWaitingStatus/WithInlineStatus merge, squash-merge, rebase,
pull, amend, drop, and patch-move handlers), so that refresh reads the
commits scope off the UI thread — the race the previous commit addresses for
everything else.

Split it: the default is for worker callers and refreshes via
RefreshFromWorker; a new CheckMergeOrRebaseWithRefreshOptionsFromUIThread is
for the handlers that run the step synchronously on the UI thread
(WithWaitingStatusSync, kept sync so rapid key presses batch): move up/down,
revert, squash-fixups, cherry-pick paste, and patch-discard.

The two share a private impl carrying which thread the caller is on, and the
auto-skip recursion (genericMergeCommandImpl for an empty commit) threads it
through so the follow-up step refreshes on the same thread. The
merge-and-commit refresh in SquashMergeCommitted, also on a worker, moves to
RefreshFromWorker to match.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 18:10:45 +02:00
Stefan HallerandClaude Opus 4.8 080542c9fb Capture the commits refresh's inputs on the UI thread
A commits refresh does its git work on a worker and then reads the model,
the contexts, and the modes for that work directly from there:
LocalCommits.GetSelectionRangeAndMode/GetLimitCommits/GetShowWholeGitGraph,
Model.Commits/MainBranches/HashPool, the filtering path/author. Those are
owned by the UI thread, which is concurrently running the cursor and render
code, so the reads race it — the dominant, confirmed source of the
commits-scope flakes (the startup ClampSelection vs GetSelectionRangeAndMode
race, for one).

Gather them into an immutable capturedCommitState on the UI thread, before
the git work is dispatched, and have refreshCommitsWithLimit compute from
that snapshot. UI-thread callers capture inline; worker callers can't (a
SYNC/BLOCK_UI refresh parks the UI thread at wg.Wait, so hopping from a
scope sub-worker would deadlock), so the capture is lifted out of the scope
worker into the refresh orchestration, and worker callers announce
themselves with a new RefreshFromWorker entry point that hops the capture to
the UI thread and blocks for it (OnUIThreadAndWait). BLOCK_UI runs the whole
refresh on the UI thread regardless of the caller, so it captures inline
too.

Every refresh issued from a worker that reaches the commits (or branches,
which pulls in commits) scope is converted: the fast-forward, branch/tag
delete, worktree remove/detach, push, reword-via-rebase, author edits,
custom-command, hard-reset-with-autostash, reset-to-ref, fetch-and-checkout,
gpg-stream, post-fetch, and external-change-poller refreshes, plus the
branch checkout and move-commits-to-new-branch refreshes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 18:10:45 +02:00
Stefan HallerandClaude Opus 4.8 1def541acb Add IsUIThread and OnUIThreadAndWait to gocui
The next commits move refresh workers to read UI-thread-owned state (the
model, contexts, selection) on the UI thread rather than off it. Two
primitives support that:

- OnUIThreadAndWait runs a function on the main event loop and blocks the
  caller until it has run, so a worker can read that state without racing.
  OnUIThreadAndWaitBackground is the same for background routines, whose
  work must not count towards the program being busy.
- IsUIThread reports whether the caller is on the main event loop, for a
  debug-only assertion that a refresh was issued from the thread it claims.
  It records the main loop's goroutine id in MainLoop and compares via
  goid, so it's promoted from an indirect to a direct dependency.

goid is used only by that debug assertion, never to drive production
control flow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 18:09:33 +02:00
Stefan HallerandClaude Opus 4.8 f0ea537956 Fetch pull requests using the freshly-loaded branches and remotes
The PR fetch needs the current branches (for their upstreams) and
remotes to know what to query. It read them from Model().Branches /
Model().Remotes on its own worker, after waiting on branchesAndRemotesWg
for the branches and remotes refreshes to finish.

That wait no longer guarantees fresh data: those refreshes now write the
model in a bounce onto the UI thread, and Done() fires before the bounce
has been processed. So the fetch read the pre-refresh lists — most
visibly, checking out a branch that has a PR wouldn't show that PR until
the next refresh, because the fetch queried the old branch set.

Have refreshBranches / refreshReflogAndBranches / refreshRemotes return
what they loaded, stash it in locals in Refresh, and hand it to the
fetch. The wait on branchesAndRemotesWg orders the fetch after both
loads have stored their slices, so it fetches against exactly the
branches and remotes that were just loaded, with no model read on the
worker. The previous commit guarantees both are always in scope when
pull requests are, so no fallback is needed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 18:09:33 +02:00
Stefan HallerandClaude Opus 4.8 23cfa9b070 Also refresh branches and remotes when refreshing pull requests
The pull-request fetch queries GitHub for the tracking branches'
upstreams against the configured remotes. It therefore depends on the
branches and remotes being up to date; a refresh that asks for pull
requests but not for those (e.g. checking out a branch) would fetch
against a stale branch/remote list — for instance missing the PR of the
branch just checked out.

Expand the scope so pull requests always co-refresh branches and
remotes. This also sets up the next commit to hand the freshly-loaded
branches and remotes straight to the fetch, instead of reading them
back from the model (which, now that those writes are bounced onto the
UI thread, would be stale on the worker).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 18:09:33 +02:00
Stefan HallerandClaude Opus 4.8 7c4d8045f9 Clamp the commit-file tree selection when the tree is rebuilt
CommitFileTreeViewModel embedded the low-level tree's SetTree, which
rebuilds the node list without touching the cursor. So after a shrinking
rebuild (e.g. moving a patch out into the index removes a file), the
selection index could be left past the end of the tree. GetSelectedItems
then indexes out of range and returns a nil node, which segfaults callers
such as canEditFiles when the options map is rendered during layout.

Override SetTree to ClampSelection after the rebuild. Unlike
FileTreeViewModel we deliberately don't also re-find the selected node by
path: that walk lands on the containing directory when a file is removed
from a dir that then collapses, whereas keeping the clamped index lands
on the sibling file (see discard_old_file_changes).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 18:09:33 +02:00
Stefan HallerandClaude Opus 4.8 bd6081d601 Select the checked-out branch via a refresh intent, not off-thread
Operations that check something out (checkout, create branch, move
commits to a new branch, fetch-and-checkout) selected the newly
checked-out branch by calling SelectFirstBranchAndFirstCommit() before
the refresh and passing KeepBranchSelectionIndex so the refresh wouldn't
override it. That set the selection directly, usually from a worker
goroutine (WithWaitingStatus/WithInlineStatus). Now that the refresh's
own selection write is bounced onto the UI thread, the two writes could
land in either order, and under load the refresh's "restore the
previously-selected branch" write would win — leaving the old branch
selected instead of the new one (flaky
move_commits_to_new_branch_from_base_branch).

Replace it with declarative selection intents applied inside the
refresh's own bounce, so the selection is set on the UI thread and
atomically with the list write (no off-thread write, and no BLOCK_UI
needed to avoid a flicker):

- BranchSelection: SelectCheckedOutBranch selects the checked-out branch
  (top of the list). The default, KeepBranchSelectionByName, restores
  the previously-selected branch by name as before. This replaces the
  KeepBranchSelectionIndex bool.
- CommitSelection: SelectHeadCommit (already existed) for the commit.
- SelectTopReflogCommit selects the top reflog entry, since a checkout
  adds a new entry there (reflog/checkout relies on this).

SelectFirstBranchAndFirstCommit is gone. The previously-selected branch
is now read at the top of the branches bounce, before the list is
overwritten, so that read moves onto the UI thread too.

fetchAndCheckout's refresh changes from ASYNC to SYNC so its
post-refresh focus switch can run in Then on the UI thread; SYNC keeps
the inline fetch spinner spinning (only BLOCK_UI would freeze it).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 18:09:33 +02:00
Stefan HallerandClaude Opus 4.8 5414daf492 Exclude toast rendering from the busy query
A toast keeps a foreground spinner task alive for its whole lifetime
(~2-4s): showing one calls renderAppStatus, whose OnWorker loop runs
until the status string clears. With the repo-switch guard in place that
made the guard's own "can't switch, operation in progress" toast keep
Busy() true, so the next escape/switch was refused until the toast
faded — you had to wait it out.

Render toasts in the background, like view-buffer content: a toast is a
transient notification, not lazygit driving an operation, so a switch
during one is fine. A real operation that shows a toast still keeps its
own foreground task busy independently.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 18:09:33 +02:00
Stefan Haller 56932abe06 Refuse a repo switch while a foreground operation is in flight
Switching repos reassigns gui.git and the process cwd; doing it while a
foreground git operation (rebase/commit/push/…) is mid-flight would run
that operation's remaining commands against the wrong repo. The same
applies while the refresh an operation triggers is still settling: its
model writes are generation-guarded, but the client-side Then/OnUIThread
callbacks that run after it aren't, and shouldn't run against a repo that
changed underneath them.

Refuse the switch (with a toast) whenever gocui reports a busy foreground
task. DispatchSwitchTo carries the guard for the simple callers. The
callers that do work before the switch check up front instead, so a
refused switch doesn't leave that work half-done: worktree creation
checks before creating (its own waiting-status spinner would otherwise
make the query busy and refuse its own switch); submodule-enter and the
recent-repos menu check before mutating the repo-path stack (pushing /
clearing it); and escape-to-parent (SwitchToParentRepo) checks before
popping it, so a refusal doesn't consume the entry and strand the user
with nowhere to escape back to. All then call the unguarded switchTo,
which is safe because their own operation is complete by then.
2026-07-07 18:09:33 +02:00
Stefan HallerandClaude Opus 4.8 8655d3f5a5 Exclude view-buffer render tasks from the busy query
The repo-switch busy query must not count view-buffer content rendering:
those tasks paint a view rather than drive a git operation, so leaving
one running across a switch is harmless (the switch's own refresh
re-renders). More importantly, they fire on nearly every focus/selection
change — including the context activation that runs right before a
menu/prompt confirmation handler (e.g. confirming worktree creation).
A synchronous busy check in such a handler would otherwise see that
render and make the very switch the handler is about to request refuse
itself.

Route ViewBufferManager's tasks through a new gocui NewBackgroundTask so
they're tracked for idle detection but excluded from the busy query. The
task "background" flag now covers two kinds of non-blocking work: the
background routines (and their refreshes) tagged earlier, and view
rendering.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 18:09:33 +02:00
Stefan HallerandClaude Opus 4.8 d95900ccd0 Tag background routines and their refreshes as background tasks
For the busy query to be usable as a repo-switch guard it has to be
false while the ongoing background routines run, or a switch would be
refused every time a background fetch or files refresh happened to be in
flight. Mark that work as background so it's excluded from the query.

The background routine dispatch in goEvery becomes OnWorkerBackground,
and the auto-fetch waiting status renders its spinner through the
background variants. Within a refresh, the background flag (which
Refresh already carries as options.Background, and which the files path
already threaded) is now threaded through every place that enqueues a
task: the async scope workers, the model-write bounces
(onUIThreadUnlessRepoChanged), refreshView, the staging bounce, the
Then dispatch, and the branch-loader's behind-count worker. Two
single-caller chains reached by a background files refresh get the flag
too: MergeConflictsHelper.EscapeMerge and BranchesHelper.
AutoForwardBranches (whose follow-up refresh must stay background when
triggered by the background fetch).

Nothing gates on the busy query yet, so this is behavior-preserving;
background tasks still count as busy for the test idle-listener, which
looks at every task regardless of the background flag.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 18:09:33 +02:00
Stefan HallerandClaude Opus 4.8 e352cafd43 Add background tasks and a synchronous busy query to gocui
Repo-switch safety needs to answer, synchronously on the UI thread,
"is any foreground work in flight right now?" so it can refuse a switch
that would run against a repo about to be swapped out. gocui already
tracks a task per OnWorker/Update for the test idle-listener; extend
that.

Tasks gain a background flag: background tasks (the ongoing routines
like auto-fetch, and the refreshes they trigger) don't count towards
busy, because their model writes are already guarded against a
concurrent switch by the repo generation. Add OnWorkerBackground,
UpdateBackground and UpdateContentOnlyBackground (plus the gui-layer
OnUIThreadBackground / OnUIThreadContentOnlyBackground / OnWorkerBackground
on IGuiCommon) so the few background call sites can opt in without
touching the hundreds of foreground callers.

TaskManager.hasBusyForegroundTaskExcept answers the query; Gui.Busy()
wraps it, excluding the event currently being processed (recorded as
currentTask) so a handler asking the question doesn't count itself.

Nothing gates on Busy() yet; this is the mechanism only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 18:09:33 +02:00
Stefan HallerandClaude Opus 4.8 cf7c3d82e6 Run the repo switch on the UI thread
DispatchSwitchTo wrapped its whole body in WithWaitingStatus, so the
switch ran on a worker: it chdirs, reassigns gui.git, and swaps gui.State
(in resetState), all of which the UI thread also reads. The generation
guard prevents the refresh-in-flight logical corruption but not this
pointer data race on gui.State.

Run the switch synchronously on the UI thread instead. Every caller is
already a UI-thread handler except NewWorktreeCheckout, which must create
the worktree (git work) on a worker first; it now dispatches only the
switch via OnUIThread. The heavy data loading still happens
asynchronously via the refresh that onNewRepo triggers, so the
synchronous part is small (a couple of git rev-parse plus direnv).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 18:09:33 +02:00
Stefan HallerandClaude Opus 4.8 3103fe97ea Replace RefreshingBranchesMutex with a branch-load sequence guard
This removes the last refresh mutex. RefreshingBranchesMutex wasn't
guarding a data race (Branch.BehindBaseBranch is atomic, and every model
write is now bounced onto the UI thread); it was serializing the two
branch loads that race at the INITIAL startup stage — an immediate one
sorted without the reflog, and an async one that loads the reflog and
sorts by recency — so that the recency-sorted write landed last and won.
That serialization was never a real guarantee, only "very likely": it
relied on the immediate load acquiring the lock before the async load,
which had to load the reflog first.

Instead, each branch load takes a monotonically increasing sequence
number, and its bounce drops the write if a later-started load has
already applied. Combined with the preceding commit (immediate load runs
before the async one is spawned), this is an actual guarantee: the
immediate non-recency load always has a lower sequence than its recency
async partner, so the highest sequence number is always held by a
recency-sorted load, and highest-wins converges on recency ordering —
even if more refreshes fire during the INITIAL window, since each
refresh's async out-sequences its own immediate.

The guard also subsumes what the mutex gave post-startup: a slow, stale
refresh's bounce can no longer clobber a newer refresh's branches.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 18:09:33 +02:00
Stefan HallerandClaude Opus 4.8 6d8ab1d063 Run the immediate startup branch refresh before spawning the async one
At the INITIAL startup stage two branch refreshes happen: an immediate
one sorted by whatever reflog we have (empty, so not by recency), and an
async one that loads the reflog first and re-sorts by recency. Until now
the async one was spawned first and the immediate one ran afterwards;
this inverts that so the immediate refresh runs before the async one is
spawned.

With RefreshingBranchesMutex still in place this is behavior-preserving
(the mutex serializes the two either way). It's a preparatory step for
replacing that mutex with a branch-load sequence guard: running the
immediate refresh first establishes a happens-before relation between
the two loads' sequence numbers, so the recency-sorted one is guaranteed
the higher sequence.

This also lets refreshReflogCommitsConsideringStartup fold into
refreshReflogAndBranches, whose two-phase logic is now all in one place.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 18:09:33 +02:00
Stefan HallerandClaude Opus 4.8 805738034f Remove refresh mutexes made redundant by bouncing
Now that every refresh scope writes its model updates on the UI thread
via onUIThreadUnlessRepoChanged, the per-scope mutexes that used to
serialize concurrent worker-goroutine access are redundant:
Model().Commits, .SubCommits, .Authors, the status view content, and
.PullRequests/.PullRequestsMap are all now written only on the UI
thread, and their readers already ran there. setSubCommits only existed
to take the lock, so it's inlined to match refreshSubCommitsWithLimit,
which writes Model().SubCommits directly.

The worker phases still *read* some of these fields (the commit
selection range, MergeRebasingCommits), but those reads race a
concurrent refresh's bounced write regardless of the mutex — the write
happens in the bounce, outside the locked region — so the mutex never
protected them. That residual read race belongs to the broader -race
effort, not to these locks.

RefreshingBranchesMutex is deliberately kept. It is load-bearing for a
reason unrelated to data races: at the INITIAL startup stage two
refreshBranches run concurrently — an immediate one with an empty
reflog (non-recency order) and an async one with the freshly-loaded
reflog (recency order). The mutex serializes them so the recency write's
bounce is enqueued last and wins. Without it the stale non-recency write
can land last, reordering the branches list (caught by the recency-sort
e2e tests: cherry_pick/*, branch/rebase_*).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 18:09:33 +02:00
Stefan HallerandClaude Sonnet 5 f7a61443fa Bounce BRANCHES model updates onto the UI thread
refreshBranches now loads the branches (and worktrees) on the worker and
writes Model.Branches, the pull-requests map, Model.Worktrees, and the
restored branch selection in an onUIThreadUnlessRepoChanged bounce. The
selection restore and rebuildPullRequestsMap run in the bounce so they
see the branches we just wrote; the LocalCommits re-render (for branch
head visualization) moves into the same bounce.

refreshStatus is adjusted to read the checked-out branch and the linked
worktree name inside its bounce rather than on the worker: both derive
from models (Branches, Worktrees) that are now written via bounces, so
reading them on the worker would format the status from stale values —
which showed up as the status line dropping the "(worktree)" suffix right
after entering a submodule or switching worktrees. The git work
(WorkingTreeState) stays on the worker.

Two callers that read the branches model right after a SYNC branches
refresh move their reads into Then:
- BranchesHelper.PostFetchRefresh: AutoForwardBranches reads Model.Branches,
  so it runs in Then (preserving that a fetch error is still returned to
  the caller and that background auto-forward errors aren't surfaced as a
  popup).
- BranchesController rename: the re-select-by-name loop runs in Then.

RefreshingBranchesMutex is left in place for the mutex cleanup.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 18:09:33 +02:00
Stefan HallerandClaude Sonnet 5 549df17279 Bounce COMMITS model updates onto the UI thread
refreshCommitsWithLimit now loads the commits, working-tree state and
bisect info on the worker and writes them all — Model.Commits,
Model.BisectInfo, Model.WorkingTreeStateAtLastCommitRefresh,
Model.CheckedOutBranch, the authors, and the restored commit selection —
in a single onUIThreadUnlessRepoChanged bounce. The selection restore
(SelectHeadCommit / KeepCommitSelectionByHash) has to run in the bounce
because it reads the freshly-loaded commits; the FocusLine scroll is
enqueued from within the bounce so it still runs after refreshView's
re-render, as before.

refForLog no longer writes Model.BisectInfo as a side effect; it returns
the bisect info it read, and the bounce writes it, keeping that model
write on the UI thread. No caller reads Model.BisectInfo synchronously
after a refresh (the bisect controller reads Git().Bisect.GetInfo()
directly), so this is safe.

refreshCommitsAndCommitFiles's post-refresh re-init of the commit files
context depends on that restored selection, so it reads the selection in
a bounce and dispatches the commit-files git work back to a worker.

LocalCommitsMutex / AuthorsMutex are left in place for the mutex cleanup.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 18:09:33 +02:00
Stefan HallerandClaude Sonnet 5 4c3f8b51ea Bounce PULL_REQUESTS model updates onto the UI thread
refreshGithubPullRequests and setGithubPullRequests now do their network
work on the worker and write Model.PullRequests / PullRequestsMap in an
onUIThreadUnlessRepoChanged bounce (the "no github remotes" and "no base
remote" early-returns clear them the same way). rebuildPullRequestsMap
moves into the bounce so the map is built from Model.Branches and
Model.Remotes as they stand on the UI thread — after those scopes'
refreshes have applied their own bounces — rather than from whatever the
worker happened to see.

The remaining worker-side reads of Model.Branches (to pick which upstream
branches to query) are the same not-yet-addressed worker-read race that
applies to the other bounced scopes.

RefreshingPullRequestsMutex is left in place for the mutex cleanup.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 18:09:33 +02:00
Stefan HallerandClaude Sonnet 5 4c9fdc4221 Bounce STATUS view update onto the UI thread
refreshStatus computes the status line on the calling goroutine (as
before) but now writes it to the status view in an
onUIThreadUnlessRepoChanged bounce rather than calling SetViewContent
directly from the worker. RefreshingStatusMutex is left in place for now;
it only guards the compute phase between concurrent callers and comes out
in the mutex cleanup.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 18:09:33 +02:00
Stefan HallerandClaude Sonnet 5 063bba6b45 Bounce REFLOG model updates onto the UI thread
refreshReflogCommits now does the git fetch on the worker and computes
the new ReflogCommits / FilteredReflogCommits values (still reading the
existing slices for the incremental prepend), then writes them in an
onUIThreadUnlessRepoChanged bounce. The freshly-computed reflog is still
returned for the branches load, so recency sorting is unaffected by the
write now landing on the UI thread (see the previous commit).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 18:09:33 +02:00
Stefan HallerandClaude Sonnet 5 bd47106d03 Thread reflog commits explicitly into the branches load
BranchLoader.Load reads the reflog commits to sort branches by recency.
Today it reads them straight from Model.ReflogCommits, which works
because in the recency path the reflog refresh writes that field
synchronously just before the branches refresh reads it (same goroutine,
sequential).

An upcoming commit bounces the reflog model write onto the UI thread, at
which point Model.ReflogCommits wouldn't be updated yet when branches
runs — branches would sort by the previous refresh's reflog. To decouple
the branches load from *when* that write lands, pass the reflog commits
to refreshBranches explicitly: refreshReflogCommits now returns the
commits it loaded, and the recency path hands them straight to
refreshBranches. The non-recency path (branches and reflog run
concurrently, as before) keeps passing Model.ReflogCommits.

Pure refactor: behavior is identical, since the value passed is exactly
what Load read from the model before.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 18:09:33 +02:00
Stefan HallerandClaude Sonnet 5 db5eb6fd39 Bounce REMOTES model updates onto the UI thread
refreshRemotes now loads the remotes on the worker and writes
Model.Remotes, rebuilds the pull-requests map, and updates the selected
remote's RemoteBranches inside an onUIThreadUnlessRepoChanged bounce.

RemotesController.addAndCheckoutRemote read Model.Remotes right after its
SYNC REMOTES refresh to select the newly-added remote; since that write
now bounces, the selection (and the follow-up fetch) move into Then so
they run against the post-refresh model rather than the stale one.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 18:09:33 +02:00
Stefan HallerandClaude Sonnet 5 0f85c2b2b4 Bounce SUB_COMMITS model updates onto the UI thread
refreshSubCommitsWithLimit now loads the sub-commits on the worker and
writes Model.SubCommits (and folds their authors into Model.Authors via
RefreshAuthors) inside an onUIThreadUnlessRepoChanged bounce.

SubCommitsMutex and AuthorsMutex are left in place: the former is shared
with setSubCommits, the latter with the commits refresh's RefreshAuthors
call, so both come out only once those other writers are on the UI thread
too.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 18:09:33 +02:00
Stefan HallerandClaude Sonnet 5 559b4bf298 Bounce REBASE_COMMITS model updates onto the UI thread
refreshRebaseCommits now computes the merged rebasing commits and working
tree state on the worker and writes Model.Commits /
WorkingTreeStateAtLastCommitRefresh in an onUIThreadUnlessRepoChanged
bounce. LocalCommitsMutex is left in place for now; it's shared with the
commits and branches refreshes and comes out once they're all bounced.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 18:09:33 +02:00
Stefan HallerandClaude Sonnet 5 d6f6d0ceba Bounce WORKTREES model updates onto the UI thread
refreshWorktrees now writes Model.Worktrees in an
onUIThreadUnlessRepoChanged bounce. loadWorktrees becomes a pure loader
that returns the worktrees instead of writing them, since it's shared
with refreshBranches; refreshWorktrees bounces the result, and the
branches call site writes it directly for now (that write moves into
refreshBranches's own bounce when that scope is migrated).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 18:09:33 +02:00
Stefan HallerandClaude Sonnet 5 ff7ecf2d2a Bounce STASH model updates onto the UI thread
refreshStashEntries now loads the stash entries on the worker and writes
Model.StashEntries in an onUIThreadUnlessRepoChanged bounce.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 18:09:33 +02:00
Stefan HallerandClaude Sonnet 5 21f1dc3366 Bounce TAGS model updates onto the UI thread
refreshTags now captures the repo generation, loads the tags on the
worker, and writes Model.Tags in an onUIThreadUnlessRepoChanged bounce
rather than directly from the worker goroutine.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 18:09:33 +02:00
Stefan HallerandClaude Sonnet 5 b203ec57ac Bounce COMMIT_FILES model updates onto the UI thread
refreshCommitFilesContext now enqueues the Model.CommitFiles write and
CommitFileTreeViewModel.SetTree() call via OnUIThread, instead of running
them directly on the worker goroutine that drives async refreshes. This is
what makes moving SwitchToDiffFilesController's post-refresh work into Then
(previous commit) actually necessary, rather than just future-proofing.

Same repo-switch hazard as the FILES bounce, closed the same way: it
captures the repo generation before the git work and bounces through
onUIThreadUnlessRepoChanged, so the write is dropped if the user switched
repos while it was in flight.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 18:09:33 +02:00
Stefan HallerandClaude Sonnet 5 6203a4e411 Move post-COMMIT_FILES-refresh work into Then
SwitchToDiffFilesController.enter calls SelectPath and Context.Push
right after a (SYNC, by default) COMMIT_FILES refresh. This works today
because the model write currently happens synchronously in the worker
before Refresh's wg.Wait() returns, but an upcoming commit will bounce
that write onto the UI thread instead, at which point wg.Wait() no
longer guarantees it's been applied, and SelectPath would operate on a
stale tree.

Move both calls into Then ahead of that change, for the same reason as
the earlier FILES-scope commit: Then is already queued via OnUIThread,
so this is behavior-preserving on its own.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 18:09:33 +02:00
Stefan HallerandClaude Sonnet 5 2c139b6ac1 Remove RefreshingFilesMutex/FileTreeViewModel.RWMutex, dead code
FileTreeViewModel.RWMutex is removed along with the
withFileTreeViewModelMutex wrapper in FilesController that RLocked it:
every writer (the bounce closure, previous commit) and every reader (key
handlers, disabled-reason callbacks) now runs on the UI thread, so the
mutex is redundant.

RefreshingFilesMutex is removed entirely, including its last use in
repos_helper's DispatchSwitchTo. That use predates the bounce and was
never about FilesController's optimistic-rendering concern; it serialized
a repo switch's onNewRepo() against an in-flight FILES refresh for the
repo being switched away from, so that a slow refresh from the old repo
couldn't write into the freshly-reset model for the new one. Bouncing the
write already broke that guarantee on its own terms — the mutex's critical
section never covered the bounced closure's actual execution, only the
(now-removed) code that enqueued it — so by this point it was only still
locked here without protecting anything real; the previous commit's
repo-generation guard is what now actually closes that race, making this
lock fully redundant rather than just relocated.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 18:09:33 +02:00
Stefan HallerandClaude Sonnet 5 be897ce55e Bounce FILES model updates onto the UI thread
refreshStateFiles now does its git work on the worker and enqueues a
single OnUIThread closure that writes Model.Submodules, Model.Files, and
the FileTreeViewModel state together, instead of writing them directly
from the worker goroutine. refreshStateSubmoduleConfigs becomes a pure
getter (returns the configs; no model write) so the result can be
threaded into that same bounce.

The STAGING handler wraps RefreshStagingPanel in OnUIThread after
fileWg.Wait() so it sees the post-bounce file model rather than the stale
pre-refresh one — without this it would race the files bounce queued just
above it.

Bouncing the write opens a hazard the old synchronous write didn't have:
if the user switches repos while this refresh is in flight, the queued
closure would fire after resetState has replaced the model with a fresh
one for the new repo, silently overwriting it with the previous repo's
files. Guard against this with a repo generation: resetState bumps a
counter on every switch, refreshStateFiles captures it before its git
work, and onUIThreadUnlessRepoChanged drops the bounce if the generation
has moved on. This one helper is the general mechanism the remaining
scopes' bounces will use too; the same guard covers the rebase-continue
prompt, which reads Model.Files right after.

A generation counter, not a comparison of the *Model pointer: switching
away from and back to a repo reuses that repo's cached state (the same
Model pointer), which a pointer comparison would wrongly accept even
though the in-flight data is stale.

PromptToContinueRebase's Then callback (previous commit) now gets an
explanatory comment, since this is the commit that makes it necessary.

The explicit locking around these writes (RefreshingFilesMutex in
refreshFilesAndSubmodules, FileTreeViewModel.RWMutex around the write in
refreshStateFiles) is left in place for now even though it's becoming
redundant, to keep this commit focused on the bounce itself; it's removed
next.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 18:09:33 +02:00
Stefan HallerandClaude Sonnet 5 ea83f50dc3 Move post-FILES-refresh model reads into Then
PromptToContinueRebase and WithEnsureCommittableFiles both read
Model.Files right after a SYNC FILES refresh. This works today because
the model write currently happens synchronously in the worker before
Refresh's wg.Wait() returns, but an upcoming commit will bounce that
write onto the UI thread instead, at which point wg.Wait() no longer
guarantees it's been applied.

Move both reads into Then ahead of that change. Then is already queued
via OnUIThread (previous commit), so this is a behavior-preserving
refactor on its own: the model is fully written by the time Then runs
either way, whether that write is still synchronous or gets bounced
later.

As part of restructuring WithEnsureCommittableFiles, prepareFilesForCommit
and syncRefresh are inlined into their single call sites.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 18:09:33 +02:00
Stefan HallerandClaude Sonnet 5 717448f105 Make RefreshOptions.Then a func() error, queue it via OnUIThread
This is preparation for upcoming commits that will bounce refresh-scope
model updates (e.g. Model.Files) onto the UI thread by enqueuing the
write via OnUIThread instead of applying it directly on the worker
goroutine. Once that lands, a Then callback that reads the model must
run after that queued write has been processed, not synchronously at
wg.Wait() time — at that point the workers have returned, but a bounce
they queued may not have been processed yet.

Queuing Then via OnUIThread here, ahead of that change, guarantees the
right ordering once it lands: a bounce queued earlier in the same
refresh is already sitting in the channel by the time wg.Wait()
returns, so Then enqueued after it will always be processed after, and
see the post-refresh model.

The signature change to func() error lets Then propagate errors
through gocui's normal error handler (the same path key-handler errors
take).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 18:09:33 +02:00
Stefan HallerandClaude Sonnet 5 b54d4c369b Remove unused IsRefreshingFiles state
GetIsRefreshingFiles() is never called anywhere in the codebase, so the
flag serves no purpose. Remove it from Gui, StateAccessor, and
IStateAccessor, and drop the two SetIsRefreshingFiles calls in
refreshFilesAndSubmodules that maintained it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 18:09:33 +02:00
Stefan Haller 5350b6c37b Remove unused CommitFileTreeViewModel.RWMutex 2026-07-07 18:09:33 +02:00
Stefan Haller dd1576138a AGENTS.md additions 2026-07-07 18:09:33 +02:00
Stefan Haller fa9429f8c7 Fix stuck inline status when pushing/fetching (#5768)
Operations that show an inline status next to the item they operate on
("Pushing", "Fast-forwarding", "Fetching", …) would sometimes fail to
remove this inline status when done.

Fixes #5534.
2026-07-07 18:08:19 +02:00
Stefan HallerandClaude Opus 4.8 78dd678ce5 Drop the now-redundant manual re-render in tag push
Pushing a tag triggers no refresh, so it used to redraw the tags view
by hand to remove the "Pushing" inline status. WithInlineStatus now
always re-renders after clearing the operation, so this is redundant.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 17:46:16 +02:00
Stefan HallerandClaude Opus 4.8 775ebc9e44 Re-render to clear an inline status when its operation finishes
Operations that show an inline status ("Pushing", "Fast-forwarding",
"Fetching", …) removed it by relying on the async refresh they trigger
to redraw the view after the item operation had been cleared. That
ordering was never guaranteed: the item operation is cleared on the
worker once the operation's function returns, while the refresh redraws
the item from the UI thread whenever its (asynchronous) git work
happens to finish. If the refresh redrew before the clear, the status
was left on screen with no later redraw to remove it, so the branch (or
tag/remote) stayed stuck showing e.g. "Pushing" indefinitely even though
the operation had completed. This is timing-dependent, which is why it
surfaced as rare, hard-to-reproduce reports and as flaky CI failures.

Fix it by re-rendering in stop() right after clearing the operation,
and by making these refreshes synchronous rather than async. Because a
synchronous refresh has already updated the model and queued its own
redraw by the time stop() runs, and UI-thread callbacks run in order,
the redraw we queue here runs last and draws the up-to-date model with
the status removed. An async refresh couldn't give that guarantee: its
model update might not have landed yet, so the redraw could briefly
flash the pre-operation status.

Pull refreshes through the shared CheckMergeOrRebaseAndSelectHeadCommit,
so that helper becomes synchronous too; its only other caller,
RegularMerge, thereby also refreshes synchronously, which is fine: a
synchronous on-worker refresh is what we want anyway.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 17:46:16 +02:00
Stefan Haller 3537f855b0 Show a spinner for more long-running operations (#5765)
Some operations used to freeze lazygit while they ran, with no sign that
anything was happening — the UI would just lock up until they finished.

This affected:

- Merging a branch (including squash merges)
- Rebasing a branch interactively onto another ref
- Setting a commit to "edit"
- Resetting to a commit, branch, or tag
- Continuing, skipping, or aborting a merge or rebase

Many of these were usually fast under normal conditions (e.g. a hard
reset when the head doesn't change, or a "rebase --continue" when
there's only a handful of commits left), but in some cases they could
take long (e.g. a hard reset to some distant commit where lots of files
changed). Now each of these shows a spinner while it works and keeps the
UI responsive, matching how similar operations already behave.
2026-07-06 12:36:47 +02:00
Stefan HallerandClaude Opus 4.8 6681ba7eda Show a waiting status while resetting to a ref
Resetting to a commit/branch/tag from the reset menu ran inline on the UI
thread with no spinner; a hard reset to a distant commit can take a while
and blocks the UI meanwhile. Run it on a worker with a waiting status. The
undo/redo callers of ResetToRef already wrap it this way.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 11:56:07 +02:00
Stefan HallerandClaude Opus 4.8 ccf49c8112 Show a waiting status when editing a commit
Setting a single commit to "edit" ran the interactive rebase inline on the
UI thread with no spinner, while its sibling startInteractiveRebaseWithEdit
(used when editing multiple commits or quick-starting a rebase) already runs
on a worker with a waiting status. Make the direct path match.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 11:55:28 +02:00
Stefan HallerandClaude Opus 4.8 2bae29c6fd Show a waiting status when starting an interactive rebase onto a ref
The interactive-rebase item in the rebase-onto-ref menu ran inline on the UI
thread with no spinner, unlike its two siblings in the same menu (simple
rebase and rebase onto base branch), which already run on a worker with a
waiting status. Make it consistent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 11:55:12 +02:00
Stefan HallerandClaude Opus 4.8 539ede2e1b Show a waiting status while merging a branch
The regular and squash merges from the merge menu ran inline on the UI
thread, freezing it with no spinner while git worked. Run them on a worker
with a waiting status, like the rebase entry points already do.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 11:54:38 +02:00
Stefan HallerandClaude Opus 4.8 44ba0d539f Show a waiting status while continuing a merge or rebase
Continuing, skipping, or aborting a merge/rebase from the options menu ran
the git command inline on the UI thread, freezing the UI with no spinner
while it worked (a continue can replay many commits). Run the
non-subprocess path on a worker with a waiting status instead, matching how
the other merge/rebase entry points already behave.

The auto-skip recursion in CheckMergeOrRebaseWithRefreshOptions must not
start its own worker: it already runs on the caller's thread (the worker of
the enclosing waiting status, or the UI thread for the synchronous callers).
Route it through genericMergeCommandImpl with the waiting status suppressed
so its behavior is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 11:54:14 +02:00
163 changed files with 4840 additions and 2603 deletions
+12 -6
View File
@@ -13,10 +13,15 @@ on:
description: 'Version bump type'
type: choice
required: true
default: 'patch'
default: 'minor (normal)'
options:
- minor
- patch
- minor (normal)
- patch (hotfix)
branch:
description: 'Branch to release from'
type: string
required: true
default: 'master'
ignore_blocks:
description: 'Ignore blocking PRs/issues'
type: boolean
@@ -49,12 +54,13 @@ jobs:
uses: actions/checkout@v7
with:
repository: jesseduffield/lazygit
ref: ${{ inputs.branch }}
token: ${{ secrets.LAZYGIT_RELEASE_PAT }}
fetch-depth: 0
- name: Get Latest Tag
run: |
latest_tag=$(git describe --tags $(git rev-list --tags --max-count=1) || echo "v0.0.0")
latest_tag=$(git describe --tags --abbrev=0 || echo "v0.0.0")
if ! [[ $latest_tag =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Error: Tag format is invalid. Expected format: vX.X.X"
@@ -121,7 +127,7 @@ jobs:
IFS='.' read -r major minor patch <<< "$LATEST_TAG"
if [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then
if [[ "$VERSION_BUMP" == "patch" ]]; then
if [[ "$VERSION_BUMP" == "patch (hotfix)" ]]; then
patch=$((patch + 1))
else
minor=$((minor + 1))
@@ -151,7 +157,7 @@ jobs:
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag "$NEW_TAG" -a -m "Release $NEW_TAG"
git push origin "$NEW_TAG"
git push origin "refs/tags/$NEW_TAG"
- name: Setup Go
uses: actions/setup-go@v6
+50
View File
@@ -48,6 +48,9 @@ while still being meaningful and self-contained.
commits that leave the tree broken and rely on a follow-up to fix it.
- **Every commit must be `gofumpt`-formatted.** Run `just format` before
committing.
- **Every commit must be lint-clean.** Run `just lint` before committing —
don't introduce a lint warning in one commit and rely on a later commit
(or the user) to clean it up.
- **Commit messages explain _why_, not _what_.** The diff already shows what
changed; the message should capture the motivation, the constraint, or the
bug being fixed. If the reason is obvious from a one-line subject, no body
@@ -157,6 +160,16 @@ genuine forks — the ones where a reasonable person might pick differently, or
where you'd be trading away something the plan assumed (scope, UX, performance,
reload behavior, …). When in doubt, surface it.
This applies with equal force to unforeseen _discoveries_, not just to
decisions you set out to make. If you find something the plan didn't account
for — a latent bug, a race, a wrong assumption, a case that turns out
unhandled — stop and raise it before designing or writing a fix, even when the
fix seems obvious and even when it's "just correctness." Finding the problem is
itself the fork: whether to fix it here or in a separate change, how generally
to solve it, and whether it reshapes the current work are all calls for me to
make with you. Don't quietly fold a self-directed fix for a newly-found problem
into the branch and let me discover it in the diff.
## Prefer the cleaner design over the smaller diff
When a task could be implemented either by tacking onto existing code or by
@@ -243,6 +256,34 @@ Follow this even when the need for the refactor is only discovered in the middle
of working on the branch; suggest to the user to rewrite the history to move the
refactor to an earlier commit (but don't do it without asking first).
## Don't read model state right after a `Refresh`
A `Refresh` (or `RefreshFromWorker`) does its git work on a worker and then
*enqueues* the model update onto the UI thread. So when `Refresh` returns, the
model is **not** updated yet — the write is still queued. Reading a field
synchronously right after refreshing its scope reads the stale, pre-refresh
value (and this is true even for SYNC refreshes):
```go
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}})
files := self.c.Model().Files // BUG: still the pre-refresh value
```
Put the read in `RefreshOptions.Then` instead — it's queued after the scope's
model writes, so it sees the fresh value:
```go
self.c.Refresh(types.RefreshOptions{
Scope: []types.RefreshableView{types.FILES},
Then: func() error {
files := self.c.Model().Files // fresh
return nil
},
})
```
`Then` is a `func() error` and works with any non-`ASYNC` mode.
## Integration test conventions
Don't bind views to local variables. Always chain method calls directly from
@@ -370,3 +411,12 @@ Never run `find` (or similar) from `/` or other paths outside the project. All
third-party code we use is vendored under `vendor/`, so dependency sources are
reachable from inside the working tree — search there instead of the host
filesystem.
## gocui is in-tree, not a dependency
The `gocui` TUI library is a fork maintained directly in this repo under
`pkg/gocui` — it's an ordinary package, not a Go module dependency. Don't look
for it in `go.mod`/`go.sum` or the module cache (`$GOMODCACHE`); it isn't
there. When you need to read or change gocui internals (the task manager, the
event loop, worker/UI-thread dispatch, view rendering), edit `pkg/gocui`
directly.
+19 -258
View File
@@ -1,273 +1,34 @@
# Contributing
This project does not accept pull requests.
## The short version
In todays world of agentic coding I have decided that it no longer makes sense for me to look at incoming pull requests. As far as I can tell, the vast majority of these is AI-generated these days, which in itself is not necessarily a bad thing; however, there's no way for me to tell whether the person posting the PR actually understands anything about the code that is being contributed or not, and I don't feel like spending time and energy on finding out whether they do.
This project does not accept pull requests. Don't bother making one, it won't be merged.
Now you might ask why this even matters; coding agents are capable of producing amazingly high-quality code, so why is it important that the person opening the PR understands it, as long as the code works and tests are green? It does actually matter very much to me. AI generated code needs to be carefully reviewed and iterated on, and it is the contributor's job to do that, not mine. And I have no idea to what extent the contributor has done this, or whether they are even capable of it.
However, there are other forms of contributions that are very welcome and encouraged; see below for what those are.
Every PR needs work and iterations until it is mergeable, whether manually coded or AI generated (even very good ones do), and if I don't know whether the person posting the PR will act on my review feedback themselves or just pass it on to their coding agent (which I guess is the much more likely case today), then it doesn't make sense for me to work with them.
## Why no PRs?
For this reason I will close incoming pull requests by default from now on, without comment. Sorry if this sounds hostile, but honestly I don't feel I have much of a choice if I want maintaining this project to still be enjoyable for me.
There are two main reasons for this, and I want to be very honest about them:
With that said, if you are indeed serious about contributing a high-quality PR to lazygit, and you are familiar with go, and you have learned enough about lazygit's code base to tell whether your changes are good, then do raise an issue and explain what you are planning to do, and somehow make it plausible that your PR will be worth my time reviewing it. In such a case I might make an exception from the default rule.
- I am maintaining lazygit for fun, as a hobby in my free time (which is quite limited). I'd like to spend my free time on things that I enjoy doing. I enjoy working on lazygit's code and improving it myself; I don't enjoy reviewing PRs. It's that simple, really. Reviewing PRs takes a lot of time; time that I would rather spend on developing lazygit myself.
- Even if I had the time and inclination to review PRs, this has become quite difficult today: most PRs nowadays are AI-generated to some extent (often completely), which in itself is not necessarily a bad thing; I heavily use AI myself these days, and I get great results from it. However, agentic coding needs to be guided by humans so that the results are good, and for contributed PRs I can't tell to what extent the human contributor did this, or is even capable of it; and I don't want to do the work of guiding a contributor's coding agent. If I post PR review feedback and have to suspect that the contributor simply passes it on to their coding agent, then that is a work mode that doesn't make sense to me, and I would rather just drive my own agent to do the work.
In the future I might also consider adopting a vouch system similar to [Ghostty's](https://github.com/ghostty-org/ghostty/blob/main/CONTRIBUTING.md#first-time-contributors), but right now I feel the effort needed to set this up and maintain is not justified given the rather low number of high-quality contributions I have seen in recent times.
### Why it might still make sense to post a PR
Even though we no longer accept pull requests, I find it important to emphasize that Lazygit is still a community project, and non-PR contributions are still very welcome. Do file issues for bug reports or feature requests, and help shape the future of lazygit by actively participating in discussing UX designs. Also, the localization system very much depends on everybody's help with translating texts (see https://crowdin.com/project/lazygit).
I can think of two such reasons:
---
- You implemented a lazygit improvement that you want to use yourself; in this case it could make sense to let others merge this change into their forks if they find it useful too. And if enough people say they want the feature, this can persuade me to add it, so putting it out there to give it visibility can be helpful.
- You posted an issue for a feature request, and have a prototype that implements it; it could be useful to publish the branch as a draft PR to better illustrate how the feature works.
The remainder of this document is the old version from a time when contributing pull requests was still encouraged. Keeping it here in case I reconsider my policy in the future.
For this reason I usually don't close pull requests to give them more visibility. Just don't expect your PR to be merged.
## PR walkthrough
## So how can I contribute then?
[This video](https://www.youtube.com/watch?v=kNavnhzZHtk) walks through the process of adding a small feature to lazygit. If you have no idea where to start, watching that video is a good first step.
There are other forms of contributions to a project besides source code that are very welcome and encouraged; for instance:
## Design principles
- File issues for bugs that you find, and I'll do my best to take care of fixing them (if they are important enough).
- File feature requests for new functionality that you want to see in lazygit. I have a lot of ideas for future improvement myself, but I have also implemented a lot of feature ideas that weren't mine, and I'm grateful for those ideas. (Of course, there are also lots of feature requests that I don't implement, so don't be disappointed if I don't jump on yours.)
- Help make other people's bug reports reproducible. Sometimes people report bugs that they have only seen once, and in such a case it can be helpful to come up with reproducible scenarios.
- Help complete or improve the translation into other languages; join https://crowdin.com/project/lazygit for that.
See [here](./VISION.md) for a set of design principles that we want to consider when building a feature or making a change.
## Codebase guide
[This doc](./docs/dev/Codebase_Guide.md) explains:
- what the different packages in the codebase are for
- where important files live
- important concepts in the code
- how the event loop works
- other useful information
## All code changes happen through Pull Requests
Pull requests are the best way to propose changes to the codebase. We actively
welcome your pull requests:
1. Fork the repo and create your branch from `master`.
2. If you've added code that should be tested, add tests.
3. If you've added code that needs documentation, update the documentation.
4. Write a [good commit message](http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html).
5. Issue that pull request!
Please do not raise pull request from your fork's master branch: make a feature branch instead. Lazygit maintainers will sometimes push changes to your branch when reviewing a PR and we often can't do this if you use your master branch.
If you've never written Go in your life, then join the club! Lazygit was the maintainer's first Go program, and most contributors have never used Go before. Go is widely considered an easy-to-learn language, so if you're looking for an open source project to gain dev experience, you've come to the right place.
## Commit history
We value a clean and useful commit history, so please take some time to organize your commits so that they make sense. Don't assume that they will be squashed on merge anyway; we don't do that here.
In particular:
- Refactorings and behavior changes should be in separate commits. There are very few exceptions where this is not possible, but in my experience they are very rare.
- Strive for minimal commits; every change that is independent from other changes should be in a commit of its own (with a good commit message that explains why the change is made).
- When you need to iterate over your implementation during review (e.g. because you discovered a bug, or a maintainer requested changes), don't just pile new commits on top. Use fixup commits to make your changes transparent while still maintaining a good commit history. If you don't know what that means, [here's a brief introduction](docs/Fixup_Commits.md).
## A note about AI
It has become common recently to throw an issue at a coding agent and submit whatever comes out of it as a PR. This is not appreciated here, and I will close PRs where I can tell this was the case, or where I even suspect it was the case.
Some of these PRs may actually be good and useful, but many are not, and it's not a good use of my time as a maintainer to look at generated PRs to decide. This is the job of the PR's contributor, and if you don't speak enough go or can't be bothered to get familiar enough with lazygit's codebase to tell, then don't contribute the PR.
## Running in a VSCode dev container
If you want to spare yourself the hassle of setting up your dev environment yourself (i.e. installing Go, extensions, and extra tools), you can run the Lazygit code in a VSCode dev container like so:
![image](https://user-images.githubusercontent.com/8456633/201500508-0d55f99f-5035-4a6f-a0f8-eaea5c003e5d.png)
This requires that:
- you have docker installed
- you have the dev containers extension installed in VSCode
See [here](https://code.visualstudio.com/docs/devcontainers/containers) for more info about dev containers.
## Running in a Github Codespace
If you want to start contributing to Lazygit with the click of a button, you can open the lazygit codebase in a Codespace. First fork the repo, then click to create a codespace:
![image](https://user-images.githubusercontent.com/8456633/201500566-ffe9105d-6030-4cc7-a525-6570b0b413a2.png)
To run lazygit from within the integrated terminal just go `go run main.go`
This allows you to contribute to Lazygit without needing to install anything on your local machine. The Codespace has all the necessary tools and extensions pre-installed.
## Using Nix for development
If you use Nix, you can leverage the included flake to set up a complete development environment with all necessary dependencies:
```sh
nix develop
```
This will drop you into a development shell that includes:
- Latest Go toolchain
- golangci-lint for code linting
- git and make
You can also build and run lazygit using nix:
```sh
# Build lazygit
nix build
# Run lazygit directly
nix run
```
The nix flake supports multiple architectures (x86_64-linux, aarch64-linux, x86_64-darwin, aarch64-darwin) and provides a consistent development environment across different systems.
## Code of conduct
Please note by participating in this project, you agree to abide by the [code of conduct].
[code of conduct]: https://github.com/jesseduffield/lazygit/blob/master/CODE-OF-CONDUCT.md
## Any contributions you make will be under the MIT Software License
In short, when you submit code changes, your submissions are understood to be
under the same [MIT License](http://choosealicense.com/licenses/mit/) that
covers the project. Feel free to contact the maintainers if that's a concern.
## Report bugs using Github's [issues](https://github.com/jesseduffield/lazygit/issues)
We use GitHub issues to track public bugs. Report a bug by [opening a new
issue](https://github.com/jesseduffield/lazygit/issues/new); it's that easy!
## Go
This project is written in Go. Go is an opinionated language with strict idioms, but some of those idioms are a little extreme. Some things we do differently:
1. There is no shame in using `self` as a receiver name in a struct method. In fact we encourage it
2. There is no shame in prefixing an interface with 'I' instead of suffixing with 'er' when there are several methods on the interface.
3. If a struct implements an interface, we make it explicit with something like:
```go
var _ MyInterface = &MyStruct{}
```
This makes the intent clearer and means that if we fail to satisfy the interface we'll get an error in the file that needs fixing.
### Code Formatting
To check code formatting [gofumpt](https://pkg.go.dev/mvdan.cc/gofumpt#section-readme) (which is a bit stricter than [gofmt](https://pkg.go.dev/cmd/gofmt)) is used.
VSCode will format the code correctly if you tell the Go extension to use `gofumpt` via your [`settings.json`](https://code.visualstudio.com/docs/getstarted/settings#_settingsjson)
by setting [`formatting.gofumpt`](https://github.com/golang/tools/blob/master/gopls/doc/settings.md#gofumpt-bool) to `true`:
```jsonc
// .vscode/settings.json
{
"gopls": {
"formatting.gofumpt": true
}
}
```
To run gofumpt from your terminal go:
```
go install mvdan.cc/gofumpt@latest && gofumpt -l -w .
```
## Programming Font
Lazygit supports [Nerd Fonts](https://www.nerdfonts.com) to render certain icons. Sometimes we use some of these icons verbatim in string literals in the code (mainly in tests), so you need to set your development environment to use a nerd font to see these.
## Internationalisation
Boy that's a hard word to spell. Anyway, lazygit is translated into several languages within the pkg/i18n package.
### For developers adding new text
If you need to render text to the user, you should add a new field to the TranslationSet struct in `pkg/i18n/english.go` and add the actual content within the `EnglishTranslationSet()` method in the same file. Then you can access via `gui.Tr.YourNewText` (or `self.c.Tr.YourNewText`, etc).
Note, we use 'Sentence case' for everything (so no 'Title Case' or 'whatever-it's-called-when-there's-no-capital-letters-case')
### For translators
Lazygit translations are managed through [Crowdin](https://crowdin.com/project/lazygit/). If you'd like to contribute translations:
1. Join the Crowdin project at https://crowdin.com/project/lazygit/
2. Select your target language and help translate missing strings
3. The translation files in `pkg/i18n/translations/` are managed by the maintainers - please don't edit them directly
For detailed information about the translation process, including how maintainers sync translations, see `pkg/i18n/translations/README.md`.
## Debugging
The easiest way to debug lazygit is to have two terminal tabs open at once: one for running lazygit (via `go run main.go -debug` in the project root) and one for viewing lazygit's logs (which can be done via `go run main.go --logs` or just `lazygit --logs`).
From most places in the codebase you have access to a logger e.g. `gui.Log.Warn("blah")` or `self.c.Log.Warn("blah")`.
If you find that the existing logs are too noisy, you can set the log level with e.g. `LOG_LEVEL=warn go run main.go -debug` and then only use `Warn` logs yourself.
If you need to log from code in the vendor directory (e.g. the `gocui` package), you won't have access to the logger, but you can easily add logging support by setting the `LAZYGIT_LOG_PATH` environment variable and using `logs.Global.Warn("blah")`. This is a global logger that's only intended for development purposes.
If you keep having to do some setup steps to reproduce an issue, read the Testing section below to see how to create an integration test by recording a lazygit session. It's pretty easy!
### VSCode debugger
If you want to trigger a debug session from VSCode, you can use the following snippet. Note that the `console` key is, at the time of writing, still an experimental feature.
```jsonc
// .vscode/launch.json
{
"version": "0.2.0",
"configurations": [
{
"name": "debug lazygit",
"type": "go",
"request": "launch",
"mode": "auto",
"program": "main.go",
"args": ["--debug"],
"console": "externalTerminal" // <-- you need this to actually see the lazygit UI in a window while debugging
}
]
}
```
## Profiling
If you want to investigate what's contributing to CPU or memory usage, see [this separate document](docs/dev/Profiling.md).
## Testing
Lazygit has two kinds of tests: unit tests and integration tests. Unit tests go in files that end in `_test.go`, and are written in Go. For integration tests, see [here](https://github.com/jesseduffield/lazygit/blob/master/pkg/integration/README.md)
## Updating Gocui
Sometimes you will need to make a change in the gocui fork (https://github.com/jesseduffield/gocui). Gocui is the package responsible for rendering windows and handling user input. Here's the typical process to follow:
1. Make the changes in gocui inside lazygit's vendor directory so it's easy to test against lazygit
2. Copy the changes over to the actual gocui repo (clone it if you haven't already, and use the `awesome` branch, not `master`)
3. Raise a PR on the gocui repo with your changes
4. After that PR is merged, make a PR in lazygit bumping the gocui version. You can bump the version by running the following at the lazygit repo root:
```sh
./scripts/bump_gocui.sh
```
5. Raise a PR in lazygit with those changes
## Updating Lazycore
[Lazycore](https://github.com/jesseduffield/lazycore) is a repo containing shared functionality between lazygit and lazydocker. Sometimes you will need to make a change to that repo and import the changes into lazygit. Similar to updating Gocui, here's what you do:
1. Make the changes in lazycore inside lazygit's vendor directory so it's easy to test against lazygit
2. Copy the changes over to the actual lazycore repo (clone it if you haven't already, and use the `master` branch)
3. Raise a PR on the lazycore repo with your changes
4. After that PR is merged, make a PR in lazygit bumping the lazycore version. You can bump the version by running the following at the lazygit repo root:
```sh
./scripts/bump_lazycore.sh
```
Or if you're using VSCode, there is a bump lazycore task you can find by going `cmd+shift+p` and typing 'Run task'
5. Raise a PR in lazygit with those changes
## Improvements
If you can think of any way to improve these docs let us know.
Importantly, if you file issues (whether bug reports or feature requests), stay around to answer questions and discuss your issue. There are few things that I find more annoying than spending time on responding to someone's issue (sometimes even making a PR that addresses it), and to then never hear from the OP again. So please set up your Github notifications so that you see when there's activity on your issue, and continue to participate.
BIN
View File
Binary file not shown.
+16
View File
@@ -79,6 +79,22 @@ git:
- externalDiffCommand: difft --color=always --display=inline --syntax-highlight=off
```
This can also be used for normal git diffs with custom parameters, such as `--color-words` or `--word-diff` which some people find useful. To do that, save a script like this to, say, `~/bin/color-words.sh`:
```sh
#!/bin/sh
git diff --color-words --no-index --color=always --no-ext-diff "$2" "$5"
```
And then use it in your git config like so:
```yaml
git:
pagers:
- externalDiffCommand: ~/bin/color-words.sh
```
Instead of setting this command in lazygit's `externalDiffCommand` config, you can also tell lazygit to use the external diff command that is configured in git itself (`diff.external`), by using
```yaml
+16
View File
@@ -79,6 +79,22 @@ git:
- externalDiffCommand: difft --color=always --display=inline --syntax-highlight=off
```
This can also be used for normal git diffs with custom parameters, such as `--color-words` or `--word-diff` which some people find useful. To do that, save a script like this to, say, `~/bin/color-words.sh`:
```sh
#!/bin/sh
git diff --color-words --no-index --color=always --no-ext-diff "$2" "$5"
```
And then use it in your git config like so:
```yaml
git:
pagers:
- externalDiffCommand: ~/bin/color-words.sh
```
Instead of setting this command in lazygit's `externalDiffCommand` config, you can also tell lazygit to use the external diff command that is configured in git itself (`diff.external`), by using
```yaml
+6 -6
View File
@@ -13,7 +13,7 @@ require (
github.com/cli/go-gh/v2 v2.13.0
github.com/cloudfoundry/jibber_jabber v0.0.0-20151120183258-bcc4c8345a21
github.com/creack/pty v1.1.24
github.com/gdamore/tcell/v3 v3.4.0
github.com/gdamore/tcell/v3 v3.4.1-0.20260703153331-243630d2fb59
github.com/go-errors/errors v1.5.1
github.com/gookit/color v1.6.1
github.com/integrii/flaggy v1.8.0
@@ -25,6 +25,7 @@ require (
github.com/lucasb-eyer/go-colorful v1.4.0
github.com/mgutz/str v1.2.0
github.com/mitchellh/go-ps v1.0.0
github.com/petermattis/goid v0.0.0-20250813065127-a731cc31b4fe
github.com/rivo/uniseg v0.4.7
github.com/sahilm/fuzzy v0.1.3
github.com/samber/lo v1.53.0
@@ -62,14 +63,13 @@ require (
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/onsi/ginkgo v1.10.3 // indirect
github.com/onsi/gomega v1.34.1 // indirect
github.com/petermattis/goid v0.0.0-20250813065127-a731cc31b4fe // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
golang.org/x/mod v0.35.0 // indirect
golang.org/x/mod v0.36.0 // indirect
golang.org/x/net v0.55.0 // indirect
golang.org/x/term v0.43.0 // indirect
golang.org/x/text v0.37.0 // indirect
golang.org/x/tools v0.44.0 // indirect
golang.org/x/term v0.44.0 // indirect
golang.org/x/text v0.38.0 // indirect
golang.org/x/tools v0.45.0 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/fsnotify.v1 v1.4.7 // indirect
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
+10 -10
View File
@@ -32,8 +32,8 @@ github.com/fatih/color v1.9.0 h1:8xPHl4/q1VyqGIPif1F+1V3Y3lSmrq01EabUW3CoW5s=
github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU=
github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw=
github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo=
github.com/gdamore/tcell/v3 v3.4.0 h1:VUym1HQZiYodA5PGQrqLxF7QwqQndcAUwQD7G7XUy5E=
github.com/gdamore/tcell/v3 v3.4.0/go.mod h1:fjKxNiIFwbzTxDU+i+AAMz+xPOgXVaZq5tbShsKseHc=
github.com/gdamore/tcell/v3 v3.4.1-0.20260703153331-243630d2fb59 h1:kUXexBZYoVdAJIOIuP6uLgK3k0G7ClDIRO27Z3epgtU=
github.com/gdamore/tcell/v3 v3.4.1-0.20260703153331-243630d2fb59/go.mod h1:Ev/2PFhL0QtVmu6XPZG9NEuITAZ6XH7i7/BF3wupBdw=
github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk=
github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
@@ -139,8 +139,8 @@ golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
@@ -168,21 +168,21 @@ golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+3 -2
View File
@@ -25,8 +25,9 @@ func NewGitCmdObjBuilder(log *logrus.Entry, innerBuilder *oscommands.CmdObjBuild
// the price of having a convenient interface where we can say .New(...).Run() is that our builder now depends on our runner, so when we want to wrap the default builder/runner in new functionality we need to jump through some hoops. We could avoid the use of a decorator function here by just exporting the runner field on the default builder but that would be misleading because we don't want anybody using that to run commands (i.e. we want there to be a single API used across the codebase)
updatedBuilder := innerBuilder.CloneWithNewRunner(func(runner oscommands.ICmdObjRunner) oscommands.ICmdObjRunner {
return &gitCmdObjRunner{
log: log,
innerRunner: runner,
log: log,
innerRunner: runner,
initialRetryDelay: defaultInitialRetryDelay,
}
})
+58 -33
View File
@@ -11,20 +11,42 @@ import (
// here we're wrapping the default command runner in some git-specific stuff e.g. retry logic if we get an error due to the presence of .git/index.lock
const (
WaitTime = 50 * time.Millisecond
RetryCount = 5
// defaultInitialRetryDelay is how long we wait before the first retry of a
// command that failed with a transient lock error. We double it before each
// subsequent retry (see retryOnLockError), so across maxRetries attempts we
// wait for a bit over a second in total. That's long enough to outlast the
// brief window during which another git process holds a lock we need —
// typically our own foreground `git status` refresh, which takes index.lock
// to persist its refreshed stat-cache.
defaultInitialRetryDelay = 20 * time.Millisecond
maxRetries = 7
)
type gitCmdObjRunner struct {
log *logrus.Entry
innerRunner oscommands.ICmdObjRunner
// initialRetryDelay is the wait before the first lock-error retry. It's a
// field rather than the constant directly so tests can set it to zero and
// not actually sleep.
initialRetryDelay time.Duration
}
// isRetryableError returns true if the error output indicates a transient
// lock-related error that may succeed on retry
func isRetryableError(output string) bool {
return strings.Contains(output, ".git/index.lock") ||
strings.Contains(output, "cannot lock ref")
// isRetryableError returns true if a failed command hit a transient
// lock-related condition that may succeed on retry. The lock message can reach
// us either in the command's captured output or, for streamed commands whose
// output we don't capture, only in the returned error, so we check both.
//
// We match the bare "index.lock" fragment rather than a fuller path or message
// so we catch the lock wherever git puts it: the main .git dir, a linked
// worktree's git dir (.git/worktrees/<name>/index.lock), or a submodule's git
// dir.
func isRetryableError(output string, err error) bool {
text := output
if err != nil {
text += "\n" + err.Error()
}
return strings.Contains(text, "index.lock") ||
strings.Contains(text, "cannot lock ref")
}
func (self *gitCmdObjRunner) Run(cmdObj *oscommands.CmdObj) error {
@@ -33,41 +55,44 @@ func (self *gitCmdObjRunner) Run(cmdObj *oscommands.CmdObj) error {
}
func (self *gitCmdObjRunner) RunWithOutput(cmdObj *oscommands.CmdObj) (string, error) {
var output string
var err error
for range RetryCount {
newCmdObj := cmdObj.Clone()
output, err = self.innerRunner.RunWithOutput(newCmdObj)
if err == nil || !isRetryableError(output) {
return output, err
}
// if we have an error based on a lock, we should wait a bit and then retry
self.log.Warn("lock error prevented command from running. Retrying command after a small wait")
time.Sleep(WaitTime)
}
return output, err
return self.retryOnLockError(func() (string, error) {
return self.innerRunner.RunWithOutput(cmdObj.Clone())
})
}
func (self *gitCmdObjRunner) RunWithOutputs(cmdObj *oscommands.CmdObj) (string, string, error) {
var stdout, stderr string
var err error
for range RetryCount {
newCmdObj := cmdObj.Clone()
stdout, stderr, err = self.innerRunner.RunWithOutputs(newCmdObj)
_, err := self.retryOnLockError(func() (string, error) {
var runErr error
stdout, stderr, runErr = self.innerRunner.RunWithOutputs(cmdObj.Clone())
return stdout + stderr, runErr
})
return stdout, stderr, err
}
if err == nil || !isRetryableError(stdout+stderr) {
return stdout, stderr, err
// retryOnLockError runs the given function, retrying if it fails with a
// transient lock error (see isRetryableError). The string returned by run is
// the command output we inspect to classify the failure. We clone the command
// for each attempt (inside run) because an *exec.Cmd can only be run once.
func (self *gitCmdObjRunner) retryOnLockError(run func() (string, error)) (string, error) {
delay := self.initialRetryDelay
var output string
var err error
for attempt := range maxRetries {
output, err = run()
if err == nil || !isRetryableError(output, err) {
break
}
// if we have an error based on a lock, we should wait a bit and then retry
self.log.Warn("lock error prevented command from running. Retrying command after a small wait")
time.Sleep(WaitTime)
if attempt < maxRetries-1 {
self.log.Warnf("lock error prevented command from running; retrying in %s", delay)
time.Sleep(delay)
delay *= 2
}
}
return stdout, stderr, err
return output, err
}
// Retry logic not implemented here, but these commands typically don't need to obtain a lock.
+137
View File
@@ -0,0 +1,137 @@
package commands
import (
"errors"
"testing"
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/stretchr/testify/assert"
)
type runnerResult struct {
output string
err error
}
// scriptedRunner is an ICmdObjRunner stub that returns a preconfigured result
// for each successive call, letting us drive the retry loop deterministically.
// It counts calls so tests can assert whether a command was retried.
type scriptedRunner struct {
results []runnerResult
calls int
}
func (self *scriptedRunner) next() (string, error) {
result := self.results[self.calls]
self.calls++
return result.output, result.err
}
func (self *scriptedRunner) Run(*oscommands.CmdObj) error {
_, err := self.next()
return err
}
func (self *scriptedRunner) RunWithOutput(*oscommands.CmdObj) (string, error) {
return self.next()
}
func (self *scriptedRunner) RunWithOutputs(*oscommands.CmdObj) (string, string, error) {
output, err := self.next()
return output, "", err
}
func (self *scriptedRunner) RunAndProcessLines(*oscommands.CmdObj, func(string) (bool, error)) error {
panic("not implemented")
}
func newTestRunner(inner *scriptedRunner) *gitCmdObjRunner {
return &gitCmdObjRunner{
log: utils.NewDummyLog(),
innerRunner: inner,
// don't actually sleep between retries
initialRetryDelay: 0,
}
}
// dummyCmdObj returns a throwaway command; only its clonability matters, since
// the scriptedRunner ignores it and returns preconfigured results.
func dummyCmdObj() *oscommands.CmdObj {
return oscommands.NewDummyCmdObjBuilder(nil).New([]string{"git", "status"})
}
func TestRunWithOutputReturnsSuccessWithoutRetrying(t *testing.T) {
inner := &scriptedRunner{results: []runnerResult{{output: "done", err: nil}}}
output, err := newTestRunner(inner).RunWithOutput(dummyCmdObj())
assert.NoError(t, err)
assert.Equal(t, "done", output)
assert.Equal(t, 1, inner.calls)
}
func TestRunWithOutputDoesNotRetryNonLockError(t *testing.T) {
inner := &scriptedRunner{results: []runnerResult{{output: "boom", err: errors.New("boom")}}}
_, err := newTestRunner(inner).RunWithOutput(dummyCmdObj())
assert.Error(t, err)
assert.Equal(t, 1, inner.calls)
}
func TestRunWithOutputRetriesWhenLockErrorIsInOutput(t *testing.T) {
inner := &scriptedRunner{results: []runnerResult{
{output: "fatal: Unable to create '/repo/.git/index.lock': File exists.", err: errors.New("exit status 128")},
{output: "done", err: nil},
}}
output, err := newTestRunner(inner).RunWithOutput(dummyCmdObj())
assert.NoError(t, err)
assert.Equal(t, "done", output)
assert.Equal(t, 2, inner.calls)
}
func TestRunWithOutputRetriesWhenLockErrorIsOnlyInError(t *testing.T) {
// A streamed command (e.g. an amend run through the gpg helper) doesn't
// capture its output, so a lock failure surfaces only in the returned error
// with an empty output string. The retry logic must still recognize it.
inner := &scriptedRunner{results: []runnerResult{
{output: "", err: errors.New("fatal: Unable to create '/repo/.git/index.lock': File exists.")},
{output: "", err: nil},
}}
_, err := newTestRunner(inner).RunWithOutput(dummyCmdObj())
assert.NoError(t, err)
assert.Equal(t, 2, inner.calls)
}
func TestRunWithOutputGivesUpAfterMaxRetries(t *testing.T) {
results := make([]runnerResult, maxRetries)
for i := range results {
results[i] = runnerResult{err: errors.New("fatal: Unable to create '/repo/.git/index.lock': File exists.")}
}
inner := &scriptedRunner{results: results}
_, err := newTestRunner(inner).RunWithOutput(dummyCmdObj())
assert.Error(t, err)
assert.Equal(t, maxRetries, inner.calls)
}
func TestRunWithOutputRetriesLockErrorInLinkedWorktree(t *testing.T) {
// In a linked worktree the lock lives at .git/worktrees/<name>/index.lock
// rather than .git/index.lock, so only matching the bare "index.lock"
// fragment lets the retry fire there too.
inner := &scriptedRunner{results: []runnerResult{
{output: "", err: errors.New("fatal: Unable to create '/repo/.git/worktrees/feature/index.lock': File exists.")},
{output: "", err: nil},
}}
_, err := newTestRunner(inner).RunWithOutput(dummyCmdObj())
assert.NoError(t, err)
assert.Equal(t, 2, inner.calls)
}
+4 -1
View File
@@ -210,7 +210,10 @@ func (self *GitHubCommands) fetchRecentPRsAux(endpoint string, repoOwner string,
req.Header.Set("Authorization", "token "+token)
req.Header.Set("Content-Type", "application/json")
client := &http.Client{}
// Bound the request so that a dead or extremely slow network can't leave
// the pull-request refresh in flight for minutes. The data is auxiliary,
// so giving up and retrying on the next refresh beats waiting.
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, err
+45 -10
View File
@@ -227,9 +227,7 @@ type cmdHandler struct {
func (self *cmdObjRunner) runAndStream(cmdObj *CmdObj) error {
return self.runAndStreamAux(cmdObj, func(handler *cmdHandler, cmdWriter io.Writer) {
go func() {
_, _ = io.Copy(cmdWriter, handler.stdoutPipe)
}()
_, _ = io.Copy(cmdWriter, handler.stdoutPipe)
})
}
@@ -244,6 +242,10 @@ func (self *cmdObjRunner) runAndStreamAux(
} else {
cmdWriter = self.guiIO.newCmdWriterFn()
}
// The command's stdout and stderr are streamed to cmdWriter concurrently
// from separate goroutines (stderr via the MultiWriter below, stdout via
// onRun), so it must be safe for concurrent writes.
cmdWriter = &synchronizedWriter{writer: cmdWriter}
if cmdObj.ShouldLog() {
self.logCmdObj(cmdObj)
@@ -276,10 +278,29 @@ func (self *cmdObjRunner) runAndStreamAux(
t := time.Now()
onRun(handler, cmdWriter)
// Stream the command's output on a goroutine while it runs, but keep a
// handle on it: the buffers it fills (stdout, and combinedOutput when
// output is suppressed) must not be read below until it has finished.
streamingDone := make(chan struct{})
go utils.Safe(func() {
defer close(streamingDone)
onRun(handler, cmdWriter)
})
err = handler.wait()
// The command has exited; wait for the streaming goroutine to drain the
// last of its output before reading those buffers. A pty reader reaches
// EOF on its own now the process is gone, but the non-pty pipe never does,
// so close it to unblock the reader — the pipe is synchronous, so all
// output has already been read by now and nothing is lost.
if !cmdObj.ShouldUsePty() {
if closeErr := handler.close(); closeErr != nil {
self.log.Error(closeErr)
}
}
<-streamingDone
self.log.Infof("%s (%s)", cmdObj.ToString(), time.Since(t))
if err != nil {
@@ -354,10 +375,7 @@ func (self *cmdObjRunner) runAndDetectCredentialRequest(
return self.runAndStreamAux(cmdObj, func(handler *cmdHandler, cmdWriter io.Writer) {
tr := io.TeeReader(handler.stdoutPipe, cmdWriter)
go utils.Safe(func() {
self.processOutput(tr, handler.stdinPipe, promptUserForCredential, handler.close, cmdObj)
})
self.processOutput(tr, handler.stdinPipe, promptUserForCredential, handler.close, cmdObj)
})
}
@@ -451,6 +469,20 @@ func (self *cmdObjRunner) getCheckForCredentialRequestFunc() func([]byte) (Crede
}
}
// synchronizedWriter serializes writes to its underlying writer so that it can
// be written from multiple goroutines at once (see runAndStreamAux, which
// streams a command's stdout and stderr to one writer from two goroutines).
type synchronizedWriter struct {
mutex deadlock.Mutex
writer io.Writer
}
func (self *synchronizedWriter) Write(p []byte) (int, error) {
self.mutex.Lock()
defer self.mutex.Unlock()
return self.writer.Write(p)
}
type Buffer struct {
b bytes.Buffer
m deadlock.Mutex
@@ -482,8 +514,11 @@ func (self *cmdObjRunner) getCmdHandlerNonPty(cmd *exec.Cmd) (*cmdHandler, error
return &cmdHandler{
stdoutPipe: stdoutReader,
stdinPipe: buf,
close: func() error { return nil },
wait: cmd.Wait,
// Closing the read end makes a blocked read on it return, which is how
// runAndStreamAux unblocks and joins the streaming goroutine once the
// command has finished (the pipe delivers no EOF of its own).
close: func() error { return stdoutReader.Close() },
wait: cmd.Wait,
}, nil
}
+30 -23
View File
@@ -7,6 +7,7 @@ import (
"sync"
"unsafe"
"github.com/jesseduffield/lazygit/pkg/utils"
"golang.org/x/sys/windows"
)
@@ -15,15 +16,13 @@ type winPty struct {
inWrite *os.File
outRead *os.File
// mu guards the teardown state below and serializes it against Resize.
// hpcClosed gates ClosePseudoConsole (it must run exactly once) and also
// keeps Resize from touching the HPCON once it's been freed: the
// background waiter in StartPty closes the pseudoconsole on child exit,
// which would otherwise race a concurrent onResize and hand
// ResizePseudoConsole a freed handle.
// mu guards hpcClosed, which gates ClosePseudoConsole (it must run
// exactly once) and also keeps Resize from touching the HPCON once it's
// been freed: the background waiter in StartPty closes the pseudoconsole
// on child exit, which would otherwise race a concurrent onResize and
// hand ResizePseudoConsole a freed handle.
mu sync.Mutex
hpcClosed bool
closed bool
}
func (p *winPty) Read(buf []byte) (int, error) { return p.outRead.Read(buf) }
@@ -49,11 +48,6 @@ func (p *winPty) Resize(cols, rows uint16) error {
func (p *winPty) closeHpc() {
p.mu.Lock()
defer p.mu.Unlock()
p.closeHpcLocked()
}
// closeHpcLocked closes the pseudoconsole; the caller must hold p.mu.
func (p *winPty) closeHpcLocked() {
if p.hpcClosed {
return
}
@@ -61,18 +55,31 @@ func (p *winPty) closeHpcLocked() {
windows.ClosePseudoConsole(p.hpc)
}
// Close tears the pty down without waiting for it: the teardown runs on a
// background goroutine and Close returns immediately.
//
// It has to, because ClosePseudoConsole can block for a long time: before
// Windows 11 24H2 it waits for the console host to exit, and since closing
// only delivers CTRL_CLOSE_EVENT to the attached client without terminating
// it, a client that keeps running (git still computing an expensive diff, a
// pager waiting for input) keeps the host — and with it ClosePseudoConsole —
// alive arbitrarily long. Close is called while holding the global PtyMutex
// and while the task's onDone once is executing, where blocking wedges every
// subsequent task for the view (and with it the UI), so none of this may
// happen on the caller's thread.
//
// Within the teardown, the pipe ends must be closed before the
// pseudoconsole, and without holding p.mu: closing the pseudoconsole flushes
// the client's pending output into the out pipe, and with the task stopped
// nobody is reading anymore, so that flush can only complete once the pipe
// is broken. The background waiter's closeHpc may already be wedged in such
// a flush while holding p.mu; closing the pipes is what unblocks it.
func (p *winPty) Close() error {
p.mu.Lock()
defer p.mu.Unlock()
if p.closed {
return nil
}
p.closed = true
// Closing the pseudoconsole breaks the pipes; the child's next write
// fails and it exits. Then we close our ends of the pipes.
p.closeHpcLocked()
p.inWrite.Close()
p.outRead.Close()
go utils.Safe(func() {
p.inWrite.Close()
p.outRead.Close()
p.closeHpc()
})
return nil
}
+53 -10
View File
@@ -6,6 +6,7 @@ import (
"github.com/jesseduffield/generics/maps"
"github.com/samber/lo"
"github.com/sasha-s/go-deadlock"
"github.com/sirupsen/logrus"
)
@@ -50,6 +51,13 @@ type PatchBuilder struct {
fileInfoMap map[string]*fileInfo
Log *logrus.Entry
// mutex guards the fields that a git worker can mutate (via Reset, at the
// end of a patch-consuming operation) while the UI thread reads them to
// render — chiefly To and the fileInfoMap pointer. The map's *entries* are
// only ever touched on the UI thread, so we only hold the lock long enough
// to read or swap the fields, never across the git I/O in getFileInfo.
mutex deadlock.Mutex
// loadFileDiff loads the diff of a file, for a given to (typically a commit hash)
loadFileDiff loadFileDiffFunc
}
@@ -62,6 +70,9 @@ func NewPatchBuilder(log *logrus.Entry, loadFileDiff loadFileDiffFunc) *PatchBui
}
func (p *PatchBuilder) Start(from, to string, reverse bool, canRebase bool) {
p.mutex.Lock()
defer p.mutex.Unlock()
p.To = to
p.From = from
p.reverse = reverse
@@ -69,10 +80,21 @@ func (p *PatchBuilder) Start(from, to string, reverse bool, canRebase bool) {
p.fileInfoMap = map[string]*fileInfo{}
}
// snapshotFileInfoMap returns the current fileInfoMap under the lock. The map's
// entries are only mutated on the UI thread, so callers can read the returned
// map without holding the lock; the lock only serializes the pointer swap that
// Reset/Start do (potentially from a git worker) against these reads.
func (p *PatchBuilder) snapshotFileInfoMap() map[string]*fileInfo {
p.mutex.Lock()
defer p.mutex.Unlock()
return p.fileInfoMap
}
func (p *PatchBuilder) PatchToApply(reverse bool, turnAddedFilesIntoDiffAgainstEmptyFile bool) string {
var patch strings.Builder
for filename, info := range p.fileInfoMap {
for filename, info := range p.snapshotFileInfoMap() {
if info.mode == UNSELECTED {
continue
}
@@ -130,12 +152,17 @@ func (p *PatchBuilder) RemoveFile(filename string, previousPath string) error {
}
func (p *PatchBuilder) getFileInfo(filename string, previousPath string) (*fileInfo, error) {
info, ok := p.fileInfoMap[filename]
p.mutex.Lock()
fileInfoMap := p.fileInfoMap
from, to, reverse := p.From, p.To, p.reverse
p.mutex.Unlock()
info, ok := fileInfoMap[filename]
if ok {
return info, nil
}
diff, err := p.loadFileDiff(p.From, p.To, p.reverse, filename, previousPath, true)
diff, err := p.loadFileDiff(from, to, reverse, filename, previousPath, true)
if err != nil {
return nil, err
}
@@ -145,7 +172,7 @@ func (p *PatchBuilder) getFileInfo(filename string, previousPath string) (*fileI
previousPath: previousPath,
}
p.fileInfoMap[filename] = info
fileInfoMap[filename] = info
return info, nil
}
@@ -220,14 +247,16 @@ func (p *PatchBuilder) RenderPatchForFile(opts RenderPatchForFileOpts) string {
}
func (p *PatchBuilder) renderEachFilePatch(plain bool) []string {
fileInfoMap := p.snapshotFileInfoMap()
// sort files by name then iterate through and render each patch
filenames := maps.Keys(p.fileInfoMap)
filenames := maps.Keys(fileInfoMap)
sort.Strings(filenames)
patches := lo.Map(filenames, func(filename string, _ int) string {
return p.RenderPatchForFile(RenderPatchForFileOpts{
Filename: filename,
PreviousPath: p.fileInfoMap[filename].previousPath,
PreviousPath: fileInfoMap[filename].previousPath,
Plain: plain,
Reverse: false,
TurnAddedFilesIntoDiffAgainstEmptyFile: true,
@@ -245,11 +274,16 @@ func (p *PatchBuilder) RenderAggregatedPatch(plain bool) string {
}
func (p *PatchBuilder) GetFileStatus(filename string, parent string) PatchStatus {
if parent != p.To {
p.mutex.Lock()
to := p.To
fileInfoMap := p.fileInfoMap
p.mutex.Unlock()
if parent != to {
return UNSELECTED
}
info, ok := p.fileInfoMap[filename]
info, ok := fileInfoMap[filename]
if !ok {
return UNSELECTED
}
@@ -267,16 +301,22 @@ func (p *PatchBuilder) GetFileIncLineIndices(filename string, previousPath strin
// clears the patch
func (p *PatchBuilder) Reset() {
p.mutex.Lock()
defer p.mutex.Unlock()
p.To = ""
p.fileInfoMap = map[string]*fileInfo{}
}
func (p *PatchBuilder) Active() bool {
p.mutex.Lock()
defer p.mutex.Unlock()
return p.To != ""
}
func (p *PatchBuilder) IsEmpty() bool {
for _, fileInfo := range p.fileInfoMap {
for _, fileInfo := range p.snapshotFileInfoMap() {
if fileInfo.mode == WHOLE || (fileInfo.mode == PART && len(fileInfo.includedLineIndices) > 0) {
return false
}
@@ -287,9 +327,12 @@ func (p *PatchBuilder) IsEmpty() bool {
// if any of these things change we'll need to reset and start a new patch
func (p *PatchBuilder) NewPatchRequired(from string, to string, reverse bool) bool {
p.mutex.Lock()
defer p.mutex.Unlock()
return from != p.From || to != p.To || reverse != p.reverse
}
func (p *PatchBuilder) AllFilesInPatch() []string {
return lo.Keys(p.fileInfoMap)
return lo.Keys(p.snapshotFileInfoMap())
}
+98
View File
@@ -0,0 +1,98 @@
package gocui
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestEventWithheldWhileBlocking(t *testing.T) {
scenarios := []struct {
name string
event GocuiEvent
withheld bool
}{
{"key", GocuiEvent{Type: eventKey, Key: NewKeyRune('x')}, true},
{"mouse click", GocuiEvent{Type: eventMouse, Key: NewKeyName(MouseLeft)}, true},
{"mouse scroll", GocuiEvent{Type: eventMouse, Key: NewKeyName(MouseWheelDown)}, false},
{"mouse move", GocuiEvent{Type: eventMouseMove}, true},
{"resize", GocuiEvent{Type: eventResize}, false},
{"focus", GocuiEvent{Type: eventFocus}, false},
{"paste", GocuiEvent{Type: eventPaste}, false},
{"error", GocuiEvent{Type: eventError}, false},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
assert.Equal(t, s.withheld, eventWithheldWhileBlocking(&s.event))
})
}
}
// setupKeyRecorder wires a keybinding on a focused view that records each time
// it fires, and returns the key event that triggers it plus the record slice.
func setupKeyRecorder(t *testing.T, g *Gui) (GocuiEvent, *[]int) {
t.Helper()
_, _ = g.SetView("main", 0, 0, 80, 22, 0)
_, err := g.SetCurrentView("main")
assert.NoError(t, err)
fired := []int{}
callCount := 0
key := NewKeyRune('x')
g.SetKeybinding("main", key, func(*Gui, *View) error {
callCount++
fired = append(fired, callCount)
return nil
})
return GocuiEvent{Type: eventKey, Key: key}, &fired
}
func TestBlockingEvents_KeysBufferedAndReplayed(t *testing.T) {
g := newTestGui(t)
keyEvent, fired := setupKeyRecorder(t, g)
// Not blocking: the key dispatches immediately.
assert.NoError(t, g.handleEvent(&keyEvent))
assert.Len(t, *fired, 1)
// While blocking: the key is buffered, not dispatched.
g.BeginBlockingEvents()
assert.NoError(t, g.handleEvent(&keyEvent))
assert.NoError(t, g.handleEvent(&keyEvent))
assert.Len(t, *fired, 1, "buffered keys must not dispatch while blocking")
// Unblocking replays the buffered keys.
assert.NoError(t, g.EndBlockingEvents())
assert.Len(t, *fired, 3, "both buffered keys should replay on unblock")
assert.Empty(t, g.bufferedKeyEvents)
}
func TestBlockingEvents_NestsWithCounter(t *testing.T) {
g := newTestGui(t)
keyEvent, fired := setupKeyRecorder(t, g)
g.BeginBlockingEvents()
g.BeginBlockingEvents()
assert.NoError(t, g.handleEvent(&keyEvent))
// The inner block ending still leaves us blocked: no replay yet.
assert.NoError(t, g.EndBlockingEvents())
assert.Empty(t, *fired)
// Only the outermost block ending replays.
assert.NoError(t, g.EndBlockingEvents())
assert.Len(t, *fired, 1)
}
func TestBlockingEvents_MouseClicksDroppedNotBuffered(t *testing.T) {
g := newTestGui(t)
g.BeginBlockingEvents()
click := GocuiEvent{Type: eventMouse, Key: NewKeyName(MouseLeft)}
assert.NoError(t, g.handleEvent(&click))
assert.Empty(t, g.bufferedKeyEvents, "mouse clicks must be dropped, not buffered")
assert.NoError(t, g.EndBlockingEvents())
}
+11 -3
View File
@@ -34,6 +34,14 @@ type escapeInterpreter struct {
// modelled — we don't track the col argument of CUPs, and most
// pager-style emitters use col 1 anyway.
screenRow, screenCol int
// The screen width that soft-wraps are counted against (see
// notifyCellsWritten). It's a snapshot of the view's InnerWidth taken on
// the UI thread (in NewView, and refreshed per render via
// View.SetContentWidth), rather than read live from the view's dimensions:
// a view's output is written from a task goroutine, and reading the live
// dimensions there would race the UI thread updating them during layout.
screenColMax int
}
type (
@@ -175,8 +183,8 @@ func (ei *escapeInterpreter) notifyColumnReset() {
// columns; if that crosses the right edge of a `screenColMax`-wide pty
// screen, the corresponding number of soft-wraps are added to screenRow
// so subsequent CUPs land on the right line.
func (ei *escapeInterpreter) notifyCellsWritten(width, screenColMax int) {
if screenColMax <= 0 {
func (ei *escapeInterpreter) notifyCellsWritten(width int) {
if ei.screenColMax <= 0 {
return
}
// One column at a time: matches ConPTY's "pending wrap" semantics
@@ -185,7 +193,7 @@ func (ei *escapeInterpreter) notifyCellsWritten(width, screenColMax int) {
// columns rather than doing the math in one shot so wide cells on a
// row boundary still wrap cleanly.
for range width {
if ei.screenCol > screenColMax {
if ei.screenCol > ei.screenColMax {
ei.screenRow++
ei.screenCol = 1
}
+5 -5
View File
@@ -39,15 +39,15 @@ func setupViews(t *testing.T, g *Gui) (*View, *View) {
return status, main
}
// pushContentOnly pushes a content-only event directly to the channel
// (synchronous, deterministic — unlike Update which spawns a goroutine).
// pushContentOnly enqueues a content-only event directly, letting the test
// control the contentOnly flag (which Update/UpdateContentOnly hard-code).
func pushContentOnly(g *Gui, f func(*Gui) error) {
g.userEvents <- userEvent{f: f, task: g.NewTask(), contentOnly: true}
g.userEvents.enqueue(userEvent{f: f, task: g.NewTask(), contentOnly: true})
}
// pushRegular pushes a regular event directly to the channel.
// pushRegular enqueues a regular (non-content-only) event directly.
func pushRegular(g *Gui, f func(*Gui) error) {
g.userEvents <- userEvent{f: f, task: g.NewTask(), contentOnly: false}
g.userEvents.enqueue(userEvent{f: f, task: g.NewTask(), contentOnly: false})
}
func TestFlushContentOnly_SkipsUntaintedViews(t *testing.T) {
+370 -43
View File
@@ -9,11 +9,13 @@ import (
"runtime"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/gdamore/tcell/v3"
"github.com/go-errors/errors"
"github.com/jesseduffield/generics/set"
"github.com/petermattis/goid"
"github.com/rivo/uniseg"
"github.com/samber/lo"
)
@@ -123,15 +125,18 @@ type clickInfo struct {
// and keybindings.
type Gui struct {
RecordingConfig
// ReplayedEvents is for passing pre-recorded input events, for the purposes of testing
ReplayedEvents replayedEvents
// replayedEvents is for passing simulated input events, for the purposes
// of testing. Events must be submitted through the Replay* methods, which
// attach a task to each event; pushing into the channels directly would
// bypass the busy-tracking that integration tests rely on.
replayedEvents replayedEvents
playRecording bool
tabClickBindings []*tabClickBinding
viewMouseBindings []*ViewMouseBinding
lastClick *clickInfo
gEvents chan GocuiEvent
userEvents chan userEvent
userEvents *userEventQueue
views []*View
currentView *View
managers []Manager
@@ -143,6 +148,10 @@ type Gui struct {
maxX, maxY int
outputMode OutputMode
stop chan struct{}
// loopExited is closed when MainLoop returns, so callers (e.g. the
// integration-test harness) can wait for the event loop to actually finish
// rather than polling or sleeping a fixed interval.
loopExited chan struct{}
// BgColor and FgColor allow to configure the background and foreground
// colors of the GUI.
@@ -193,7 +202,26 @@ type Gui struct {
taskManager *TaskManager
// The task of the event currently being processed on the main goroutine, if
// any. Only touched from the main goroutine (in processEvent). It's excluded
// from the Busy() check so that an event handler asking "is anything else
// busy?" doesn't count itself.
currentTask Task
lastHoverView *View
// uiThreadID is the goroutine id of the main event loop, recorded when
// MainLoop starts. IsUIThread compares against it. Written once, read from
// worker goroutines, so it's atomic.
uiThreadID atomic.Int64
// blockInputCount, when greater than zero, withholds keyboard input from
// the handlers: key events are buffered into bufferedKeyEvents and replayed
// once the count drops back to zero, while mouse clicks and hover are
// dropped outright. It's a counter so blocking can nest. Both fields are
// only touched on the UI thread. See BeginBlockingEvents.
blockInputCount int
bufferedKeyEvents []GocuiEvent
}
type NewGuiOpts struct {
@@ -236,18 +264,14 @@ func NewGui(opts NewGuiOpts) (*Gui, error) {
g.outputMode = opts.OutputMode
g.stop = make(chan struct{})
g.loopExited = make(chan struct{})
g.gEvents = make(chan GocuiEvent, 20)
// Update does a non-blocking send and panics on a full channel rather than
// blocking (which would deadlock the UI goroutine against itself) or
// silently reordering. The buffer is sized well above the peak occupancy we
// see in practice, so the panic stays unreachable in normal use; if it ever
// fires, that's a real anomaly to investigate, not a cue to grow the buffer.
g.userEvents = make(chan userEvent, 256)
g.userEvents = newUserEventQueue()
g.taskManager = newTaskManager()
if opts.PlayRecording {
g.ReplayedEvents = replayedEvents{
g.replayedEvents = replayedEvents{
Keys: make(chan *TcellKeyEventWrapper),
Resizes: make(chan *TcellResizeEventWrapper),
MouseEvents: make(chan *TcellMouseEventWrapper),
@@ -269,18 +293,63 @@ func NewGui(opts NewGuiOpts) (*Gui, error) {
g.playRecording = opts.PlayRecording
// Record the UI thread here, at construction. This assumes NewGui is called
// on the same goroutine that will run MainLoop, which holds for all our
// callers -- and it means IsUIThread is already correct for the UI work that
// runs during startup, before we reach MainLoop.
g.uiThreadID.Store(goid.Get())
return g, nil
}
func (g *Gui) NewTask() *TaskImpl {
return g.taskManager.NewTask()
return g.taskManager.NewTask(false)
}
// An idle listener listens for when the program is idle. This is useful for
// integration tests which can wait for the program to be idle before taking
// the next step in the test.
func (g *Gui) AddIdleListener(c chan struct{}) {
g.taskManager.addIdleListener(c)
// NewBackgroundTask creates a task that is tracked for idle detection but does
// not count towards the program being busy for repo-switch safety. See
// TaskImpl.background.
func (g *Gui) NewBackgroundTask() *TaskImpl {
return g.taskManager.NewTask(true)
}
// ReplayKeyEvent simulates a key press, as if the user had typed it. It's used
// by integration tests. The event carries a task, so that the program counts
// as busy from before the event is submitted until the main loop has fully
// processed it; the test driver relies on this when it waits for the program
// to go idle after submitting an event. (If the task were only created once
// the main loop picks the event up, there would be a window in which the event
// is still in flight but nothing counts as busy.)
func (g *Gui) ReplayKeyEvent(ev *TcellKeyEventWrapper) {
ev.task = g.NewTask()
g.replayedEvents.Keys <- ev
}
// ReplayMouseEvent is like ReplayKeyEvent, but for mouse events.
func (g *Gui) ReplayMouseEvent(ev *TcellMouseEventWrapper) {
ev.task = g.NewTask()
g.replayedEvents.MouseEvents <- ev
}
// ReplayFocusEvent is like ReplayKeyEvent, but for focus events.
func (g *Gui) ReplayFocusEvent(ev *TcellFocusEventWrapper) {
ev.task = g.NewTask()
g.replayedEvents.FocusEvents <- ev
}
// Busy reports whether any foreground work is in flight, ignoring the event
// currently being processed on the main goroutine (see currentTask). Background
// routines (auto-fetch etc.) don't count. It's used to decide whether it's safe
// to switch repos. Must be called on the main goroutine.
func (g *Gui) Busy() bool {
return g.taskManager.hasBusyForegroundTaskExcept(g.currentTask)
}
// WaitUntilIdle blocks until the program is idle (no busy tasks). This is
// useful for integration tests which want to wait for the program to finish
// processing before taking the next step in the test.
func (g *Gui) WaitUntilIdle() {
g.taskManager.WaitUntilIdle()
}
// Close finalizes the library. It should be called after a successful
@@ -290,6 +359,11 @@ func (g *Gui) Close() {
Screen.Fini()
}
// LoopExited returns a channel that is closed once MainLoop has returned.
func (g *Gui) LoopExited() <-chan struct{} {
return g.loopExited
}
// Size returns the terminal's size.
func (g *Gui) Size() (x, y int) {
return g.maxX, g.maxY
@@ -327,7 +401,7 @@ func (g *Gui) SetView(name string, x0, y0, x1, y1 int, overlaps byte) (*View, er
v.y1 = y1
if sizeChanged {
v.clearViewLines()
v.ClearViewLines()
if v.Editable {
cursorX, cursorY := v.TextArea.GetCursorXY()
@@ -608,6 +682,13 @@ func (g *Gui) SetRenderSearchStatusFunc(renderSearchStatusFunc func(*View, int,
g.renderSearchStatusFunc = renderSearchStatusFunc
}
// SetUpdateQueueHighWaterMarkHandler registers a diagnostic callback invoked
// with the new depth whenever the queue of pending Update callbacks reaches a
// new maximum. It may be called from any goroutine.
func (g *Gui) SetUpdateQueueHighWaterMarkHandler(f func(depth int)) {
g.userEvents.setHighWaterMarkHandler(f)
}
// userEvent represents an event triggered by the user.
type userEvent struct {
f func(*Gui) error
@@ -618,29 +699,204 @@ type userEvent struct {
contentOnly bool
}
// Update enqueues f on the user-events channel for the UI loop to run on its
// next iteration. Multiple Update calls from the same goroutine arrive in
// source order via the channel's FIFO. The send is non-blocking — if the
// channel is full we panic rather than block or silently reorder, since a
// blocked send from the UI goroutine would deadlock against itself and
// silently switching to inline execution would break the ordering guarantee
// callers rely on. The buffer is sized generously enough that this should
// never fire in practice; if it does, that's a signal to investigate, not
// to grow the buffer reflexively.
func (g *Gui) Update(f func(*Gui) error) {
task := g.NewTask()
// userEventQueue is an unbounded, order-preserving FIFO of work enqueued by
// Update and friends for the main loop to run.
//
// It's unbounded (rather than a fixed-size channel) because producers must
// never block or lose work. Update can be called from the UI goroutine itself,
// where a blocking send would deadlock against the loop that drains the queue;
// and it can be called from arbitrary worker goroutines that may enqueue faster
// than the loop drains. That happens while the loop is stalled — suspended for
// a subprocess (the editor runs on the UI thread), or hung in a long handler —
// and also when a long-running worker operation emits a steady stream of
// updates that outpaces the loop (e.g. the waiting-status spinner ticks while a
// large directory is toggled into a custom patch). A fixed channel forces a
// choice between blocking (deadlock), dropping or reordering, and panicking on
// overflow; an unbounded queue avoids all three while preserving FIFO order.
//
// enqueue appends under the mutex and rings the doorbell; the main loop selects
// on the doorbell to wake, then drains the slice to empty. The doorbell is
// buffered(1) and rung with a non-blocking send, so it's a coalescing "work
// pending" flag rather than a per-event signal: a burst of appends leaves at
// most one token, and the loop drains everything the token represents on a
// single wake. A token left over after a drain (because the drain happened to
// empty the slice after the ring) just causes one harmless empty wake.
type userEventQueue struct {
mutex sync.Mutex
events []userEvent
doorbell chan struct{}
// highWaterMark is the deepest the queue has ever been, and
// onHighWaterMark (if set) is called with the new depth each time that
// record is broken. Purely diagnostic: it lets us see how deep the queue
// gets in practice (see SetUpdateQueueHighWaterMarkHandler).
highWaterMark int
onHighWaterMark func(int)
}
func newUserEventQueue() *userEventQueue {
return &userEventQueue{doorbell: make(chan struct{}, 1)}
}
// enqueue appends an event and wakes the main loop. It never blocks.
func (q *userEventQueue) enqueue(ev userEvent) {
q.mutex.Lock()
q.events = append(q.events, ev)
newHighWaterMark := 0
if len(q.events) > q.highWaterMark {
q.highWaterMark = len(q.events)
newHighWaterMark = q.highWaterMark
}
onHighWaterMark := q.onHighWaterMark
q.mutex.Unlock()
// Report outside the lock: the handler does I/O (logging) and must not
// stall other producers or the draining loop.
if newHighWaterMark > 0 && onHighWaterMark != nil {
onHighWaterMark(newHighWaterMark)
}
select {
case g.userEvents <- userEvent{f: f, task: task}:
case q.doorbell <- struct{}{}:
default:
panic("gocui: userEvents channel full; refusing to block or reorder")
}
}
func (q *userEventQueue) setHighWaterMarkHandler(f func(int)) {
q.mutex.Lock()
q.onHighWaterMark = f
q.mutex.Unlock()
}
// dequeue pops the oldest event, reporting false when the queue is empty.
func (q *userEventQueue) dequeue() (userEvent, bool) {
q.mutex.Lock()
defer q.mutex.Unlock()
if len(q.events) == 0 {
return userEvent{}, false
}
ev := q.events[0]
if len(q.events) == 1 {
// Release the backing array whenever the queue drains, so a one-off
// burst doesn't pin its peak size for the rest of the session.
q.events = nil
} else {
q.events[0] = userEvent{}
q.events = q.events[1:]
}
return ev, true
}
// Update enqueues f for the UI loop to run on its next iteration. Multiple
// Update calls from the same goroutine arrive in source order (the queue is
// FIFO). The enqueue never blocks and never drops work; see userEventQueue for
// why the queue is unbounded.
func (g *Gui) Update(f func(*Gui) error) {
g.update(f, false)
}
// Like Update, but the enqueued work is a background routine (or triggered by
// one), so it doesn't count towards the program being busy for repo-switch
// safety. See TaskImpl.background.
func (g *Gui) UpdateBackground(f func(*Gui) error) {
g.update(f, true)
}
func (g *Gui) update(f func(*Gui) error, background bool) {
task := g.taskManager.NewTask(background)
g.userEvents.enqueue(userEvent{f: f, task: task})
}
// Like Update, but signals that the callback only modifies content.
func (g *Gui) UpdateContentOnly(f func(*Gui) error) {
task := g.NewTask()
g.userEvents <- userEvent{f: f, task: task, contentOnly: true}
g.updateContentOnly(f, false)
}
// Like UpdateContentOnly, but for background work (see UpdateBackground).
func (g *Gui) UpdateContentOnlyBackground(f func(*Gui) error) {
g.updateContentOnly(f, true)
}
func (g *Gui) updateContentOnly(f func(*Gui) error, background bool) {
task := g.taskManager.NewTask(background)
g.userEvents.enqueue(userEvent{f: f, task: task, contentOnly: true})
}
// IsUIThread reports whether the caller is running on the main event-loop
// goroutine (the one running MainLoop). It calls goid.Get, so use it only for
// debug assertions, not to drive production control flow.
func (g *Gui) IsUIThread() bool {
return goid.Get() == g.uiThreadID.Load()
}
// BeginBlockingEvents starts withholding keyboard input from the handlers, so a
// long-running operation can't be disrupted by keys the user presses while it
// runs. Keys are buffered and replayed once EndBlockingEvents balances this
// call; mouse clicks and hover are dropped for the duration. Scrolling,
// resizing, focus changes and all rendering keep working throughout. It's a
// counter, so blocking nests; every call must be paired with EndBlockingEvents.
//
// Must be called on the UI thread. Callers arrange this by beginning the block
// synchronously from the keybinding handler, before dispatching the operation
// to a worker — beginning it from the worker would race the next queued
// keypress, which is exactly the input we mean to withhold.
func (g *Gui) BeginBlockingEvents() {
g.blockInputCount++
}
// EndBlockingEvents balances a BeginBlockingEvents call. When the last nested
// block ends, the keys buffered while blocked are replayed in order through the
// normal dispatch path, so they act on the now-current context (a key whose
// binding no longer exists is simply ignored, just as if it had been pressed
// now). Must be called on the UI thread.
func (g *Gui) EndBlockingEvents() error {
g.blockInputCount--
if g.blockInputCount > 0 {
return nil
}
buffered := g.bufferedKeyEvents
g.bufferedKeyEvents = nil
for i := range buffered {
if err := g.handleEvent(&buffered[i]); err != nil {
return err
}
}
return nil
}
// OnUIThreadAndWait runs f on the main event-loop goroutine and blocks the
// caller until f has run, returning f's error. Use it to read UI-thread-owned
// state (the model, contexts) from a worker without racing the UI thread.
//
// It must be called from a worker goroutine, never from the UI thread itself:
// the UI thread would block waiting for a callback only it can run, which
// deadlocks. Callers arrange this by construction (see the refresh helper's
// RefreshFromWorker); a debug-only assertion there guards against getting it
// wrong.
func (g *Gui) OnUIThreadAndWait(f func() error) error {
return g.onUIThreadAndWait(f, false)
}
// Like OnUIThreadAndWait, but the enqueued work belongs to a background routine,
// so it doesn't count towards the program being busy (see UpdateBackground).
func (g *Gui) OnUIThreadAndWaitBackground(f func() error) error {
return g.onUIThreadAndWait(f, true)
}
func (g *Gui) onUIThreadAndWait(f func() error, background bool) error {
enqueue := g.Update
if background {
enqueue = g.UpdateBackground
}
result := make(chan error, 1)
enqueue(func(*Gui) error {
result <- f()
return nil
})
return <-result
}
// Calls a function in a goroutine. Handles panics gracefully and tracks
@@ -650,7 +906,18 @@ func (g *Gui) UpdateContentOnly(f func(*Gui) error) {
// background goroutines where you wouldn't want lazygit to be considered busy
// (i.e. when you wouldn't want a loader to be shown to the user)
func (g *Gui) OnWorker(f func(Task) error) {
task := g.NewTask()
g.onWorker(f, false)
}
// Like OnWorker, but for a background routine (or work triggered by one), so it
// doesn't count towards the program being busy for repo-switch safety. See
// TaskImpl.background.
func (g *Gui) OnWorkerBackground(f func(Task) error) {
g.onWorker(f, true)
}
func (g *Gui) onWorker(f func(Task) error, background bool) {
task := g.taskManager.NewTask(background)
go func() {
g.onWorkerAux(f, task)
task.Done()
@@ -714,6 +981,8 @@ func (g *Gui) SetManagerFunc(manager func(*Gui) error) {
// MainLoop runs the main loop until an error is returned. A successful
// finish should return ErrQuit.
func (g *Gui) MainLoop() error {
defer close(g.loopExited)
go func() {
for {
select {
@@ -758,17 +1027,37 @@ func (g *Gui) handleError(err error) error {
func (g *Gui) processEvent() error {
contentOnly := false
// currentTask is the task of the event we're about to handle; recording it
// lets Busy() ignore it, so a handler asking "is anything else busy?" (the
// repo-switch guard does) doesn't count itself. Handlers of the remaining
// events drained below run with currentTask still set to this primary event;
// that's fine because the only Busy() callers are keybinding handlers, which
// are always the primary event here.
select {
case ev := <-g.gEvents:
task := g.NewTask()
defer func() { task.Done() }()
// Replayed test events already carry their task (see ReplayKeyEvent);
// organic events get theirs here.
task := ev.task
if task == nil {
task = g.NewTask()
}
g.currentTask = task
defer func() { g.currentTask = nil; task.Done() }()
if err := g.handleError(g.handleEvent(&ev)); err != nil {
return err
}
case ev := <-g.userEvents:
case <-g.userEvents.doorbell:
ev, ok := g.userEvents.dequeue()
if !ok {
// A leftover doorbell token whose events were already drained by a
// previous iteration's processRemainingEvents: nothing to run and
// nothing new to render.
return nil
}
contentOnly = ev.contentOnly
defer func() { ev.task.Done() }()
g.currentTask = ev.task
defer func() { g.currentTask = nil; ev.task.Done() }()
if err := g.handleError(ev.f(g)); err != nil {
return err
@@ -795,18 +1084,27 @@ func (g *Gui) processRemainingEvents() (bool, error) {
select {
case ev := <-g.gEvents:
contentOnly = false
if err := g.handleError(g.handleEvent(&ev)); err != nil {
err := g.handleError(g.handleEvent(&ev))
if ev.task != nil {
ev.task.Done()
}
if err != nil {
return false, err
}
case ev := <-g.userEvents:
default:
// No gui event is pending; drain a queued user event instead.
// gui events take priority so input stays responsive, but they're
// bounded (buffer of 20), so this can't starve the user-event queue.
ev, ok := g.userEvents.dequeue()
if !ok {
return contentOnly, nil
}
contentOnly = ev.contentOnly && contentOnly
err := g.handleError(ev.f(g))
ev.task.Done()
if err != nil {
return false, err
}
default:
return contentOnly, nil
}
}
}
@@ -814,6 +1112,17 @@ func (g *Gui) processRemainingEvents() (bool, error) {
// handleEvent handles an event, based on its type (key-press, error,
// etc.)
func (g *Gui) handleEvent(ev *GocuiEvent) error {
if g.blockInputCount > 0 && eventWithheldWhileBlocking(ev) {
if ev.Type == eventKey {
// Buffer keys so they replay against fresh state on unblock.
g.bufferedKeyEvents = append(g.bufferedKeyEvents, *ev)
}
// Mouse clicks and hover fall through to here without being buffered:
// replaying them once the operation has changed the layout underneath
// them would target the wrong thing, so we drop them outright.
return nil
}
switch ev.Type {
case eventKey, eventMouse, eventMouseMove:
return g.onKey(ev)
@@ -832,6 +1141,24 @@ func (g *Gui) handleEvent(ev *GocuiEvent) error {
}
}
// eventWithheldWhileBlocking reports whether an event must not reach the
// handlers while input is blocked (see BeginBlockingEvents). Key events are
// withheld (buffered for replay); mouse clicks and hover are withheld (dropped).
// Everything else — mouse scrolling, resize, focus, paste, errors — flows
// through as usual.
func eventWithheldWhileBlocking(ev *GocuiEvent) bool {
switch ev.Type {
case eventKey:
return true
case eventMouse:
return !IsMouseScrollKey(ev.Key.KeyName())
case eventMouseMove:
return true
default:
return false
}
}
func (g *Gui) onResize() {
// not sure if we actually need this
// g.screen.Sync()
@@ -1150,7 +1477,7 @@ func (g *Gui) flush() error {
// if GUI's size has changed, we need to redraw all views
if maxX != g.maxX || maxY != g.maxY {
for _, v := range g.views {
v.clearViewLines()
v.ClearViewLines()
}
}
g.maxX, g.maxY = maxX, maxY
@@ -1189,7 +1516,7 @@ func viewsToRedrawContentOnly(views []*View) []*View {
redrawIndexes := set.New[int]()
for i, v := range views {
if !v.tainted && !redrawIndexes.Includes(i) {
if !v.IsTainted() && !redrawIndexes.Includes(i) {
continue
}
+21 -1
View File
@@ -8,8 +8,9 @@ type Task interface {
Done()
Pause()
Continue()
// not exporting because we don't need to
// not exporting these because we don't need to
isBusy() bool
isBackground() bool
}
type TaskImpl struct {
@@ -17,6 +18,17 @@ type TaskImpl struct {
busy bool
onDone func()
withMutex func(func())
// Background tasks don't count towards the program being "busy" for the
// purpose of deciding whether a repo switch is safe (see
// TaskManager.hasBusyForegroundTaskExcept). Two kinds of work are tagged
// this way: the ongoing background routines (auto-fetch, files refresh,
// external-change detection) and the refreshes they trigger, whose model
// writes are already guarded against a concurrent repo switch by the repo
// generation; and view-buffer content rendering, which only paints a view
// and so is harmless to leave running across a switch. What stays
// foreground is lazygit driving a git operation and applying its results
// to the model — exactly the work a repo switch must not run underneath.
background bool
}
func (self *TaskImpl) Done() {
@@ -39,6 +51,10 @@ func (self *TaskImpl) isBusy() bool {
return self.busy
}
func (self *TaskImpl) isBackground() bool {
return self.background
}
type TaskStatus int
const (
@@ -73,6 +89,10 @@ func (self *FakeTask) isBusy() bool {
return self.status == TaskStatusBusy
}
func (self *FakeTask) isBackground() bool {
return false
}
func (self *FakeTask) Status() TaskStatus {
return self.status
}
+59 -21
View File
@@ -6,23 +6,26 @@ import "sync"
// the main goroutine or a worker goroutine). Used by integration tests
// to wait until the program is idle before progressing.
type TaskManager struct {
// each of these listeners will be notified when the program goes from busy to idle
idleListeners []chan struct{}
tasks map[int]Task
tasks map[int]Task
// auto-incrementing id for new tasks
nextId int
mutex sync.Mutex
// signalled whenever the program transitions from busy to idle; used by
// WaitUntilIdle
idleCond *sync.Cond
}
func newTaskManager() *TaskManager {
return &TaskManager{
tasks: make(map[int]Task),
idleListeners: []chan struct{}{},
self := &TaskManager{
tasks: make(map[int]Task),
}
self.idleCond = sync.NewCond(&self.mutex)
return self
}
func (self *TaskManager) NewTask() *TaskImpl {
func (self *TaskManager) NewTask(background bool) *TaskImpl {
self.mutex.Lock()
defer self.mutex.Unlock()
@@ -30,14 +33,54 @@ func (self *TaskManager) NewTask() *TaskImpl {
taskId := self.nextId
onDone := func() { self.delete(taskId) }
task := &TaskImpl{id: taskId, busy: true, onDone: onDone, withMutex: self.withMutex}
task := &TaskImpl{id: taskId, busy: true, background: background, onDone: onDone, withMutex: self.withMutex}
self.tasks[taskId] = task
return task
}
func (self *TaskManager) addIdleListener(c chan struct{}) {
self.idleListeners = append(self.idleListeners, c)
// hasBusyForegroundTaskExcept reports whether any task other than `ignore` is
// currently busy and not a background task. It's used to decide whether a repo
// switch is safe: a foreground operation (or the refresh it triggers, or that
// refresh's follow-up callbacks) still in flight means the switch must wait, so
// it doesn't run against a repo that's about to be swapped out.
//
// `ignore` is the event currently being processed on the UI thread — the switch
// attempt itself — which is always busy and so must not count as a reason to
// refuse itself.
func (self *TaskManager) hasBusyForegroundTaskExcept(ignore Task) bool {
self.mutex.Lock()
defer self.mutex.Unlock()
for _, task := range self.tasks {
if task != ignore && task.isBusy() && !task.isBackground() {
return true
}
}
return false
}
// WaitUntilIdle blocks until no task is busy. Integration tests use it to wait
// for the program to finish processing before taking the next step.
func (self *TaskManager) WaitUntilIdle() {
self.mutex.Lock()
defer self.mutex.Unlock()
for self.hasBusyTask() {
self.idleCond.Wait()
}
}
// caller must hold self.mutex
func (self *TaskManager) hasBusyTask() bool {
for _, task := range self.tasks {
if task.isBusy() {
return true
}
}
return false
}
func (self *TaskManager) withMutex(f func()) {
@@ -46,17 +89,12 @@ func (self *TaskManager) withMutex(f func()) {
f()
// Check if all tasks are done
for _, task := range self.tasks {
if task.isBusy() {
return
}
}
// If we get here, all tasks are done, so
// notify listeners that the program is idle
for _, listener := range self.idleListeners {
listener <- struct{}{}
// Wake up any goroutine blocked in WaitUntilIdle. This must not block on
// the waiter (we hold the mutex, and the waiter may itself be trying to
// acquire it, e.g. by creating a task, before it next waits) — which is
// exactly what Broadcast guarantees.
if !self.hasBusyTask() {
self.idleCond.Broadcast()
}
}
+129
View File
@@ -0,0 +1,129 @@
package gocui
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestTaskManagerHasBusyForegroundTaskExcept(t *testing.T) {
t.Run("no tasks", func(t *testing.T) {
tm := newTaskManager()
assert.False(t, tm.hasBusyForegroundTaskExcept(nil))
})
t.Run("a busy foreground task counts", func(t *testing.T) {
tm := newTaskManager()
tm.NewTask(false)
assert.True(t, tm.hasBusyForegroundTaskExcept(nil))
})
t.Run("a busy background task does not count", func(t *testing.T) {
tm := newTaskManager()
tm.NewTask(true)
assert.False(t, tm.hasBusyForegroundTaskExcept(nil))
})
t.Run("a done foreground task does not count", func(t *testing.T) {
tm := newTaskManager()
task := tm.NewTask(false)
task.Done()
assert.False(t, tm.hasBusyForegroundTaskExcept(nil))
})
t.Run("a paused foreground task does not count", func(t *testing.T) {
tm := newTaskManager()
task := tm.NewTask(false)
task.Pause()
assert.False(t, tm.hasBusyForegroundTaskExcept(nil))
})
t.Run("the ignored task does not count", func(t *testing.T) {
tm := newTaskManager()
task := tm.NewTask(false)
assert.False(t, tm.hasBusyForegroundTaskExcept(task))
})
t.Run("another foreground task counts even when one is ignored", func(t *testing.T) {
tm := newTaskManager()
ignored := tm.NewTask(false)
tm.NewTask(false)
assert.True(t, tm.hasBusyForegroundTaskExcept(ignored))
})
t.Run("only a background task alongside the ignored current event", func(t *testing.T) {
// This is the repo-switch case: the switch is handled as the current
// event (ignored) while a background refresh is in flight; it must not
// be considered busy.
tm := newTaskManager()
current := tm.NewTask(false)
tm.NewTask(true)
assert.False(t, tm.hasBusyForegroundTaskExcept(current))
})
}
func TestTaskManagerWaitUntilIdle(t *testing.T) {
// returnsWithin reports whether f returns within the given duration.
returnsWithin := func(d time.Duration, f func()) bool {
done := make(chan struct{})
go func() {
f()
close(done)
}()
select {
case <-done:
return true
case <-time.After(d):
return false
}
}
t.Run("returns immediately when no task was ever created", func(t *testing.T) {
tm := newTaskManager()
assert.True(t, returnsWithin(time.Second, tm.WaitUntilIdle))
})
t.Run("blocks while a task is busy", func(t *testing.T) {
tm := newTaskManager()
tm.NewTask(false)
assert.False(t, returnsWithin(50*time.Millisecond, tm.WaitUntilIdle))
})
t.Run("wakes up when the last busy task completes", func(t *testing.T) {
tm := newTaskManager()
task := tm.NewTask(false)
go func() {
time.Sleep(10 * time.Millisecond)
task.Done()
}()
assert.True(t, returnsWithin(time.Second, tm.WaitUntilIdle))
})
t.Run("a paused task counts as idle", func(t *testing.T) {
tm := newTaskManager()
task := tm.NewTask(false)
task.Pause()
assert.True(t, returnsWithin(time.Second, tm.WaitUntilIdle))
})
t.Run("a task completing while nobody waits must not block", func(t *testing.T) {
// This is the deadlock case: the waiter (the integration-test runner)
// is between waits, and itself needs the task manager's mutex (it
// creates a task whenever it enqueues work) before it waits again. The
// idle notification must neither block the completing task while it
// holds the mutex, nor get lost.
tm := newTaskManager()
assert.True(t, returnsWithin(time.Second, func() {
// the program goes idle with nobody waiting...
tm.NewTask(true).Done()
// ...and creating and completing more tasks afterwards must still
// be possible
task := tm.NewTask(false)
tm.NewTask(false).Done()
task.Done()
}))
assert.True(t, returnsWithin(time.Second, tm.WaitUntilIdle))
})
}
+26 -4
View File
@@ -172,6 +172,12 @@ type GocuiEvent struct {
Focused bool
Start bool
N int
// task tracks the processing of this event for idle detection. Events
// replayed by integration tests carry a task from the moment they are
// submitted (see Gui.ReplayKeyEvent); for organic events it is nil, and
// the main loop creates a task when it picks the event up.
task Task
}
// Event types.
@@ -208,6 +214,8 @@ type TcellKeyEventWrapper struct {
Mod tcell.ModMask
Key tcell.Key
Ch string
task Task // see GocuiEvent.task
}
func NewTcellKeyEventWrapper(event *tcell.EventKey, timestamp int64) *TcellKeyEventWrapper {
@@ -229,6 +237,8 @@ type TcellMouseEventWrapper struct {
Y int
ButtonMask tcell.ButtonMask
ModMask tcell.ModMask
task Task // see GocuiEvent.task
}
func NewTcellMouseEventWrapper(event *tcell.EventMouse, timestamp int64) *TcellMouseEventWrapper {
@@ -269,6 +279,8 @@ func (wrapper TcellResizeEventWrapper) toTcellEvent() tcell.Event {
type TcellFocusEventWrapper struct {
Timestamp int64
Focused bool
task Task // see GocuiEvent.task
}
func NewTcellFocusEventWrapper(event *tcell.EventFocus, timestamp int64) *TcellFocusEventWrapper {
@@ -285,21 +297,31 @@ func (wrapper TcellFocusEventWrapper) toTcellEvent() tcell.Event {
// pollEvent get tcell.Event and transform it into gocuiEvent
func (g *Gui) pollEvent() GocuiEvent {
var tev tcell.Event
var task Task
if g.playRecording {
select {
case ev := <-g.ReplayedEvents.Keys:
case ev := <-g.replayedEvents.Keys:
tev = (ev).toTcellEvent()
case ev := <-g.ReplayedEvents.Resizes:
task = ev.task
case ev := <-g.replayedEvents.Resizes:
tev = (ev).toTcellEvent()
case ev := <-g.ReplayedEvents.MouseEvents:
case ev := <-g.replayedEvents.MouseEvents:
tev = (ev).toTcellEvent()
case ev := <-g.ReplayedEvents.FocusEvents:
task = ev.task
case ev := <-g.replayedEvents.FocusEvents:
tev = (ev).toTcellEvent()
task = ev.task
}
} else {
tev = <-Screen.EventQ()
}
event := gocuiEventFromTcellEvent(tev)
event.task = task
return event
}
func gocuiEventFromTcellEvent(tev tcell.Event) GocuiEvent {
switch tev := tev.(type) {
case *tcell.EventInterrupt:
return GocuiEvent{Type: eventInterrupt}
+111
View File
@@ -0,0 +1,111 @@
package gocui
import (
"sync"
"testing"
"github.com/stretchr/testify/assert"
)
// Enqueuing far more events than the old fixed 256-slot buffer, without the
// main loop draining them, used to panic ("userEvents channel full"). It must
// not: producers can legitimately burst faster than a stalled UI loop drains
// (e.g. one command-log entry per git command when adding a large directory to
// a custom patch, or any producer while the loop is blocked in a subprocess).
// The events must also stay in FIFO order.
func TestUpdateIsUnboundedAndPreservesOrder(t *testing.T) {
g := newTestGui(t)
const n = 1000
var got []int
for i := range n {
g.Update(func(*Gui) error {
got = append(got, i)
return nil
})
}
// Drain the whole queue the way the main loop's inner drain does.
_, err := g.processRemainingEvents()
assert.NoError(t, err)
want := make([]int, n)
for i := range want {
want[i] = i
}
assert.Equal(t, want, got)
}
// The high-water-mark handler fires only when the queue reaches a new maximum
// depth, reporting that depth. It does not reset when the queue drains.
func TestUpdateQueueHighWaterMark(t *testing.T) {
g := newTestGui(t)
var marks []int
g.SetUpdateQueueHighWaterMarkHandler(func(depth int) { marks = append(marks, depth) })
noop := func(*Gui) error { return nil }
// Three enqueues with no drain: new highs 1, 2, 3.
g.Update(noop)
g.Update(noop)
g.Update(noop)
_, err := g.processRemainingEvents()
assert.NoError(t, err)
// Two enqueues stay below the previous high of 3: no new marks.
g.Update(noop)
g.Update(noop)
_, err = g.processRemainingEvents()
assert.NoError(t, err)
// Four enqueues with no drain: only depth 4 beats the previous high.
for range 4 {
g.Update(noop)
}
assert.Equal(t, []int{1, 2, 3, 4}, marks)
}
// Concurrent producers must be able to enqueue safely (run under -race). Only
// same-goroutine order is guaranteed, so we check that every event is delivered
// exactly once and that each producer's own events stay in order.
func TestUpdateConcurrentProducers(t *testing.T) {
g := newTestGui(t)
const producers = 8
const perProducer = 500
type item struct{ producer, seq int }
var got []item
var wg sync.WaitGroup
for p := range producers {
wg.Add(1)
go func() {
defer wg.Done()
for seq := range perProducer {
g.Update(func(*Gui) error {
got = append(got, item{p, seq})
return nil
})
}
}()
}
// Update is a synchronous, non-blocking enqueue, so once every producer has
// returned, every event is in the queue and a single drain sees them all.
wg.Wait()
_, err := g.processRemainingEvents()
assert.NoError(t, err)
assert.Len(t, got, producers*perProducer)
lastSeq := make([]int, producers)
for p := range lastSeq {
lastSeq[p] = -1
}
for _, it := range got {
assert.Equal(t, lastSeq[it.producer]+1, it.seq, "producer %d events out of order", it.producer)
lastSeq[it.producer] = it.seq
}
}
+124 -44
View File
@@ -7,6 +7,7 @@ package gocui
import (
"fmt"
"io"
"slices"
"strings"
"sync"
"unicode"
@@ -50,6 +51,14 @@ type View struct {
// tained is true if the viewLines must be updated
tainted bool
// firstDirtyLine is the index of the lowest line in `lines` that has been
// written to or highlighted since viewLines was last refreshed, and whose
// cached wrapping (lineType.wrappedCells) may therefore be stale. Lines
// below it are unchanged and can reuse their cached wrapping instead of
// being re-wrapped, which keeps refreshViewLinesIfNeeded cheap while
// scrolling appends new lines to a long buffer.
firstDirtyLine int
// the last position that the mouse was hovering over; nil if the mouse is outside of
// this view, or not hovering over a cell
lastHoverPosition *pos
@@ -206,6 +215,16 @@ func (v *View) clearViewLines() {
v.clearHover()
}
// ClearViewLines is clearViewLines guarded by writeMutex. It's for callers on
// the UI thread (the layout pass) that touch a view whose content a task
// goroutine may be writing concurrently: viewLines/tainted/hover are all
// buffer state that writeMutex protects.
func (v *View) ClearViewLines() {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
v.clearViewLines()
}
type searcher struct {
searchString string
searchPositions []SearchPosition
@@ -457,6 +476,16 @@ type viewLine struct {
type lineType struct {
cells cells
trailingFillAttributes *trailingFillAttributes
// wrappedCells caches the result of wrapping `cells` to `wrappedColumns`
// columns, so that unchanged lines don't have to be re-wrapped on every
// refreshViewLinesIfNeeded (which runs on every scroll event, via
// ViewLinesHeight). Wrapping measures every cell's width and allocates, so
// for a long buffer that dominates the cost of scrolling. The cache is used
// only for lines below View.firstDirtyLine whose wrappedColumns still
// matches the current width; nil means nothing is cached yet.
wrappedCells [][]cell
wrappedColumns int
}
// trailingFillAttributes describes the fg/bg colors that draw() should
@@ -518,9 +547,20 @@ func NewView(name string, x0, y0, x1, y1 int, mode OutputMode) *View {
v.SelFgColor, v.SelBgColor = ColorDefault, ColorDefault
v.InactiveViewSelBgColor = ColorDefault
v.TitleColor, v.FrameColor = ColorDefault, ColorDefault
v.ei.screenColMax = v.InnerWidth()
return v
}
// SetContentWidth tells the view the screen width that content written to it
// should count soft-wraps against (see escapeInterpreter.notifyCellsWritten).
// Callers pass the view's InnerWidth; it's a separate call, made on the UI
// thread when a render starts, so that the task goroutine that streams the
// content can consult this snapshot instead of reading the view's live
// dimensions (which the UI thread mutates during layout).
func (v *View) SetContentWidth(width int) {
v.ei.screenColMax = width
}
// Dimensions returns the dimensions of the View
func (v *View) Dimensions() (int, int, int, int) {
return v.x0, v.y0, v.x1, v.y1
@@ -815,6 +855,9 @@ func (v *View) Write(p []byte) (n int, err error) {
func (v *View) write(p []byte) {
v.tainted = true
// write only ever touches lines from v.wy onwards, so any cached wrapping
// below that stays valid.
v.firstDirtyLine = min(v.firstDirtyLine, v.wy)
v.clearHover()
// Fill with empty cells, if writing outside current view buffer
@@ -886,7 +929,7 @@ func (v *View) write(p []byte) {
for _, c := range cells {
totalWidth += c.width
}
v.ei.notifyCellsWritten(totalWidth, v.InnerWidth())
v.ei.notifyCellsWritten(totalWidth)
}
}
}
@@ -1104,10 +1147,25 @@ func (v *View) CopyContent(from *View) {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
// A background task may be streaming output into the source view's buffer
// via Write, so read it under its own lock. The source is always a
// different view than the destination (see the sole caller,
// moveMainContextToTop), and no other code holds two view write locks at
// once, so this can't deadlock.
from.writeMutex.Lock()
defer from.writeMutex.Unlock()
v.clear()
v.lines = from.lines
v.viewLines = from.viewLines
// Clone the row slices rather than sharing them: the source view stays
// live (its streaming task keeps appending rows, and refreshViewLinesIfNeeded
// fills each row's wrapping cache in place via &lines[i]), so sharing the
// backing arrays would race those writes against this view's own rendering.
// This is a shallow clone -- the per-row cell data is immutable once written
// and stays shared, so the cost is proportional to the number of rows, not
// their contents.
v.lines = slices.Clone(from.lines)
v.viewLines = slices.Clone(from.viewLines)
v.ox = from.ox
v.oy = from.oy
v.cx = from.cx
@@ -1255,6 +1313,8 @@ func (v *View) updateSearchPositions() {
// IsTainted tells us if the view is tainted
func (v *View) IsTainted() bool {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
return v.tainted
}
@@ -1358,48 +1418,64 @@ func (v *View) draw() {
}
func (v *View) refreshViewLinesIfNeeded() {
if v.tainted {
maxX := v.InnerWidth()
lineIdx := 0
lines := v.lines
for i, line := range lines {
wrap := 0
if v.Wrap {
wrap = maxX
}
ls := lineWrap(line.cells, wrap)
for j := range ls {
// Per-segment trailing fill. When the source line opted in
// via '\x1b[K', the LAST wrapped segment uses those colors
// directly; earlier segments use the colors of their own
// last cell, so the trailing area matches the bg active
// where that segment ended rather than bleeding the
// '\x1b[K' bg back across color changes in the line.
var attrs *trailingFillAttributes
if line.trailingFillAttributes != nil {
if j == len(ls)-1 {
attrs = line.trailingFillAttributes
} else if len(ls[j]) > 0 {
last := ls[j][len(ls[j])-1]
attrs = &trailingFillAttributes{fg: last.fgColor, bg: last.bgColor}
}
}
vline := viewLine{
linesX: j, linesY: i, line: ls[j],
trailingFillAttributes: attrs,
}
if lineIdx > len(v.viewLines)-1 {
v.viewLines = append(v.viewLines, vline)
} else {
v.viewLines[lineIdx] = vline
}
lineIdx++
}
}
v.tainted = false
if !v.tainted {
return
}
maxX := v.InnerWidth()
wrap := 0
if v.Wrap {
wrap = maxX
}
lineIdx := 0
lines := v.lines
for i := range lines {
line := &lines[i]
// Reuse the previously wrapped result for lines that haven't changed
// since the last refresh (i.e. below firstDirtyLine) and were wrapped at
// the current width. Wrapping is expensive and this loop runs on every
// scroll event, so only the lines that were actually just read (or
// re-highlighted) should be wrapped afresh.
if line.wrappedCells == nil || line.wrappedColumns != wrap || i >= v.firstDirtyLine {
line.wrappedCells = lineWrap(line.cells, wrap)
line.wrappedColumns = wrap
}
ls := line.wrappedCells
for j := range ls {
// Per-segment trailing fill. When the source line opted in
// via '\x1b[K', the LAST wrapped segment uses those colors
// directly; earlier segments use the colors of their own
// last cell, so the trailing area matches the bg active
// where that segment ended rather than bleeding the
// '\x1b[K' bg back across color changes in the line.
var attrs *trailingFillAttributes
if line.trailingFillAttributes != nil {
if j == len(ls)-1 {
attrs = line.trailingFillAttributes
} else if len(ls[j]) > 0 {
last := ls[j][len(ls[j])-1]
attrs = &trailingFillAttributes{fg: last.fgColor, bg: last.bgColor}
}
}
vline := viewLine{
linesX: j, linesY: i, line: ls[j],
trailingFillAttributes: attrs,
}
if lineIdx > len(v.viewLines)-1 {
v.viewLines = append(v.viewLines, vline)
} else {
v.viewLines[lineIdx] = vline
}
lineIdx++
}
}
v.firstDirtyLine = len(lines)
v.tainted = false
}
// if autoscroll is enabled but we only have a single row of cells shown to the
@@ -1487,6 +1563,9 @@ func (v *View) BufferLines() []string {
// Buffer returns a string with the contents of the view's internal
// buffer.
func (v *View) Buffer() string {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
return linesToString(v.lines)
}
@@ -1599,6 +1678,7 @@ func (v *View) SetHighlight(y int, on bool) {
cells = append(cells, c)
}
v.tainted = true
v.firstDirtyLine = min(v.firstDirtyLine, y)
v.lines[y].cells = cells
v.clearHover()
}
+29 -11
View File
@@ -43,6 +43,11 @@ func (self *BackgroundRoutineMgr) startBackgroundRoutines() {
if userConfig.Git.AutoFetch {
fetchInterval := userConfig.Refresher.FetchInterval
if fetchInterval > 0 {
// The channel must be created here, on the UI thread and before
// the fetch goroutine spawns, so that triggerImmediateFetch (also
// running on the UI thread) can read the field without racing the
// write. See triggerImmediateFetch for why it is buffered.
self.triggerFetch = make(chan struct{}, 1)
go utils.Safe(self.startBackgroundFetch)
} else {
self.gui.c.Log.Errorf(
@@ -74,7 +79,7 @@ func (self *BackgroundRoutineMgr) startBackgroundRoutines() {
}
if self.gui.Config.GetDebug() {
self.goEvery(time.Second*time.Duration(10), self.gui.stopChan, func(_ bool) error {
self.goEvery(time.Second*time.Duration(10), self.gui.stopChan, nil, func(_ bool) error {
formatBytes := func(b uint64) string {
const unit = 1000
if b < unit {
@@ -125,15 +130,15 @@ func (self *BackgroundRoutineMgr) startBackgroundFetch() {
_ = fetch(true)
userConfig := self.gui.UserConfig()
self.triggerFetch = self.goEvery(userConfig.Refresher.FetchIntervalDuration(), self.gui.stopChan, fetch)
self.goEvery(userConfig.Refresher.FetchIntervalDuration(), self.gui.stopChan, self.triggerFetch, fetch)
}
func (self *BackgroundRoutineMgr) startBackgroundFilesRefresh() {
self.gui.waitForIntro.Wait()
userConfig := self.gui.UserConfig()
self.goEvery(userConfig.Refresher.RefreshIntervalDuration(), self.gui.stopChan, func(_ bool) error {
self.gui.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}, Background: true})
self.goEvery(userConfig.Refresher.RefreshIntervalDuration(), self.gui.stopChan, nil, func(_ bool) error {
self.gui.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}, Background: true})
return nil
})
}
@@ -151,6 +156,7 @@ func (self *BackgroundRoutineMgr) startBackgroundExternalChangeDetection() {
self.goEvery(
userConfig.Refresher.ExternalChangeCheckIntervalDuration(),
self.gui.stopChan,
nil,
func(_ bool) error {
self.checkForExternalChanges()
return nil
@@ -184,13 +190,13 @@ func (self *BackgroundRoutineMgr) checkForExternalChanges() {
// No need to update the stored snapshot here; Refresh does that.
self.gui.c.Log.Info("External ref change detected — refreshing")
self.gui.c.Refresh(types.RefreshOptions{Background: true})
self.gui.c.RefreshFromWorker(types.RefreshOptions{Background: true})
}
// returns a channel that can be used to trigger the callback immediately
func (self *BackgroundRoutineMgr) goEvery(interval time.Duration, stop chan struct{}, function func(bool) error) chan struct{} {
// Runs function every interval until stop is closed. A send on retrigger (if
// non-nil) runs the callback immediately and restarts the interval.
func (self *BackgroundRoutineMgr) goEvery(interval time.Duration, stop, retrigger chan struct{}, function func(bool) error) {
done := make(chan struct{})
retrigger := make(chan struct{})
go utils.Safe(func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
@@ -198,7 +204,10 @@ func (self *BackgroundRoutineMgr) goEvery(interval time.Duration, stop chan stru
if self.backgroundRefreshesPaused() {
return
}
self.gui.c.OnWorker(func(gocui.Task) error {
// OnWorkerBackground, not OnWorker: these routines and the refreshes
// they trigger must not count towards lazygit being busy, or they'd
// spuriously block a repo switch every time one happens to be running.
self.gui.c.OnWorkerBackground(func(gocui.Task) error {
_ = function(retriggered)
done <- struct{}{}
return nil
@@ -220,7 +229,6 @@ func (self *BackgroundRoutineMgr) goEvery(interval time.Duration, stop chan stru
}
}
})
return retrigger
}
func (self *BackgroundRoutineMgr) backgroundFetch() (err error) {
@@ -231,6 +239,16 @@ func (self *BackgroundRoutineMgr) backgroundFetch() (err error) {
func (self *BackgroundRoutineMgr) triggerImmediateFetch() {
if self.triggerFetch != nil {
self.triggerFetch <- struct{}{}
// This runs on the UI thread, which must never block waiting for a
// background routine; in particular, the goEvery loop only receives
// between callbacks, and an in-flight fetch can itself be waiting for
// the UI thread to perform its post-fetch refresh, so a blocking send
// here would deadlock. The channel has a buffer of one, so the trigger
// is latched even when the loop isn't currently receiving; if one is
// already pending, the two coalesce.
select {
case self.triggerFetch <- struct{}{}:
default:
}
}
}
+23 -7
View File
@@ -27,10 +27,20 @@ func (gui *Gui) LogAction(action string) {
return
}
gui.Views.Extras.Autoscroll = true
// LogAction and LogCommand are called both from the UI thread and from git
// worker goroutines, so bounce the writes onto the UI thread: they touch the
// view's autoscroll flag and the GuiLog slice, which the layout/draw code
// reads. Ordering between successive log calls is preserved by the FIFO the
// bounce enqueues onto. It's a background bounce because writing the command
// log is incidental display work that must not count towards lazygit being
// busy (otherwise it could block a repo switch).
gui.onUIThreadBackground(func() error {
gui.Views.Extras.Autoscroll = true
gui.GuiLog = append(gui.GuiLog, action)
fmt.Fprint(gui.Views.Extras, "\n"+style.FgYellow.Sprint(action))
gui.GuiLog = append(gui.GuiLog, action)
fmt.Fprint(gui.Views.Extras, "\n"+style.FgYellow.Sprint(action))
return nil
})
}
func (gui *Gui) LogCommand(cmdStr string, commandLine bool) {
@@ -38,17 +48,23 @@ func (gui *Gui) LogCommand(cmdStr string, commandLine bool) {
return
}
gui.Views.Extras.Autoscroll = true
textStyle := theme.DefaultTextColor
if !commandLine {
// if we're not dealing with a direct command that could be run on the command line,
// we style it differently to communicate that
textStyle = style.FgMagenta
}
gui.GuiLog = append(gui.GuiLog, cmdStr)
indentedCmdStr := " " + strings.ReplaceAll(cmdStr, "\n", "\n ")
fmt.Fprint(gui.Views.Extras, "\n"+textStyle.Sprint(indentedCmdStr))
// See the comment in LogAction: bounce onto the UI thread since we may be
// called from a git worker, in the background so it can't block a repo switch.
gui.onUIThreadBackground(func() error {
gui.Views.Extras.Autoscroll = true
gui.GuiLog = append(gui.GuiLog, cmdStr)
fmt.Fprint(gui.Views.Extras, "\n"+textStyle.Sprint(indentedCmdStr))
return nil
})
}
func (gui *Gui) printCommandLogHeader() {
+58 -38
View File
@@ -32,34 +32,76 @@ type ListRenderer struct {
getNonModelItems func() []*NonModelItem
// The remaining fields are private and shouldn't be initialized by clients
numNonModelItems int
viewIndicesByModelIndex []int
modelIndicesByViewIndex []int
columnPositions []int
columnPositions []int
}
func (self *ListRenderer) GetList() types.IList {
return self.list
}
func (self *ListRenderer) ModelIndexToViewIndex(modelIndex int) int {
modelIndex = lo.Clamp(modelIndex, 0, self.list.Len())
if self.viewIndicesByModelIndex != nil {
return self.viewIndicesByModelIndex[modelIndex]
func (self *ListRenderer) getNonModelItemList() []*NonModelItem {
if self.getNonModelItems == nil {
return nil
}
return self.getNonModelItems()
}
return modelIndex
func (self *ListRenderer) ModelIndexToViewIndex(modelIndex int) int {
return modelIndexToViewIndex(self.list.Len(), self.getNonModelItemList(), modelIndex)
}
func (self *ListRenderer) ViewIndexToModelIndex(viewIndex int) int {
viewIndex = lo.Clamp(viewIndex, 0, self.list.Len()+self.numNonModelItems)
if self.modelIndicesByViewIndex != nil {
return self.modelIndicesByViewIndex[viewIndex]
}
return viewIndexToModelIndex(self.list.Len(), self.getNonModelItemList(), viewIndex)
}
// modelToViewIndexConverter returns a model-to-view index conversion that
// reuses a single snapshot of the non-model items. Callers that convert many
// indices in a row (e.g. search, which converts every commit) should use this
// rather than calling ModelIndexToViewIndex per index, which would rebuild the
// non-model items each time.
func (self *ListRenderer) modelToViewIndexConverter() func(modelIndex int) int {
listLength := self.list.Len()
nonModelItems := self.getNonModelItemList()
return func(modelIndex int) int {
return modelIndexToViewIndex(listLength, nonModelItems, modelIndex)
}
}
// The view shows the model items with the non-model items (e.g. section
// headers) inserted at their model indices. The two conversions below are
// computed directly from the current list length and non-model items, so they
// don't depend on the list having been rendered, and they can never be stale
// with respect to a model that changed since the last render (which used to
// cause both wrong results and index-out-of-range panics).
//
// The non-model items are assumed to be ordered by their Index, which is how
// all producers build them; the i-th one therefore ends up at view index
// Index+i.
func modelIndexToViewIndex(listLength int, nonModelItems []*NonModelItem, modelIndex int) int {
modelIndex = lo.Clamp(modelIndex, 0, listLength)
// Each non-model item inserted at or before this model item pushes it down
// by one row in the view.
viewIndex := modelIndex
for _, item := range nonModelItems {
if item.Index <= modelIndex {
viewIndex++
}
}
return viewIndex
}
func viewIndexToModelIndex(listLength int, nonModelItems []*NonModelItem, viewIndex int) int {
viewIndex = lo.Clamp(viewIndex, 0, listLength+len(nonModelItems))
// Subtract the non-model items that appear before this view index.
modelIndex := viewIndex
for i, item := range nonModelItems {
if item.Index+i < viewIndex {
modelIndex--
}
}
return modelIndex
}
func (self *ListRenderer) ColumnPositions() []int {
return self.columnPositions
}
@@ -71,23 +113,18 @@ func (self *ListRenderer) renderLines(startIdx int, endIdx int) string {
if self.getColumnAlignments != nil {
columnAlignments = self.getColumnAlignments()
}
nonModelItems := []*NonModelItem{}
self.numNonModelItems = 0
if self.getNonModelItems != nil {
nonModelItems = self.getNonModelItems()
self.prepareConversionArrays(nonModelItems)
}
nonModelItems := self.getNonModelItemList()
startModelIdx := 0
if startIdx == -1 {
startIdx = 0
} else {
startModelIdx = self.ViewIndexToModelIndex(startIdx)
startModelIdx = viewIndexToModelIndex(self.list.Len(), nonModelItems, startIdx)
}
endModelIdx := self.list.Len()
if endIdx == -1 {
endIdx = endModelIdx + len(nonModelItems)
} else {
endModelIdx = self.ViewIndexToModelIndex(endIdx)
endModelIdx = viewIndexToModelIndex(self.list.Len(), nonModelItems, endIdx)
}
lines, columnPositions := utils.RenderDisplayStrings(
self.getDisplayStrings(startModelIdx, endModelIdx),
@@ -97,23 +134,6 @@ func (self *ListRenderer) renderLines(startIdx int, endIdx int) string {
return strings.Join(lines, "\n")
}
func (self *ListRenderer) prepareConversionArrays(nonModelItems []*NonModelItem) {
self.numNonModelItems = len(nonModelItems)
viewIndicesByModelIndex := lo.Range(self.list.Len() + 1)
modelIndicesByViewIndex := lo.Range(self.list.Len() + 1)
offset := 0
for _, item := range nonModelItems {
for i := item.Index; i <= self.list.Len(); i++ {
viewIndicesByModelIndex[i]++
}
modelIndicesByViewIndex = slices.Insert(
modelIndicesByViewIndex, item.Index+offset, modelIndicesByViewIndex[item.Index+offset])
offset++
}
self.viewIndicesByModelIndex = viewIndicesByModelIndex
self.modelIndicesByViewIndex = modelIndicesByViewIndex
}
func (self *ListRenderer) insertNonModelItems(
nonModelItems []*NonModelItem, endIdx int, startIdx int, lines []string, columnPositions []int,
) []string {
+24 -3
View File
@@ -254,9 +254,6 @@ func TestListRenderer_ModelIndexToViewIndex_and_back(t *testing.T) {
getNonModelItems: getNonModelItems,
}
// Need to render first so that it knows the non-model items
self.renderLines(-1, -1)
for i := range len(s.modelIndices) {
assert.Equal(t, s.expectedViewIndices[i], self.ModelIndexToViewIndex(s.modelIndices[i]))
}
@@ -267,3 +264,27 @@ func TestListRenderer_ModelIndexToViewIndex_and_back(t *testing.T) {
})
}
}
// The index conversions must not depend on the list having been rendered
// first. It used to be renderLines that populated the conversion arrays, so
// converting an index before the first render silently ignored the non-model
// items (and converting after the model changed used a stale snapshot).
func TestListRenderer_IndexConversionsAreRenderIndependent(t *testing.T) {
modelInts := lo.Map(lo.Range(3), func(i int, _ int) myint { return myint(i) })
self := &ListRenderer{
list: NewListViewModel(func() []myint { return modelInts }),
getDisplayStrings: func(startIdx int, endIdx int) [][]string {
return lo.Map(modelInts[startIdx:endIdx],
func(i myint, _ int) []string { return []string{fmt.Sprint(i)} })
},
// A section header sits at model index 1, so model item 1 is pushed down
// to view index 2, and view index 2 maps back to model item 1.
getNonModelItems: func() []*NonModelItem {
return []*NonModelItem{{Index: 1, Content: "--- header ---"}}
},
}
// Deliberately convert without rendering first.
assert.Equal(t, 2, self.ModelIndexToViewIndex(1))
assert.Equal(t, 1, self.ViewIndexToModelIndex(2))
}
+8 -5
View File
@@ -4,6 +4,7 @@ import (
"fmt"
"log"
"strings"
"sync/atomic"
"time"
"github.com/jesseduffield/lazygit/pkg/commands/models"
@@ -142,7 +143,9 @@ type LocalCommitsViewModel struct {
// If this is true we limit the amount of commits we load, for the sake of keeping things fast.
// If the user attempts to scroll past the end of the list, we will load more commits.
limitCommits bool
// Atomic because a checkout or reset sets it from a worker goroutine while the
// commits refresh reads it on the UI thread to decide how many commits to load.
limitCommits atomic.Bool
// If this is true we'll use git log --all when fetching the commits.
showWholeGitGraph bool
@@ -151,9 +154,9 @@ type LocalCommitsViewModel struct {
func NewLocalCommitsViewModel(getModel func() []*models.Commit, c *ContextCommon) *LocalCommitsViewModel {
self := &LocalCommitsViewModel{
ListViewModel: NewListViewModel(getModel),
limitCommits: true,
showWholeGitGraph: c.UserConfig().Git.Log.ShowWholeGraph,
}
self.limitCommits.Store(true)
return self
}
@@ -221,15 +224,15 @@ func (self *LocalCommitsContext) RefForAdjustingLineNumberInDiff() string {
}
func (self *LocalCommitsContext) ModelSearchResults(searchStr string, caseSensitive bool) []gocui.SearchPosition {
return searchModelCommits(caseSensitive, self.GetCommits(), self.ColumnPositions(), self.ModelIndexToViewIndex, searchStr)
return searchModelCommits(caseSensitive, self.GetCommits(), self.ColumnPositions(), self.modelToViewIndexConverter(), searchStr)
}
func (self *LocalCommitsViewModel) SetLimitCommits(value bool) {
self.limitCommits = value
self.limitCommits.Store(value)
}
func (self *LocalCommitsViewModel) GetLimitCommits() bool {
return self.limitCommits
return self.limitCommits.Load()
}
func (self *LocalCommitsViewModel) SetShowWholeGitGraph(value bool) {
+1 -1
View File
@@ -223,7 +223,7 @@ func (self *SubCommitsContext) RefForAdjustingLineNumberInDiff() string {
}
func (self *SubCommitsContext) ModelSearchResults(searchStr string, caseSensitive bool) []gocui.SearchPosition {
return searchModelCommits(caseSensitive, self.GetCommits(), self.ColumnPositions(), self.ModelIndexToViewIndex, searchStr)
return searchModelCommits(caseSensitive, self.GetCommits(), self.ColumnPositions(), self.modelToViewIndexConverter(), searchStr)
}
func (self *SubCommitsContext) IndexForGotoBottom() int {
+9 -2
View File
@@ -81,10 +81,17 @@ func (self *SuggestionsContext) SetSuggestions(suggestions []*types.Suggestion)
}
func (self *SuggestionsContext) RefreshSuggestions() {
// Capture the suggestions function and the prompt input here, on the UI
// thread, rather than inside the worker below: the main thread rewrites both
// (State.FindSuggestions and the prompt's TextArea) when it (re)creates a
// prompt panel, so reading them from the worker races those writes. It's
// also more correct -- we search for the input as it was when dispatched,
// which is what this request's AsyncHandler id corresponds to.
findSuggestionsFn := self.State.FindSuggestions
promptInput := self.c.GetPromptInput()
self.State.AsyncHandler.Do(func() func() {
findSuggestionsFn := self.State.FindSuggestions
if findSuggestionsFn != nil {
suggestions := findSuggestionsFn(self.c.GetPromptInput())
suggestions := findSuggestionsFn(promptInput)
return func() { self.SetSuggestions(suggestions) }
}
return func() {}
+9 -6
View File
@@ -274,18 +274,21 @@ func (self *BisectController) afterMark(selectCurrent bool, waitToReselect bool)
}
func (self *BisectController) afterBisectMarkRefresh(selectCurrent bool, waitToReselect bool) error {
selectFn := func() {
selectFn := func() error {
if selectCurrent {
self.selectCurrentBisectCommit()
}
}
if waitToReselect {
self.c.Refresh(types.RefreshOptions{Mode: types.SYNC, Scope: []types.RefreshableView{}, Then: selectFn})
return nil
}
selectFn()
if waitToReselect {
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{}, Then: selectFn})
return nil
}
if err := selectFn(); err != nil {
return err
}
self.c.Helpers().Bisect.PostBisectCommandRefresh()
return nil
+22 -21
View File
@@ -331,7 +331,6 @@ func (self *BranchesController) viewUpstreamOptions(selectedBranch *models.Branc
return err
}
self.c.Refresh(types.RefreshOptions{
Mode: types.SYNC,
Scope: []types.RefreshableView{
types.BRANCHES,
types.COMMITS,
@@ -355,7 +354,6 @@ func (self *BranchesController) viewUpstreamOptions(selectedBranch *models.Branc
return err
}
self.c.Refresh(types.RefreshOptions{
Mode: types.SYNC,
Scope: []types.RefreshableView{
types.BRANCHES,
types.COMMITS,
@@ -546,7 +544,7 @@ func (self *BranchesController) forceCheckout() error {
if err := self.c.Git().Branch.Checkout(branch.Name, git_commands.CheckoutOptions{Force: true}); err != nil {
return err
}
self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC})
self.c.Refresh(types.RefreshOptions{})
return nil
},
})
@@ -599,11 +597,10 @@ func (self *BranchesController) createNewBranchWithName(newBranchName string) er
return err
}
self.c.Helpers().Refs.SelectFirstBranchAndFirstCommit()
self.c.Refresh(types.RefreshOptions{
Mode: types.ASYNC,
KeepBranchSelectionIndex: true,
CommitSelection: types.KeepCommitSelectionIndex,
BranchSelection: types.SelectCheckedOutBranch,
CommitSelection: types.SelectHeadCommit,
SelectTopReflogCommit: true,
})
return nil
}
@@ -710,9 +707,9 @@ func (self *BranchesController) fastForward(branch *models.Branch) error {
}
action := self.c.Tr.Actions.FastForwardBranch
worktree, ok := self.worktreeForBranch(branch)
return self.c.WithInlineStatus(branch, types.ItemOperationFastForwarding, context.LOCAL_BRANCHES_CONTEXT_KEY, func(task gocui.Task) error {
worktree, ok := self.worktreeForBranch(branch)
if ok {
self.c.LogAction(action)
@@ -734,7 +731,7 @@ func (self *BranchesController) fastForward(branch *models.Branch) error {
WorktreePath: worktreePath,
},
)
self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC})
self.c.RefreshFromWorker(types.RefreshOptions{})
return err
}
@@ -743,7 +740,7 @@ func (self *BranchesController) fastForward(branch *models.Branch) error {
err := self.c.Git().Sync.FastForward(
task, branch.Name, branch.UpstreamRemote, branch.UpstreamBranch,
)
self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.BRANCHES}})
self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.BRANCHES}})
return err
})
}
@@ -760,7 +757,7 @@ func (self *BranchesController) createSortMenu() error {
if self.c.UserConfig().Git.LocalBranchSortOrder != sortOrder {
self.c.UserConfig().Git.LocalBranchSortOrder = sortOrder
self.c.Contexts().Branches.SetSelection(0)
self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.BRANCHES}})
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.BRANCHES}})
return nil
}
return nil
@@ -783,20 +780,24 @@ func (self *BranchesController) rename(branch *models.Branch) error {
return err
}
// need to find where the branch is now so that we can re-select it. That means we need to refetch the branches synchronously and then find our branch
// need to find where the branch is now so that we can re-select it. That means we need to
// refetch the branches and then find our branch. The branches model update is bounced
// onto the UI thread, so the re-selection (which reads Model.Branches) has to run in
// Then; reading it inline here would see the previous model.
self.c.Refresh(types.RefreshOptions{
Mode: types.SYNC,
Scope: []types.RefreshableView{types.BRANCHES, types.WORKTREES},
Then: func() error {
// now that we've got our stuff again we need to find that branch and reselect it.
for i, newBranch := range self.c.Model().Branches {
if newBranch.Name == newBranchName {
self.context().SetSelection(i)
self.context().HandleRender()
}
}
return nil
},
})
// now that we've got our stuff again we need to find that branch and reselect it.
for i, newBranch := range self.c.Model().Branches {
if newBranch.Name == newBranchName {
self.context().SetSelection(i)
self.context().HandleRender()
}
}
return nil
},
})
+20 -13
View File
@@ -324,7 +324,7 @@ func (self *CommitFilesController) checkout(node *filetree.CommitFileNode) error
return err
}
self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC})
self.c.Refresh(types.RefreshOptions{})
return nil
}
@@ -337,7 +337,9 @@ func (self *CommitFilesController) discard(selectedNodes []*filetree.CommitFileN
Title: self.c.Tr.DiscardFileChangesTitle,
Prompt: prompt,
HandleConfirm: func() error {
return self.c.WithWaitingStatus(self.c.Tr.RebasingStatus, func(gocui.Task) error {
commits := self.c.Model().Commits
selectedLineIdx := self.c.Contexts().LocalCommits.GetSelectedLineIdx()
return self.c.WithWaitingStatusBlockingInput(self.c.Tr.RebasingStatus, func(gocui.Task) error {
var filePaths []string
selectedNodes = normalisedSelectedCommitFileNodes(selectedNodes)
@@ -356,14 +358,17 @@ func (self *CommitFilesController) discard(selectedNodes []*filetree.CommitFileN
})
}
err := self.c.Git().Rebase.DiscardOldFileChanges(self.c.Model().Commits, self.c.Contexts().LocalCommits.GetSelectedLineIdx(), filePaths)
err := self.c.Git().Rebase.DiscardOldFileChanges(commits, selectedLineIdx, filePaths)
if err := self.c.Helpers().MergeAndRebase.CheckMergeOrRebase(err); err != nil {
return err
}
if self.context().RangeSelectEnabled() {
self.context().GetList().CancelRangeSelect()
}
self.c.OnUIThread(func() error {
if self.context().RangeSelectEnabled() {
self.context().GetList().CancelRangeSelect()
}
return nil
})
return nil
})
@@ -442,20 +447,16 @@ func (self *CommitFilesController) toggleForPatch(selectedNodes []*filetree.Comm
self.c.UserConfig().Keybinding.Universal.IncreaseContextInDiffView)
}
refName := self.context().GetRef().RefName()
toggle := func() error {
return self.c.WithWaitingStatus(self.c.Tr.UpdatingPatch, func(gocui.Task) error {
if !self.c.Git().Patch.PatchBuilder.Active() {
if err := self.startPatchBuilder(); err != nil {
return err
}
}
selectedNodes = normalisedSelectedCommitFileNodes(selectedNodes)
// Find if any file in the selection is unselected or partially added
adding := lo.SomeBy(selectedNodes, func(node *filetree.CommitFileNode) bool {
return node.SomeFile(func(file *models.CommitFile) bool {
fileStatus := self.c.Git().Patch.PatchBuilder.GetFileStatus(file.Path, self.context().GetRef().RefName())
fileStatus := self.c.Git().Patch.PatchBuilder.GetFileStatus(file.Path, refName)
return fileStatus == patch.PART || fileStatus == patch.UNSELECTED
})
})
@@ -498,6 +499,12 @@ func (self *CommitFilesController) toggleForPatch(selectedNodes []*filetree.Comm
self.c.Git().Patch.PatchBuilder.Reset()
}
if !self.c.Git().Patch.PatchBuilder.Active() {
if err := self.startPatchBuilder(); err != nil {
return err
}
}
return toggle()
},
})
@@ -132,10 +132,11 @@ func (self *CustomPatchOptionsMenuAction) returnFocusFromPatchExplorerIfNecessar
func (self *CustomPatchOptionsMenuAction) handleDeletePatchFromCommit() error {
self.returnFocusFromPatchExplorerIfNecessary()
commits := self.c.Model().Commits
commitIndex := self.getPatchCommitIndex()
return self.c.WithWaitingStatus(self.c.Tr.RebasingStatus, func(gocui.Task) error {
commitIndex := self.getPatchCommitIndex()
self.c.LogAction(self.c.Tr.Actions.RemovePatchFromCommit)
err := self.c.Git().Patch.DeletePatchesFromCommit(self.c.Model().Commits, commitIndex)
err := self.c.Git().Patch.DeletePatchesFromCommit(commits, commitIndex)
return self.c.Helpers().MergeAndRebase.CheckMergeOrRebase(err)
})
}
@@ -143,10 +144,12 @@ func (self *CustomPatchOptionsMenuAction) handleDeletePatchFromCommit() error {
func (self *CustomPatchOptionsMenuAction) handleMovePatchToSelectedCommit() error {
self.returnFocusFromPatchExplorerIfNecessary()
commits := self.c.Model().Commits
commitIndex := self.getPatchCommitIndex()
toCommitIndex := self.c.Contexts().LocalCommits.GetSelectedLineIdx()
return self.c.WithWaitingStatus(self.c.Tr.RebasingStatus, func(gocui.Task) error {
commitIndex := self.getPatchCommitIndex()
self.c.LogAction(self.c.Tr.Actions.MovePatchToSelectedCommit)
err := self.c.Git().Patch.MovePatchToSelectedCommit(self.c.Model().Commits, commitIndex, self.c.Contexts().LocalCommits.GetSelectedLineIdx())
err := self.c.Git().Patch.MovePatchToSelectedCommit(commits, commitIndex, toCommitIndex)
return self.c.Helpers().MergeAndRebase.CheckMergeOrRebase(err)
})
}
@@ -159,10 +162,11 @@ func (self *CustomPatchOptionsMenuAction) handleMovePatchIntoWorkingTree() error
Title: self.c.Tr.MustStashTitle,
Prompt: self.c.Tr.MustStashWarning,
HandleConfirm: func() error {
commits := self.c.Model().Commits
commitIndex := self.getPatchCommitIndex()
return self.c.WithWaitingStatus(self.c.Tr.RebasingStatus, func(gocui.Task) error {
commitIndex := self.getPatchCommitIndex()
self.c.LogAction(self.c.Tr.Actions.MovePatchIntoIndex)
err := self.c.Git().Patch.MovePatchIntoIndex(self.c.Model().Commits, commitIndex, mustStash)
err := self.c.Git().Patch.MovePatchIntoIndex(commits, commitIndex, mustStash)
return self.c.Helpers().MergeAndRebase.CheckMergeOrRebase(err)
})
},
@@ -183,14 +187,18 @@ func (self *CustomPatchOptionsMenuAction) handlePullPatchIntoNewCommit() error {
DescriptionTitle: self.c.Tr.CommitDescriptionTitle,
PreserveMessage: false,
OnConfirm: func(summary string, description string) error {
commits := self.c.Model().Commits
self.c.Helpers().Commits.CloseCommitMessagePanel()
return self.c.WithWaitingStatus(self.c.Tr.RebasingStatus, func(gocui.Task) error {
self.c.Helpers().Commits.CloseCommitMessagePanel()
self.c.LogAction(self.c.Tr.Actions.MovePatchIntoNewCommit)
err := self.c.Git().Patch.PullPatchIntoNewCommit(self.c.Model().Commits, commitIndex, summary, description)
err := self.c.Git().Patch.PullPatchIntoNewCommit(commits, commitIndex, summary, description)
if err := self.c.Helpers().MergeAndRebase.CheckMergeOrRebase(err); err != nil {
return err
}
self.c.Context().Push(self.c.Contexts().LocalCommits, types.OnFocusOpts{})
self.c.OnUIThread(func() error {
self.c.Context().Push(self.c.Contexts().LocalCommits, types.OnFocusOpts{})
return nil
})
return nil
})
},
@@ -214,14 +222,18 @@ func (self *CustomPatchOptionsMenuAction) handlePullPatchIntoNewCommitBefore() e
DescriptionTitle: self.c.Tr.CommitDescriptionTitle,
PreserveMessage: false,
OnConfirm: func(summary string, description string) error {
commits := self.c.Model().Commits
self.c.Helpers().Commits.CloseCommitMessagePanel()
return self.c.WithWaitingStatus(self.c.Tr.RebasingStatus, func(gocui.Task) error {
self.c.Helpers().Commits.CloseCommitMessagePanel()
self.c.LogAction(self.c.Tr.Actions.MovePatchIntoNewCommit)
err := self.c.Git().Patch.PullPatchIntoNewCommitBefore(self.c.Model().Commits, commitIndex, summary, description)
err := self.c.Git().Patch.PullPatchIntoNewCommitBefore(commits, commitIndex, summary, description)
if err := self.c.Helpers().MergeAndRebase.CheckMergeOrRebase(err); err != nil {
return err
}
self.c.Context().Push(self.c.Contexts().LocalCommits, types.OnFocusOpts{})
self.c.OnUIThread(func() error {
self.c.Context().Push(self.c.Contexts().LocalCommits, types.OnFocusOpts{})
return nil
})
return nil
})
},
@@ -257,7 +269,7 @@ func (self *CustomPatchOptionsMenuAction) handleApplyPatch(reverse bool) error {
return err
}
self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC})
self.c.Refresh(types.RefreshOptions{})
return nil
},
})
+4 -4
View File
@@ -22,7 +22,7 @@ func (self *DiffingMenuAction) Call() error {
OnPress: func() error {
self.c.Modes().Diffing.Ref = name
// can scope this down based on current view but too lazy right now
self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC})
self.c.Refresh(types.RefreshOptions{})
return nil
},
},
@@ -38,7 +38,7 @@ func (self *DiffingMenuAction) Call() error {
FindSuggestionsFunc: self.c.Helpers().Suggestions.GetRefsSuggestionsFunc(),
HandleConfirm: func(response string) error {
self.c.Modes().Diffing.Ref = response
self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC})
self.c.Refresh(types.RefreshOptions{})
return nil
},
})
@@ -54,7 +54,7 @@ func (self *DiffingMenuAction) Call() error {
Label: self.c.Tr.SwapDiff,
OnPress: func() error {
self.c.Modes().Diffing.Reverse = !self.c.Modes().Diffing.Reverse
self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC})
self.c.Refresh(types.RefreshOptions{})
return nil
},
},
@@ -62,7 +62,7 @@ func (self *DiffingMenuAction) Call() error {
Label: self.c.Tr.ExitDiffMode,
OnPress: func() error {
self.c.Modes().Diffing = diffing.New()
self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC})
self.c.Refresh(types.RefreshOptions{})
return nil
},
},
+11 -28
View File
@@ -44,7 +44,7 @@ func (self *FilesController) GetKeybindings(opts types.KeybindingsOpts) []*types
{
Keys: opts.GetKeys(opts.Config.Universal.Select),
Handler: self.withItems(self.press),
GetDisabledReason: self.require(self.withFileTreeViewModelMutex(self.itemsSelected(self.canStageSelection))),
GetDisabledReason: self.require(self.itemsSelected(self.canStageSelection)),
Description: self.c.Tr.Stage,
Tooltip: self.c.Tr.StageTooltip,
DisplayOnScreen: true,
@@ -91,7 +91,7 @@ func (self *FilesController) GetKeybindings(opts types.KeybindingsOpts) []*types
{
Keys: opts.GetKeys(opts.Config.Universal.Edit),
Handler: self.withItems(self.edit),
GetDisabledReason: self.require(self.withFileTreeViewModelMutex(self.itemsSelected(self.canEditFiles))),
GetDisabledReason: self.require(self.itemsSelected(self.canEditFiles)),
Description: self.c.Tr.Edit,
Tooltip: self.c.Tr.EditFileTooltip,
DisplayOnScreen: true,
@@ -145,7 +145,7 @@ func (self *FilesController) GetKeybindings(opts types.KeybindingsOpts) []*types
{
Keys: opts.GetKeys(opts.Config.Universal.Remove),
Handler: self.withItems(self.remove),
GetDisabledReason: self.withFileTreeViewModelMutex(self.require(self.itemsSelected(self.canRemove))),
GetDisabledReason: self.require(self.itemsSelected(self.canRemove)),
Description: self.c.Tr.Discard,
Tooltip: self.c.Tr.DiscardFileChangesTooltip,
OpensMenu: true,
@@ -182,7 +182,7 @@ func (self *FilesController) GetKeybindings(opts types.KeybindingsOpts) []*types
Handler: self.withItems(self.openMergeConflictMenu),
Description: self.c.Tr.ViewMergeConflictOptions,
Tooltip: self.c.Tr.ViewMergeConflictOptionsTooltip,
GetDisabledReason: self.require(self.withFileTreeViewModelMutex(self.itemsSelected(self.canOpenMergeConflictMenu))),
GetDisabledReason: self.require(self.itemsSelected(self.canOpenMergeConflictMenu)),
OpensMenu: true,
DisplayOnScreen: true,
},
@@ -209,15 +209,6 @@ func (self *FilesController) GetKeybindings(opts types.KeybindingsOpts) []*types
}
}
func (self *FilesController) withFileTreeViewModelMutex(callback func() *types.DisabledReason) func() *types.DisabledReason {
return func() *types.DisabledReason {
self.c.Contexts().Files.FileTreeViewModel.RWMutex.RLock()
defer self.c.Contexts().Files.FileTreeViewModel.RWMutex.RUnlock()
return callback()
}
}
func (self *FilesController) GetMouseKeybindings(opts types.KeybindingsOpts) []*gocui.ViewMouseBinding {
return []*gocui.ViewMouseBinding{
{
@@ -574,11 +565,6 @@ func (self *FilesController) toggleStaged(
}
func (self *FilesController) pressWithLock(selectedNodes []*filetree.FileNode) error {
// Obtaining this lock because optimistic rendering requires us to mutate
// the files in our model.
self.c.Mutexes().RefreshingFilesMutex.Lock()
defer self.c.Mutexes().RefreshingFilesMutex.Unlock()
// When filtering, expand directory nodes to individual visible file paths
// so that only filtered files are staged/unstaged.
toPaths := func(nodes []*filetree.FileNode) []string {
@@ -650,7 +636,7 @@ func (self *FilesController) press(nodes []*filetree.FileNode) error {
return err
}
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}, Mode: types.ASYNC})
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}})
self.context().HandleFocus(types.OnFocusOpts{})
return nil
@@ -935,16 +921,13 @@ func (self *FilesController) toggleStagedAll() error {
return err
}
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}, Mode: types.ASYNC})
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}})
self.context().HandleFocus(types.OnFocusOpts{})
return nil
}
func (self *FilesController) toggleStagedAllWithLock() error {
self.c.Mutexes().RefreshingFilesMutex.Lock()
defer self.c.Mutexes().RefreshingFilesMutex.Unlock()
root := self.context().FileTreeViewModel.GetRoot()
stage := func(unstagedNodes []*filetree.FileNode) error {
@@ -1221,7 +1204,7 @@ func (self *FilesController) setStatusFiltering(filter filetree.FileTreeDisplayF
// Whenever we switch between untracked and other filters, we need to refresh the files view
// because the untracked files filter applies when running `git status`.
if previousFilter != filter && (previousFilter == filetree.DisplayUntracked || filter == filetree.DisplayUntracked) {
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}, Mode: types.ASYNC})
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}})
} else {
self.c.PostRefreshUpdate(self.context())
}
@@ -1757,7 +1740,7 @@ func (self *FilesController) remove(selectedNodes []*filetree.FileNode) error {
return err
}
self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.FILES, types.WORKTREES}})
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES, types.WORKTREES}})
return nil
},
Keys: self.c.KeybindingsOpts().GetKeys(self.c.UserConfig().Keybinding.Files.ConfirmDiscard),
@@ -1783,7 +1766,7 @@ func (self *FilesController) remove(selectedNodes []*filetree.FileNode) error {
return err
}
self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.FILES, types.WORKTREES}})
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES, types.WORKTREES}})
return nil
},
Keys: menuKey('u'),
@@ -1808,10 +1791,10 @@ func (self *FilesController) remove(selectedNodes []*filetree.FileNode) error {
}
func (self *FilesController) ResetSubmodule(submodule *models.SubmoduleConfig) error {
file := self.c.Helpers().WorkingTree.FileForSubmodule(submodule)
return self.c.WithWaitingStatus(self.c.Tr.ResettingSubmoduleStatus, func(gocui.Task) error {
self.c.LogAction(self.c.Tr.Actions.ResetSubmodule)
file := self.c.Helpers().WorkingTree.FileForSubmodule(submodule)
if file != nil {
if err := self.c.Git().WorkingTree.UnStageFile(file.Names(), file.Tracked); err != nil {
return err
@@ -1825,7 +1808,7 @@ func (self *FilesController) ResetSubmodule(submodule *models.SubmoduleConfig) e
return err
}
self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.FILES, types.SUBMODULES}})
self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES, types.SUBMODULES}})
return nil
})
}
+2 -1
View File
@@ -122,9 +122,10 @@ func (self *FilteringMenuAction) setFiltering() error {
self.c.Context().Push(self.c.Contexts().LocalCommits, types.OnFocusOpts{})
self.c.Refresh(types.RefreshOptions{Scope: helpers.ScopesToRefreshWhenFilteringModeChanges(), Then: func() {
self.c.Refresh(types.RefreshOptions{Scope: helpers.ScopesToRefreshWhenFilteringModeChanges(), Then: func() error {
self.c.Contexts().LocalCommits.SetSelection(0)
self.c.Contexts().LocalCommits.HandleFocus(types.OnFocusOpts{})
return nil
}})
return nil
+1 -1
View File
@@ -158,7 +158,7 @@ func (self *GlobalController) createCustomPatchOptionsMenu() error {
}
func (self *GlobalController) refresh() error {
self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC})
self.c.Refresh(types.RefreshOptions{})
return nil
}
@@ -65,6 +65,10 @@ func (self *AppStatusHelper) WithWaitingStatus(message string, f func(gocui.Task
})
}
// WithWaitingStatusImpl is WithWaitingStatus for callers that already run on a
// goroutine of their own (e.g. the auto-fetch poller) rather than wanting the
// work dispatched to a worker. task is used to hide the status while the task
// is paused; it may be nil for callers whose f ignores its task.
func (self *AppStatusHelper) WithWaitingStatusImpl(message string, f func(gocui.Task) error, task gocui.Task) error {
// A waiting status means lazygit is driving a git operation itself (often
// one that internally runs a rebase and continues it). Pause the background
@@ -78,16 +82,33 @@ func (self *AppStatusHelper) WithWaitingStatusImpl(message string, f func(gocui.
})
}
func (self *AppStatusHelper) WithWaitingStatusSync(message string, f func() error) error {
self.c.PauseBackgroundRefreshes(true)
defer self.c.PauseBackgroundRefreshes(false)
return self.statusMgr().WithWaitingStatus(message, func() {}, func(*status.WaitingStatusHandle) error {
stop := make(chan struct{})
defer func() { close(stop) }()
self.renderAppStatusSync(stop)
return f()
// WithWaitingStatusBlockingInput is like WithWaitingStatus, but it also blocks
// keyboard input for the whole duration of the operation: keys the user presses
// while it runs are buffered and replayed against the post-operation state (see
// gocui.BeginBlockingEvents). Use it for operations that manipulate an
// in-progress rebase or otherwise rewrite commits, where a racing keypress
// would target the wrong commit or todo.
//
// Must be called on the UI thread: the block is begun synchronously here, before
// the operation is dispatched to a worker, so no keypress can slip through in
// between.
func (self *AppStatusHelper) WithWaitingStatusBlockingInput(message string, f func(gocui.Task) error) {
self.c.GocuiGui().BeginBlockingEvents()
// Hide the rebasing-mode indicator (and its reset button) while we drive the
// rebase ourselves; it reflects the transient on-disk state and would
// otherwise flash on for the duration of the operation.
self.modeHelper.SetSuppressRebasingMode(true)
self.c.OnWorker(func(task gocui.Task) error {
// End the block and restore the mode indicator once the operation and its
// refresh have applied their UI updates: OnUIThread queues this after the
// refresh's model bounces and Then (which RefreshFromWorker has already
// enqueued by the time f returns), so the replayed keys act on the
// refreshed state and any resulting rebase state shows correctly.
defer self.c.OnUIThread(func() error {
self.modeHelper.SetSuppressRebasingMode(false)
return self.c.GocuiGui().EndBlockingEvents()
})
return self.WithWaitingStatusImpl(message, f, task)
})
}
@@ -100,21 +121,36 @@ func (self *AppStatusHelper) GetStatusString() string {
return appStatus
}
// renderAppStatus ensures the render loop that keeps the app-status view up to
// date is running. There is one loop for the whole status stack, no matter how
// many statuses are showing: it draws whatever the top status currently is,
// and exits after drawing a final empty frame once the last status is removed.
//
// The loop always runs as a background task, regardless of what kind of
// operation owns a status: rendering runs no git commands, so it must never
// count towards lazygit being busy — otherwise it would block repo switching
// for as long as anything is showing (e.g. for the whole duration of a hung
// background fetch, or of a toast fading). A foreground operation's busy-ness
// is carried by its own worker task, not by the renderer.
func (self *AppStatusHelper) renderAppStatus() {
self.c.OnWorker(func(_ gocui.Task) error {
if !self.statusMgr().ClaimRenderLoop() {
return
}
self.c.OnWorkerBackground(func(_ gocui.Task) error {
ticker := time.NewTicker(time.Millisecond * time.Duration(self.c.UserConfig().Gui.Spinner.Rate))
defer ticker.Stop()
prevAppStatus := ""
for range ticker.C {
appStatus, color := self.statusMgr().GetStatusString(self.c.UserConfig())
update := self.c.OnUIThreadContentOnly
update := self.c.OnUIThreadContentOnlyBackground
if utils.StringWidth(appStatus) != utils.StringWidth(prevAppStatus) {
// Need a full layout whenever the width of the status string changes. This can't
// happen during normal spinning because we validate that all spinner frames have
// the same width, so typically this will only be triggered at the beginning and end
// of a status, or if the status string changes midway for some reason.
update = self.c.OnUIThread
update = self.c.OnUIThreadBackground
}
update(func() error {
self.c.Views().AppStatus.FgColor = color
@@ -123,64 +159,12 @@ func (self *AppStatusHelper) renderAppStatus() {
})
prevAppStatus = appStatus
if appStatus == "" {
// Checked after rendering, so that the frame which clears the view
// has already been drawn when we exit.
if self.statusMgr().ReleaseRenderLoopIfEmpty() {
break
}
}
return nil
})
}
func (self *AppStatusHelper) renderAppStatusSync(stop chan struct{}) {
go func() {
ticker := time.NewTicker(time.Millisecond * time.Duration(self.c.UserConfig().Gui.Spinner.Rate))
defer ticker.Stop()
// Write the status into the view before the first layout below, so that
// layout (which sizes the bottom line based on the actual content of the
// AppStatus view) leaves room for it and it shows right away. The ticker
// only updates the spinner frame using ForceFlushViewsContentOnly, so this
// doesn't re-layout.
self.setAppStatusContent()
// Forcing a re-layout and redraw after we added the waiting status;
// this is needed in case the gui.showBottomLine config is set to false,
// to make sure the bottom line appears. It's also useful for redrawing
// once after each of several consecutive keypresses, e.g. pressing
// ctrl-j to move a commit down several steps.
_ = self.c.GocuiGui().ForceLayoutAndRedraw()
self.modeHelper.SetSuppressRebasingMode(true)
defer func() { self.modeHelper.SetSuppressRebasingMode(false) }()
outer:
for {
select {
case <-ticker.C:
self.setAppStatusContent()
// Redraw all views of the bottom line:
bottomLineViews := []*gocui.View{
self.c.Views().AppStatus, self.c.Views().Options, self.c.Views().Information,
self.c.Views().StatusSpacer1, self.c.Views().StatusSpacer2,
}
_ = self.c.GocuiGui().ForceFlushViewsContentOnly(bottomLineViews)
case <-stop:
// Clear the status from the view and re-layout, otherwise the
// stale content would keep layout reserving room for it forever.
// The UI thread is free again at this point, so we go through
// OnUIThread like the async renderAppStatus does.
self.c.OnUIThread(func() error {
self.c.SetViewContent(self.c.Views().AppStatus, "")
return nil
})
break outer
}
}
}()
}
func (self *AppStatusHelper) setAppStatusContent() {
appStatus, color := self.statusMgr().GetStatusString(self.c.UserConfig())
self.c.Views().AppStatus.FgColor = color
self.c.SetViewContent(self.c.Views().AppStatus, appStatus)
}
+1 -1
View File
@@ -31,5 +31,5 @@ func (self *BisectHelper) Reset() error {
}
func (self *BisectHelper) PostBisectCommandRefresh() {
self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{}})
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{}})
}
+48 -19
View File
@@ -45,8 +45,11 @@ func (self *BranchesHelper) ConfirmLocalDelete(branches []*models.Branch) error
return err
}
self.c.Contexts().Branches.CollapseRangeSelectionToTop()
self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.BRANCHES}})
self.c.OnUIThread(func() error {
self.c.Contexts().Branches.CollapseRangeSelectionToTop()
return nil
})
self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.BRANCHES}})
return nil
})
})
@@ -84,9 +87,12 @@ func (self *BranchesHelper) ConfirmDeleteRemote(remoteBranches []*models.RemoteB
if err := self.deleteRemoteBranches(remoteBranches, task); err != nil {
return err
}
self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.BRANCHES, types.REMOTES}})
self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.BRANCHES, types.REMOTES}})
if resetRemoteBranchesSelection {
self.c.Contexts().RemoteBranches.CollapseRangeSelectionToTop()
self.c.OnUIThread(func() error {
self.c.Contexts().RemoteBranches.CollapseRangeSelectionToTop()
return nil
})
}
return nil
})
@@ -151,8 +157,11 @@ func (self *BranchesHelper) ConfirmLocalAndRemoteDelete(branches []*models.Branc
return err
}
self.c.Contexts().Branches.CollapseRangeSelectionToTop()
self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.BRANCHES, types.REMOTES}})
self.c.OnUIThread(func() error {
self.c.Contexts().Branches.CollapseRangeSelectionToTop()
return nil
})
self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.BRANCHES, types.REMOTES}})
return nil
})
},
@@ -311,9 +320,11 @@ func (self *BranchesHelper) deleteLocalBranchesContinuation(branches []*models.B
return err
}
self.c.Contexts().Branches.CollapseRangeSelectionToTop()
self.c.Refresh(types.RefreshOptions{
Mode: types.ASYNC,
self.c.OnUIThread(func() error {
self.c.Contexts().Branches.CollapseRangeSelectionToTop()
return nil
})
self.c.RefreshFromWorker(types.RefreshOptions{
Scope: []types.RefreshableView{types.WORKTREES, types.BRANCHES, types.FILES},
})
return nil
@@ -329,9 +340,11 @@ func (self *BranchesHelper) deleteLocalAndRemoteBranchesContinuation(branches []
return err
}
self.c.Contexts().Branches.CollapseRangeSelectionToTop()
self.c.Refresh(types.RefreshOptions{
Mode: types.ASYNC,
self.c.OnUIThread(func() error {
self.c.Contexts().Branches.CollapseRangeSelectionToTop()
return nil
})
self.c.RefreshFromWorker(types.RefreshOptions{
Scope: []types.RefreshableView{types.WORKTREES, types.BRANCHES, types.REMOTES, types.FILES},
})
return nil
@@ -387,14 +400,30 @@ func (self *BranchesHelper) PostFetchRefresh(fetchErr error, background bool) er
if self.c.UserConfig().Git.AutoForwardBranches != "none" {
scope = append(scope, types.WORKTREES)
}
self.c.Refresh(types.RefreshOptions{Scope: scope, Mode: types.SYNC, Background: background})
if fetchErr != nil {
return fetchErr
}
return self.AutoForwardBranches()
// AutoForwardBranches reads Model.Branches, which the branches refresh writes
// via a bounce, so it has to run in Then rather than right after Refresh
// returns (where it would still see the previous branches).
self.c.RefreshFromWorker(types.RefreshOptions{
Scope: scope,
Background: background,
Then: func() error {
if fetchErr != nil {
return nil
}
err := self.AutoForwardBranches(background)
if background && err != nil {
// The background poller discards this return value, so surface
// the error in the log rather than as a popup for background work.
self.c.Log.Error(err)
return nil
}
return err
},
})
return fetchErr
}
func (self *BranchesHelper) AutoForwardBranches() error {
func (self *BranchesHelper) AutoForwardBranches(background bool) error {
if self.c.UserConfig().Git.AutoForwardBranches == "none" {
return nil
}
@@ -426,7 +455,7 @@ func (self *BranchesHelper) AutoForwardBranches() error {
self.c.LogCommand(strings.TrimRight(updateCommands, "\n"), false)
err := self.c.Git().Branch.UpdateBranchRefs(updateCommands)
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.BRANCHES}, Mode: types.SYNC})
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.BRANCHES}, Background: background})
return err
}
@@ -4,6 +4,7 @@ import (
"strconv"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/modes/cherrypicking"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/utils"
@@ -82,9 +83,9 @@ func (self *CherryPickHelper) Paste() error {
"numCommits": strconv.Itoa(len(self.getData().CherryPickedCommits)),
}),
HandleConfirm: func() error {
return self.c.WithWaitingStatusSync(self.c.Tr.CherryPickingStatus, func() error {
mustStash := IsWorkingTreeDirtyExceptSubmodules(self.c.Model().Files, self.c.Model().Submodules)
mustStash := IsWorkingTreeDirtyExceptSubmodules(self.c.Model().Files, self.c.Model().Submodules)
cherryPickedCommits := self.getData().CherryPickedCommits
return self.c.WithWaitingStatusBlockingInput(self.c.Tr.CherryPickingStatus, func(gocui.Task) error {
self.c.LogAction(self.c.Tr.Actions.CherryPick)
if mustStash {
@@ -93,9 +94,9 @@ func (self *CherryPickHelper) Paste() error {
}
}
cherryPickedCommits := self.getData().CherryPickedCommits
result := self.c.Git().Rebase.CherryPickCommits(cherryPickedCommits)
err := self.rebaseHelper.CheckMergeOrRebaseWithRefreshOptions(result, types.RefreshOptions{Mode: types.SYNC})
err := self.rebaseHelper.CheckMergeOrRebaseWithRefreshOptions(result,
types.RefreshOptions{BatchUIUpdates: true})
if err != nil {
return result
}
@@ -109,14 +110,19 @@ func (self *CherryPickHelper) Paste() error {
return result
}
if !isInCherryPick {
self.getData().DidPaste = true
self.rerender()
// DidPaste and the re-render touch mode state and contexts,
// so run them on the UI thread.
self.c.OnUIThread(func() error {
self.getData().DidPaste = true
self.rerender()
return nil
})
if mustStash {
if err := self.c.Git().Stash.Pop(0); err != nil {
return err
}
self.c.Refresh(types.RefreshOptions{
self.c.RefreshFromWorker(types.RefreshOptions{
Scope: []types.RefreshableView{types.STASH, types.FILES},
})
}
@@ -77,9 +77,7 @@ func (self *ConfirmationHelper) wrappedPromptConfirmationFunction(
}
func (self *ConfirmationHelper) DeactivateConfirmation() {
self.c.Mutexes().PopupMutex.Lock()
self.c.State().GetRepoState().SetCurrentPopupOpts(nil)
self.c.Mutexes().PopupMutex.Unlock()
self.c.Views().Confirmation.Visible = false
@@ -87,9 +85,7 @@ func (self *ConfirmationHelper) DeactivateConfirmation() {
}
func (self *ConfirmationHelper) DeactivatePrompt() {
self.c.Mutexes().PopupMutex.Lock()
self.c.State().GetRepoState().SetCurrentPopupOpts(nil)
self.c.Mutexes().PopupMutex.Unlock()
self.c.Views().Prompt.Visible = false
self.c.Views().Suggestions.Visible = false
@@ -188,9 +184,6 @@ func characterForMask(mask bool) string {
}
func (self *ConfirmationHelper) CreatePopupPanel(ctx goContext.Context, opts types.CreatePopupPanelOpts) {
self.c.Mutexes().PopupMutex.Lock()
defer self.c.Mutexes().PopupMutex.Unlock()
_, cancel := goContext.WithCancel(ctx)
// we don't allow interruptions of non-loader popups in case we get stuck somehow
@@ -33,7 +33,7 @@ func (self *CredentialsHelper) PromptUserForCredential(passOrUname oscommands.Cr
HandleConfirm: func(input string) error {
ch <- input + "\n"
self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC})
self.c.Refresh(types.RefreshOptions{})
return nil
},
HandleClose: func() error {
+1 -1
View File
@@ -94,7 +94,7 @@ func (self *DiffHelper) FilterPathsForCommit(commit *models.Commit) []string {
func (self *DiffHelper) ExitDiffMode() error {
self.c.Modes().Diffing = diffing.New()
self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC})
self.c.Refresh(types.RefreshOptions{})
return nil
}
+1 -1
View File
@@ -137,7 +137,7 @@ func (self *FixupHelper) HandleFindBaseCommitForFixupPress() error {
if err := self.c.Git().WorkingTree.StageAll(true); err != nil {
return err
}
self.c.Refresh(types.RefreshOptions{Mode: types.SYNC, Scope: []types.RefreshableView{types.FILES}})
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}})
}
self.c.Contexts().LocalCommits.SetSelection(index)
+5 -5
View File
@@ -26,7 +26,7 @@ func (self *GpgHelper) WithGpgHandling(
onSuccess func() error,
refreshScope []types.RefreshableView,
) error {
refreshOptions := types.RefreshOptions{Mode: types.ASYNC, Scope: refreshScope}
refreshOptions := types.RefreshOptions{Scope: refreshScope}
return self.withGpgHandling(
cmdObj, configKey, waitingStatus, onSuccess, refreshOptions, refreshOptions)
}
@@ -40,8 +40,8 @@ func (self *GpgHelper) WithGpgHandlingAndSelectHeadCommit(
waitingStatus string,
onSuccess func() error,
) error {
failureRefreshOptions := types.RefreshOptions{Mode: types.ASYNC}
successRefreshOptions := types.RefreshOptions{Mode: types.ASYNC, CommitSelection: types.SelectHeadCommit}
failureRefreshOptions := types.RefreshOptions{}
successRefreshOptions := types.RefreshOptions{CommitSelection: types.SelectHeadCommit}
return self.withGpgHandling(
cmdObj, configKey, waitingStatus, onSuccess, failureRefreshOptions, successRefreshOptions)
}
@@ -88,7 +88,7 @@ func (self *GpgHelper) runAndStream(
) error {
return self.c.WithWaitingStatus(waitingStatus, func(gocui.Task) error {
if err := cmdObj.StreamOutput().Run(); err != nil {
self.c.Refresh(failureRefreshOptions)
self.c.RefreshFromWorker(failureRefreshOptions)
return fmt.Errorf(
self.c.Tr.GitCommandFailed, self.c.UserConfig().Keybinding.Universal.ExtrasMenu,
)
@@ -100,7 +100,7 @@ func (self *GpgHelper) runAndStream(
}
}
self.c.Refresh(successRefreshOptions)
self.c.RefreshFromWorker(successRefreshOptions)
return nil
})
}
@@ -138,22 +138,18 @@ func (self *InlineStatusHelper) stop(opts InlineStatusOpts) {
self.c.State().ClearItemOperation(opts.Item)
// When recording a demo we need to re-render the context again here to
// remove the inline status. In normal usage we don't want to do this
// because in the case of pushing a branch this would first reveal the ↑3↓7
// status from before the push for a brief moment, to be replaced by a green
// checkmark a moment later when the async refresh is done. This looks
// jarring, so normally we rely on the async refresh to redraw with the
// status removed. (In some rare cases, where there's no refresh at all, we
// need to redraw manually in the controller; see TagsController.push() for
// an example.)
//
// In demos, however, we turn all async refreshes into sync ones, because
// this looks better in demos. In this case the refresh happens while the
// status is still set, so we need to render again after removing it.
if self.c.InDemo() {
self.renderContext(opts.ContextKey)
}
// Re-render the context to remove the inline status now that the operation
// finished. Any refresh it triggered must be synchronous, not async: by the
// time we get here a synchronous refresh has already updated the model and
// queued its own re-render, and since UI-thread callbacks run in order, the
// render we queue here runs after it and draws the up-to-date model without
// the inline status. An async refresh might not have updated the model yet,
// so this render could briefly show the stale, pre-operation model: when
// pushing a branch, for example, it would flash the old ↑3↓7 ahead/behind
// counts for a moment before the refresh replaced them with a green
// checkmark. (Operations that don't refresh at all are fine too: there's
// nothing stale to show, so this just drops the status.)
self.renderContext(opts.ContextKey)
}
func (self *InlineStatusHelper) renderContext(contextKey types.ContextKey) {
@@ -79,6 +79,22 @@ func (self *MergeAndRebaseHelper) ContinueRebase() error {
}
func (self *MergeAndRebaseHelper) genericMergeCommand(command string) error {
// The menu/prompt/confirm handlers that reach here run on the UI thread and
// spin up a worker (via the waiting status below) to do the actual work.
return self.genericMergeCommandImpl(command, true, false)
}
// genericMergeCommandImpl runs a merge/rebase continue/skip/abort and handles
// the result. Continuing can be slow (it may replay many commits), so the
// non-subprocess path runs on a worker with a waiting status.
//
// showWaitingStatus is false only for the recursive auto-skip in
// CheckMergeOrRebaseWithRefreshOptions, which already runs on a worker, so it
// must not spin up a second waiting status. calledFromWorker is used only by the
// subprocess path below: it's true for that recursive worker skip and false for
// genericMergeCommand's UI-thread invocation, so the post-action refresh picks
// RefreshFromWorker vs Refresh correctly.
func (self *MergeAndRebaseHelper) genericMergeCommandImpl(command string, showWaitingStatus bool, calledFromWorker bool) error {
status := self.c.Git().Status.WorkingTreeState()
if status.None() {
@@ -111,24 +127,35 @@ func (self *MergeAndRebaseHelper) genericMergeCommand(command string) error {
needsSubprocess := (effectiveStatus == models.WORKING_TREE_STATE_MERGING && command != REBASE_OPTION_ABORT && self.c.UserConfig().Git.Merging.ManualCommit) ||
// but we'll also use a subprocess if we have exec todos; those are likely to be lengthy build
// tasks whose output the user will want to see in the terminal
(effectiveStatus == models.WORKING_TREE_STATE_REBASING && command != REBASE_OPTION_ABORT && self.hasExecTodos())
(effectiveStatus == models.WORKING_TREE_STATE_REBASING && command != REBASE_OPTION_ABORT && self.hasExecTodos(calledFromWorker))
if needsSubprocess {
// TODO: see if we should be calling more of the code from self.Git.Rebase.GenericMergeOrRebaseAction
success, err := self.c.RunSubprocess(self.c.Git().Rebase.GenericMergeOrRebaseActionCmdObj(commandType, command))
self.c.Refresh(types.RefreshOptions{
Mode: types.ASYNC,
self.refreshAfterMergeOrRebase(types.RefreshOptions{
CommitSelection: commitSelectionAfterMerge(success && selectHeadCommitOnSuccess),
})
}, calledFromWorker)
self.RecordWhetherMergeOrRebaseStartedInLazygit()
return err
}
result := self.c.Git().Rebase.GenericMergeOrRebaseAction(commandType, command)
return self.CheckMergeOrRebaseWithRefreshOptions(result,
types.RefreshOptions{
Mode: types.ASYNC,
CommitSelection: commitSelectionAfterMerge(result == nil && selectHeadCommitOnSuccess),
// runAction always ends up on a worker: either the waiting status below spins
// one up, or we're the recursive auto-skip reached from
// CheckMergeOrRebaseWithRefreshOptions, which already runs on one.
runAction := func() error {
result := self.c.Git().Rebase.GenericMergeOrRebaseAction(commandType, command)
return self.CheckMergeOrRebaseWithRefreshOptions(result,
types.RefreshOptions{
CommitSelection: commitSelectionAfterMerge(result == nil && selectHeadCommitOnSuccess),
})
}
if showWaitingStatus {
return self.c.WithWaitingStatus(status.Title(self.c.Tr), func(gocui.Task) error {
return runAction()
})
}
return runAction()
}
// commitSelectionAfterMerge maps whether a merge/rebase/pull created a new
@@ -141,16 +168,31 @@ func commitSelectionAfterMerge(createdNewCommit bool) types.CommitSelectionBehav
return types.KeepCommitSelectionByHash
}
func (self *MergeAndRebaseHelper) hasExecTodos() bool {
for _, commit := range self.c.Model().Commits {
if !commit.IsTODO() {
break
}
if commit.Action == todo.Exec {
return true
func (self *MergeAndRebaseHelper) hasExecTodos(calledFromWorker bool) bool {
check := func() bool {
for _, commit := range self.c.Model().Commits {
if !commit.IsTODO() {
break
}
if commit.Action == todo.Exec {
return true
}
}
return false
}
return false
// This reads the model, which is only safe on the UI thread, so bounce there
// when we're being called from a worker.
if !calledFromWorker {
return check()
}
result := false
_ = self.c.GocuiGui().OnUIThreadAndWait(func() error {
result = check()
return nil
})
return result
}
var conflictStrings = []string{
@@ -183,17 +225,20 @@ func (self *MergeAndRebaseHelper) RecordWhetherMergeOrRebaseStartedInLazygit() {
self.c.Git().Status.WorkingTreeState().Any())
}
// CheckMergeOrRebaseWithRefreshOptions handles the result of a merge/rebase
// step and refreshes. It always runs on a worker (the WithWaitingStatus /
// WithWaitingStatusBlockingInput / WithInlineStatus handlers).
func (self *MergeAndRebaseHelper) CheckMergeOrRebaseWithRefreshOptions(result error, refreshOptions types.RefreshOptions) error {
self.c.Refresh(refreshOptions)
self.refreshAfterMergeOrRebase(refreshOptions, true)
self.RecordWhetherMergeOrRebaseStartedInLazygit()
if result == nil {
return nil
} else if strings.Contains(result.Error(), "No changes - did you forget to use") {
return self.genericMergeCommand(REBASE_OPTION_SKIP)
return self.genericMergeCommandImpl(REBASE_OPTION_SKIP, false, true)
} else if strings.Contains(result.Error(), "The previous cherry-pick is now empty") {
return self.genericMergeCommand(REBASE_OPTION_SKIP)
return self.genericMergeCommandImpl(REBASE_OPTION_SKIP, false, true)
} else if strings.Contains(result.Error(), "No rebase in progress?") {
// assume in this case that we're already done
return nil
@@ -201,8 +246,20 @@ func (self *MergeAndRebaseHelper) CheckMergeOrRebaseWithRefreshOptions(result er
return self.CheckForConflicts(result)
}
// refreshAfterMergeOrRebase issues the post-action refresh on the entry point
// that matches the thread the merge/rebase ran on: RefreshFromWorker for the
// worker callers, Refresh for the merge/rebase-continue subprocess path that
// stays on the UI thread.
func (self *MergeAndRebaseHelper) refreshAfterMergeOrRebase(refreshOptions types.RefreshOptions, calledFromWorker bool) {
if calledFromWorker {
self.c.RefreshFromWorker(refreshOptions)
} else {
self.c.Refresh(refreshOptions)
}
}
func (self *MergeAndRebaseHelper) CheckMergeOrRebase(result error) error {
return self.CheckMergeOrRebaseWithRefreshOptions(result, types.RefreshOptions{Mode: types.ASYNC})
return self.CheckMergeOrRebaseWithRefreshOptions(result, types.RefreshOptions{})
}
// Like CheckMergeOrRebase, but for operations that create a new commit at HEAD
@@ -211,7 +268,7 @@ func (self *MergeAndRebaseHelper) CheckMergeOrRebase(result error) error {
// before the refresh.
func (self *MergeAndRebaseHelper) CheckMergeOrRebaseAndSelectHeadCommit(result error) error {
return self.CheckMergeOrRebaseWithRefreshOptions(result,
types.RefreshOptions{Mode: types.ASYNC, CommitSelection: commitSelectionAfterMerge(result == nil)})
types.RefreshOptions{CommitSelection: commitSelectionAfterMerge(result == nil)})
}
func (self *MergeAndRebaseHelper) CheckForConflicts(result error) error {
@@ -265,7 +322,7 @@ func (self *MergeAndRebaseHelper) AbortMergeOrRebaseWithConfirm() error {
}
// PromptToContinueRebase asks the user if they want to continue the rebase/merge that's in progress
func (self *MergeAndRebaseHelper) PromptToContinueRebase() error {
func (self *MergeAndRebaseHelper) PromptToContinueRebase() {
self.continueRebasePromptShowing = true
self.c.Confirm(types.ConfirmOpts{
Title: self.c.Tr.Continue,
@@ -285,33 +342,38 @@ func (self *MergeAndRebaseHelper) PromptToContinueRebase() error {
// Need to refresh the files to be really sure if this is the case.
// We would otherwise be relying on lazygit's auto-refresh on focus,
// but this is not supported by all terminals or on all platforms.
//
// The model.Files update is bounced onto the UI thread, so we have
// to read it in Then; reading it inline here would see the previous
// model.
self.c.Refresh(types.RefreshOptions{
Mode: types.SYNC, Scope: []types.RefreshableView{types.FILES},
Scope: []types.RefreshableView{types.FILES},
Then: func() error {
unstagedFiles := GetUnstagedFilesExceptSubmodules(self.c.Model().Files, self.c.Model().Submodules)
if len(unstagedFiles) > 0 {
self.c.Confirm(types.ConfirmOpts{
Title: self.c.Tr.Continue,
Prompt: self.c.Tr.UnstagedFilesAfterConflictsResolved,
HandleConfirm: func() error {
self.c.LogAction(self.c.Tr.Actions.StageAllFiles)
if err := self.c.Git().WorkingTree.StageFiles(unstagedFiles, []string{}); err != nil {
return err
}
return self.genericMergeCommand(REBASE_OPTION_CONTINUE)
},
})
return nil
}
return self.genericMergeCommand(REBASE_OPTION_CONTINUE)
},
})
unstagedFiles := GetUnstagedFilesExceptSubmodules(self.c.Model().Files, self.c.Model().Submodules)
if len(unstagedFiles) > 0 {
self.c.Confirm(types.ConfirmOpts{
Title: self.c.Tr.Continue,
Prompt: self.c.Tr.UnstagedFilesAfterConflictsResolved,
HandleConfirm: func() error {
self.c.LogAction(self.c.Tr.Actions.StageAllFiles)
if err := self.c.Git().WorkingTree.StageFiles(unstagedFiles, []string{}); err != nil {
return err
}
return self.genericMergeCommand(REBASE_OPTION_CONTINUE)
},
})
return nil
}
return self.genericMergeCommand(REBASE_OPTION_CONTINUE)
return nil
},
})
return nil
}
// DismissContinueRebasePromptIfShowing closes the "continue the rebase/merge?"
@@ -361,8 +423,8 @@ func (self *MergeAndRebaseHelper) RebaseOntoRef(ref string) error {
DisabledReason: disabledReason,
OnPress: func() error {
self.c.LogAction(self.c.Tr.Actions.RebaseBranch)
baseCommit := self.c.Modes().MarkedBaseCommit.GetHash()
return self.c.WithWaitingStatus(self.c.Tr.RebasingStatus, func(task gocui.Task) error {
baseCommit := self.c.Modes().MarkedBaseCommit.GetHash()
var err error
if baseCommit != "" {
err = self.c.Git().Rebase.RebaseBranchFromBaseCommit(ref, baseCommit)
@@ -371,7 +433,9 @@ func (self *MergeAndRebaseHelper) RebaseOntoRef(ref string) error {
}
err = self.CheckMergeOrRebase(err)
if err == nil {
return self.ResetMarkedBaseCommit()
self.c.OnUIThread(func() error {
return self.ResetMarkedBaseCommit()
})
}
return err
})
@@ -387,20 +451,25 @@ func (self *MergeAndRebaseHelper) RebaseOntoRef(ref string) error {
OnPress: func() error {
self.c.LogAction(self.c.Tr.Actions.RebaseBranch)
baseCommit := self.c.Modes().MarkedBaseCommit.GetHash()
var err error
if baseCommit != "" {
err = self.c.Git().Rebase.EditRebaseFromBaseCommit(ref, baseCommit)
} else {
err = self.c.Git().Rebase.EditRebase(ref)
}
if err = self.CheckMergeOrRebase(err); err != nil {
return err
}
if err = self.ResetMarkedBaseCommit(); err != nil {
return err
}
self.c.Context().Push(self.c.Contexts().LocalCommits, types.OnFocusOpts{})
return nil
return self.c.WithWaitingStatus(self.c.Tr.RebasingStatus, func(task gocui.Task) error {
var err error
if baseCommit != "" {
err = self.c.Git().Rebase.EditRebaseFromBaseCommit(ref, baseCommit)
} else {
err = self.c.Git().Rebase.EditRebase(ref)
}
if err = self.CheckMergeOrRebase(err); err != nil {
return err
}
self.c.OnUIThread(func() error {
if err := self.ResetMarkedBaseCommit(); err != nil {
return err
}
self.c.Context().Push(self.c.Contexts().LocalCommits, types.OnFocusOpts{})
return nil
})
return nil
})
},
},
{
@@ -412,8 +481,8 @@ func (self *MergeAndRebaseHelper) RebaseOntoRef(ref string) error {
Tooltip: self.c.Tr.RebaseOntoBaseBranchTooltip,
OnPress: func() error {
self.c.LogAction(self.c.Tr.Actions.RebaseBranch)
baseCommit := self.c.Modes().MarkedBaseCommit.GetHash()
return self.c.WithWaitingStatus(self.c.Tr.RebasingStatus, func(task gocui.Task) error {
baseCommit := self.c.Modes().MarkedBaseCommit.GetHash()
var err error
if baseCommit != "" {
err = self.c.Git().Rebase.RebaseBranchFromBaseCommit(baseBranch, baseCommit)
@@ -422,7 +491,9 @@ func (self *MergeAndRebaseHelper) RebaseOntoRef(ref string) error {
}
err = self.CheckMergeOrRebase(err)
if err == nil {
return self.ResetMarkedBaseCommit()
self.c.OnUIThread(func() error {
return self.ResetMarkedBaseCommit()
})
}
return err
})
@@ -564,36 +635,42 @@ func (self *MergeAndRebaseHelper) MergeRefIntoCheckedOutBranch(refName string) e
func (self *MergeAndRebaseHelper) RegularMerge(refName string, variant git_commands.MergeVariant) func() error {
return func() error {
self.c.LogAction(self.c.Tr.Actions.Merge)
err := self.c.Git().Branch.Merge(refName, variant)
return self.CheckMergeOrRebaseAndSelectHeadCommit(err)
return self.c.WithWaitingStatus(self.c.Tr.MergingStatus, func(gocui.Task) error {
err := self.c.Git().Branch.Merge(refName, variant)
return self.CheckMergeOrRebaseAndSelectHeadCommit(err)
})
}
}
func (self *MergeAndRebaseHelper) SquashMergeUncommitted(refName string) func() error {
return func() error {
self.c.LogAction(self.c.Tr.Actions.SquashMerge)
err := self.c.Git().Branch.Merge(refName, git_commands.MERGE_VARIANT_SQUASH)
return self.CheckMergeOrRebase(err)
return self.c.WithWaitingStatus(self.c.Tr.MergingStatus, func(gocui.Task) error {
err := self.c.Git().Branch.Merge(refName, git_commands.MERGE_VARIANT_SQUASH)
return self.CheckMergeOrRebase(err)
})
}
}
func (self *MergeAndRebaseHelper) SquashMergeCommitted(refName, checkedOutBranchName string) func() error {
return func() error {
self.c.LogAction(self.c.Tr.Actions.SquashMerge)
err := self.c.Git().Branch.Merge(refName, git_commands.MERGE_VARIANT_SQUASH)
if err = self.CheckMergeOrRebase(err); err != nil {
return err
}
message := utils.ResolvePlaceholderString(self.c.UserConfig().Git.Merging.SquashMergeMessage, map[string]string{
"selectedRef": refName,
"currentBranch": checkedOutBranchName,
return self.c.WithWaitingStatus(self.c.Tr.MergingStatus, func(gocui.Task) error {
err := self.c.Git().Branch.Merge(refName, git_commands.MERGE_VARIANT_SQUASH)
if err = self.CheckMergeOrRebase(err); err != nil {
return err
}
message := utils.ResolvePlaceholderString(self.c.UserConfig().Git.Merging.SquashMergeMessage, map[string]string{
"selectedRef": refName,
"currentBranch": checkedOutBranchName,
})
err = self.c.Git().Commit.CommitCmdObj(message, "", false).Run()
if err != nil {
return err
}
self.c.RefreshFromWorker(types.RefreshOptions{})
return nil
})
err = self.c.Git().Commit.CommitCmdObj(message, "", false).Run()
if err != nil {
return err
}
self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC})
return nil
}
}
@@ -51,26 +51,28 @@ func (self *MergeConflictsHelper) resetMergeState() {
self.context().GetState().Reset()
}
func (self *MergeConflictsHelper) EscapeMerge() error {
self.resetMergeState()
// EscapeMerge returns from the merge conflicts view to the files context. It
// must be called on the UI thread, without the merge-conflicts mutex held:
// pushing the files context renders the newly focused file to the main view,
// which can take the mutex again (via SetMergeState).
func (self *MergeConflictsHelper) EscapeMerge() {
self.ResetMergeState()
// doing this in separate UI thread so that we're not still holding the lock by the time refresh the file
self.c.OnUIThread(func() error {
// There is a race condition here: refreshing the files scope can trigger the
// confirmation context to be pushed if all conflicts are resolved (prompting
// to continue the merge/rebase. In that case, we don't want to then push the
// files context over it.
// So long as both places call OnUIThread, we're fine.
if self.c.Context().IsCurrent(self.c.Contexts().MergeConflicts) {
self.c.Context().Push(self.c.Contexts().Files, types.OnFocusOpts{})
}
return nil
})
return nil
// The files refresh may already have opened the prompt to continue the
// rebase/merge on top of us (if all conflicts are resolved); in that case
// don't push the files context over it.
if self.c.Context().IsCurrent(self.c.Contexts().MergeConflicts) {
self.c.Context().Push(self.c.Contexts().Files, types.OnFocusOpts{})
}
}
func (self *MergeConflictsHelper) SetConflictsAndRender(path string) (bool, error) {
hasConflicts, err := self.setMergeStateWithoutLock(path)
// SetConflictsAndRender re-reads the file being merged and re-renders the
// merge conflicts view. Returns whether the file still has conflicts.
func (self *MergeConflictsHelper) SetConflictsAndRender() (bool, error) {
self.context().GetMutex().Lock()
defer self.context().GetMutex().Unlock()
hasConflicts, err := self.setMergeStateWithoutLock(self.context().GetState().GetPath())
if err != nil {
return false, err
}
@@ -121,20 +123,17 @@ func (self *MergeConflictsHelper) Render() {
}
func (self *MergeConflictsHelper) RefreshMergeState() error {
self.c.Contexts().MergeConflicts.GetMutex().Lock()
defer self.c.Contexts().MergeConflicts.GetMutex().Unlock()
if self.c.Context().Current().GetKey() != context.MERGE_CONFLICTS_CONTEXT_KEY {
return nil
}
hasConflicts, err := self.SetConflictsAndRender(self.c.Contexts().MergeConflicts.GetState().GetPath())
hasConflicts, err := self.SetConflictsAndRender()
if err != nil {
return err
}
if !hasConflicts {
return self.EscapeMerge()
self.EscapeMerge()
}
return nil
+2 -1
View File
@@ -191,7 +191,7 @@ func (self *ModeHelper) ClearFiltering() error {
self.c.Refresh(types.RefreshOptions{
Scope: ScopesToRefreshWhenFilteringModeChanges(),
Then: func() {
Then: func() error {
// Find the commit that was last selected in filtering mode, and select it again after refreshing
if !self.c.Contexts().LocalCommits.SelectCommitByHash(selectedCommitHash) {
// If we couldn't find it (either because no commit was selected
@@ -202,6 +202,7 @@ func (self *ModeHelper) ClearFiltering() error {
}
self.c.PostRefreshUpdate(self.c.Contexts().LocalCommits)
return nil
},
})
return nil
File diff suppressed because it is too large Load Diff
+89 -80
View File
@@ -31,15 +31,6 @@ func NewRefsHelper(
}
}
func (self *RefsHelper) SelectFirstBranchAndFirstCommit() {
self.c.Contexts().Branches.SetSelection(0)
self.c.Contexts().ReflogCommits.SetSelection(0)
self.c.Contexts().LocalCommits.SetSelection(0)
self.c.Contexts().Branches.GetView().SetOriginY(0)
self.c.Contexts().ReflogCommits.GetView().SetOriginY(0)
self.c.Contexts().LocalCommits.GetView().SetOriginY(0)
}
func (self *RefsHelper) CheckoutRef(ref string, options types.CheckoutRefOptions) error {
waitingStatus := options.WaitingStatus
if waitingStatus == "" {
@@ -49,8 +40,6 @@ func (self *RefsHelper) CheckoutRef(ref string, options types.CheckoutRefOptions
cmdOptions := git_commands.CheckoutOptions{Force: false, EnvVars: options.EnvVars}
refresh := func() {
self.SelectFirstBranchAndFirstCommit()
// loading a heap of commits is slow so we limit them whenever doing a reset
self.c.Contexts().LocalCommits.SetLimitCommits(true)
@@ -66,11 +55,12 @@ func (self *RefsHelper) CheckoutRef(ref string, options types.CheckoutRefOptions
if options.RefreshPullRequests {
scope = append(scope, types.PULL_REQUESTS)
}
self.c.Refresh(types.RefreshOptions{
Mode: types.BLOCK_UI,
Scope: scope,
KeepBranchSelectionIndex: true,
CommitSelection: types.KeepCommitSelectionIndex,
self.c.RefreshFromWorker(types.RefreshOptions{
BatchUIUpdates: true,
Scope: scope,
BranchSelection: types.SelectCheckedOutBranch,
CommitSelection: types.SelectHeadCommit,
SelectTopReflogCommit: true,
})
}
@@ -170,7 +160,6 @@ func (self *RefsHelper) CheckoutRemoteBranch(fullBranchName string, localBranchN
// Do a sync refresh to make sure the new branch is visible,
// so that we see an inline status when checking it out
self.c.Refresh(types.RefreshOptions{
Mode: types.SYNC,
Scope: []types.RefreshableView{types.BRANCHES},
})
return checkout(localBranchName, true)
@@ -209,12 +198,14 @@ func (self *RefsHelper) ResetToRef(ref string, strength string, envVars []string
return err
}
self.c.Contexts().LocalCommits.SetSelection(0)
self.c.Contexts().ReflogCommits.SetSelection(0)
// loading a heap of commits is slow so we limit them whenever doing a reset
self.c.Contexts().LocalCommits.SetLimitCommits(true)
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES, types.BRANCHES, types.REFLOG, types.COMMITS}, CommitSelection: types.KeepCommitSelectionIndex})
self.c.RefreshFromWorker(types.RefreshOptions{
Scope: []types.RefreshableView{types.FILES, types.BRANCHES, types.REFLOG, types.COMMITS},
CommitSelection: types.SelectHeadCommit,
SelectTopReflogCommit: true,
})
return nil
}
@@ -288,7 +279,9 @@ func (self *RefsHelper) CreateGitResetMenu(name string, ref string) error {
Prompt: self.c.Tr.ResetHardConfirmation,
HandleConfirm: func() error {
self.c.LogAction("Reset")
return self.ResetToRef(ref, row.strength, []string{})
return self.c.WithWaitingStatus(self.c.Tr.ResettingStatus, func(gocui.Task) error {
return self.ResetToRef(ref, row.strength, []string{})
})
},
})
},
@@ -369,16 +362,21 @@ func (self *RefsHelper) NewBranch(from string, fromFormattedName string, suggest
}
refresh := func() {
if self.c.Context().Current() != self.c.Contexts().Branches {
self.c.Context().Push(self.c.Contexts().Branches, types.OnFocusOpts{})
}
self.SelectFirstBranchAndFirstCommit()
self.c.Refresh(types.RefreshOptions{
Mode: types.BLOCK_UI,
KeepBranchSelectionIndex: true,
CommitSelection: types.KeepCommitSelectionIndex,
self.c.RefreshFromWorker(types.RefreshOptions{
BatchUIUpdates: true,
BranchSelection: types.SelectCheckedOutBranch,
CommitSelection: types.SelectHeadCommit,
SelectTopReflogCommit: true,
Then: func() error {
// Switch to the branches panel only now, in the same batched
// frame that applies the refreshed data, so the panel switch
// and the new branch appear together rather than flashing the
// old branch list while the checkout is still in progress.
if self.c.Context().Current() != self.c.Contexts().Branches {
self.c.Context().Push(self.c.Contexts().Branches, types.OnFocusOpts{})
}
return nil
},
})
}
@@ -392,34 +390,44 @@ func (self *RefsHelper) NewBranch(from string, fromFormattedName string, suggest
if newBranchName != suggestedBranchName {
newBranchFunc = self.c.Git().Branch.NewWithoutTracking
}
if err := newBranchFunc(newBranchName, from); err != nil {
if IsSwitchBranchUncommittedChangesError(err) {
// offer to autostash changes
self.c.Confirm(types.ConfirmOpts{
Title: self.c.Tr.AutoStashTitle,
Prompt: self.c.Tr.AutoStashPrompt,
HandleConfirm: func() error {
if err := self.c.Git().Stash.Push(fmt.Sprintf(self.c.Tr.AutoStashForNewBranch, newBranchName)); err != nil {
return err
}
if err := newBranchFunc(newBranchName, from); err != nil {
return err
}
err := self.c.Git().Stash.Pop(0)
// Branch switch successful so re-render the UI even if the pop operation failed (e.g. conflict).
refresh()
return err
},
})
return nil
// Creating the branch checks it out, which can take a while when
// the ref we're branching off is distant, so do it on a worker.
return self.c.WithWaitingStatus(self.c.Tr.CreatingBranchStatus, func(gocui.Task) error {
if err := newBranchFunc(newBranchName, from); err != nil {
if IsSwitchBranchUncommittedChangesError(err) {
// offer to autostash changes
self.c.OnUIThread(func() error {
self.c.Confirm(types.ConfirmOpts{
Title: self.c.Tr.AutoStashTitle,
Prompt: self.c.Tr.AutoStashPrompt,
HandleConfirm: func() error {
return self.c.WithWaitingStatus(self.c.Tr.CreatingBranchStatus, func(gocui.Task) error {
if err := self.c.Git().Stash.Push(fmt.Sprintf(self.c.Tr.AutoStashForNewBranch, newBranchName)); err != nil {
return err
}
if err := newBranchFunc(newBranchName, from); err != nil {
return err
}
err := self.c.Git().Stash.Pop(0)
// Branch switch successful so re-render the UI even if the pop operation failed (e.g. conflict).
refresh()
return err
})
},
})
return nil
})
return nil
}
return err
}
return err
}
refresh()
return nil
refresh()
return nil
})
},
})
@@ -433,6 +441,8 @@ func (self *RefsHelper) MoveCommitsToNewBranch() error {
return err
}
mustStash := IsWorkingTreeDirtyExceptSubmodules(self.c.Model().Files, self.c.Model().Submodules)
withNewBranchNamePrompt := func(baseBranchName string, f func(string) error) error {
prompt := utils.ResolvePlaceholderString(
self.c.Tr.NewBranchNameBranchOff,
@@ -471,7 +481,9 @@ func (self *RefsHelper) MoveCommitsToNewBranch() error {
Title: self.c.Tr.MoveCommitsToNewBranch,
Prompt: prompt,
HandleConfirm: func() error {
return withNewBranchNamePrompt(currentBranch.Name, self.moveCommitsToNewBranchStackedOnCurrentBranch)
return withNewBranchNamePrompt(currentBranch.Name, func(newBranchName string) error {
return self.moveCommitsToNewBranchStackedOnCurrentBranch(newBranchName, mustStash)
})
},
})
return nil
@@ -491,27 +503,31 @@ func (self *RefsHelper) MoveCommitsToNewBranch() error {
{
Label: fmt.Sprintf(self.c.Tr.MoveCommitsToNewBranchFromBaseItem, shortBaseBranchName),
OnPress: func() error {
commitsToCherryPick := lo.Filter(self.c.Model().Commits, func(commit *models.Commit, _ int) bool {
return commit.Status == models.StatusUnpushed
})
return withNewBranchNamePrompt(shortBaseBranchName, func(newBranchName string) error {
return self.moveCommitsToNewBranchOffOfMainBranch(newBranchName, baseBranchRef)
return self.moveCommitsToNewBranchOffOfMainBranch(newBranchName, baseBranchRef, commitsToCherryPick, mustStash)
})
},
},
{
Label: fmt.Sprintf(self.c.Tr.MoveCommitsToNewBranchStackedItem, currentBranch.Name),
OnPress: func() error {
return withNewBranchNamePrompt(currentBranch.Name, self.moveCommitsToNewBranchStackedOnCurrentBranch)
return withNewBranchNamePrompt(currentBranch.Name, func(newBranchName string) error {
return self.moveCommitsToNewBranchStackedOnCurrentBranch(newBranchName, mustStash)
})
},
},
},
})
}
func (self *RefsHelper) moveCommitsToNewBranchStackedOnCurrentBranch(newBranchName string) error {
func (self *RefsHelper) moveCommitsToNewBranchStackedOnCurrentBranch(newBranchName string, mustStash bool) error {
if err := self.c.Git().Branch.NewWithoutCheckout(newBranchName, "HEAD"); err != nil {
return err
}
mustStash := IsWorkingTreeDirtyExceptSubmodules(self.c.Model().Files, self.c.Model().Submodules)
if mustStash {
if err := self.c.Git().Stash.Push(fmt.Sprintf(self.c.Tr.AutoStashForNewBranch, newBranchName)); err != nil {
return err
@@ -532,22 +548,16 @@ func (self *RefsHelper) moveCommitsToNewBranchStackedOnCurrentBranch(newBranchNa
}
}
self.SelectFirstBranchAndFirstCommit()
self.c.Refresh(types.RefreshOptions{
Mode: types.BLOCK_UI,
KeepBranchSelectionIndex: true,
CommitSelection: types.KeepCommitSelectionIndex,
self.c.RefreshFromWorker(types.RefreshOptions{
BatchUIUpdates: true,
BranchSelection: types.SelectCheckedOutBranch,
CommitSelection: types.SelectHeadCommit,
SelectTopReflogCommit: true,
})
return nil
}
func (self *RefsHelper) moveCommitsToNewBranchOffOfMainBranch(newBranchName string, baseBranchRef string) error {
commitsToCherryPick := lo.Filter(self.c.Model().Commits, func(commit *models.Commit, _ int) bool {
return commit.Status == models.StatusUnpushed
})
mustStash := IsWorkingTreeDirtyExceptSubmodules(self.c.Model().Files, self.c.Model().Submodules)
func (self *RefsHelper) moveCommitsToNewBranchOffOfMainBranch(newBranchName string, baseBranchRef string, commitsToCherryPick []*models.Commit, mustStash bool) error {
if mustStash {
if err := self.c.Git().Stash.Push(fmt.Sprintf(self.c.Tr.AutoStashForNewBranch, newBranchName)); err != nil {
return err
@@ -563,7 +573,7 @@ func (self *RefsHelper) moveCommitsToNewBranchOffOfMainBranch(newBranchName stri
}
err := self.c.Git().Rebase.CherryPickCommits(commitsToCherryPick)
err = self.rebaseHelper.CheckMergeOrRebaseWithRefreshOptions(err, types.RefreshOptions{Mode: types.SYNC})
err = self.rebaseHelper.CheckMergeOrRebaseWithRefreshOptions(err, types.RefreshOptions{})
if err != nil {
return err
}
@@ -574,12 +584,11 @@ func (self *RefsHelper) moveCommitsToNewBranchOffOfMainBranch(newBranchName stri
}
}
self.SelectFirstBranchAndFirstCommit()
self.c.Refresh(types.RefreshOptions{
Mode: types.BLOCK_UI,
KeepBranchSelectionIndex: true,
CommitSelection: types.KeepCommitSelectionIndex,
self.c.RefreshFromWorker(types.RefreshOptions{
BatchUIUpdates: true,
BranchSelection: types.SelectCheckedOutBranch,
CommitSelection: types.SelectHeadCommit,
SelectTopReflogCommit: true,
})
return nil
}
+85 -43
View File
@@ -13,7 +13,6 @@ import (
"github.com/jesseduffield/lazygit/pkg/commands/direnv"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/env"
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/context"
"github.com/jesseduffield/lazygit/pkg/gui/presentation/icons"
"github.com/jesseduffield/lazygit/pkg/gui/style"
@@ -44,13 +43,20 @@ func NewRecentReposHelper(
}
func (self *ReposHelper) EnterSubmodule(submodule *models.SubmoduleConfig) error {
// Check before pushing onto the repo-path stack, so a refused switch
// doesn't leave a stale entry there (which escape would later switch back
// to, needlessly reloading the current repo).
if self.switchRefusedBecauseBusy() {
return nil
}
wd, err := os.Getwd()
if err != nil {
return err
}
self.c.State().GetRepoPathStack().Push(wd)
return self.DispatchSwitchToRepo(submodule.FullPath(), context.NO_CONTEXT)
return self.switchTo(submodule.FullPath(), self.c.Tr.ErrRepositoryMovedOrDeleted, context.NO_CONTEXT)
}
func (self *ReposHelper) getCurrentBranch(path string) string {
@@ -130,10 +136,16 @@ func (self *ReposHelper) CreateRecentReposMenu() error {
style.FgMagenta.Sprint(path),
},
OnPress: func() error {
// Check before clearing the stack, so a refused switch doesn't
// forget the submodule breadcrumb (which would leave escape
// unable to return to the parent repo).
if self.switchRefusedBecauseBusy() {
return nil
}
// if we were in a submodule, we want to forget about that stack of repos
// so that hitting escape in the new repo does nothing
self.c.State().GetRepoPathStack().Clear()
return self.DispatchSwitchToRepo(path, context.NO_CONTEXT)
return self.switchTo(path, self.c.Tr.ErrRepositoryMovedOrDeleted, context.NO_CONTEXT)
},
}
})
@@ -141,59 +153,89 @@ func (self *ReposHelper) CreateRecentReposMenu() error {
return self.c.Menu(types.CreateMenuOptions{Title: self.c.Tr.RecentRepos, Items: menuItems})
}
func (self *ReposHelper) DispatchSwitchToRepo(path string, contextKey types.ContextKey) error {
return self.DispatchSwitchTo(path, self.c.Tr.ErrRepositoryMovedOrDeleted, contextKey)
// SwitchToParentRepo switches back to the repo the current submodule was
// entered from (the top of the repo-path stack). Like the other callers that do
// work before switching, it checks for an in-flight operation *before* popping
// the stack, so a refused switch leaves the stack intact — otherwise the entry
// would be consumed and escape would no longer return to the parent once the
// operation finished. The caller must only call this when the stack is
// non-empty.
func (self *ReposHelper) SwitchToParentRepo() error {
if self.switchRefusedBecauseBusy() {
return nil
}
return self.switchTo(self.c.State().GetRepoPathStack().Pop(), self.c.Tr.ErrRepositoryMovedOrDeleted, context.NO_CONTEXT)
}
func (self *ReposHelper) DispatchSwitchTo(path string, errMsg string, contextKey types.ContextKey) error {
return self.c.WithWaitingStatus(self.c.Tr.Switching, func(gocui.Task) error {
env.UnsetGitLocationEnvVars()
originalPath, err := os.Getwd()
if err != nil {
return nil
if self.switchRefusedBecauseBusy() {
return nil
}
return self.switchTo(path, errMsg, contextKey)
}
// switchRefusedBecauseBusy reports (and shows a toast) whether a repo switch
// must be refused because a foreground git operation is in flight. Switching
// reassigns gui.git and the process cwd, so switching mid-operation would run
// the operation's remaining git commands against the wrong repo. Callers that
// do work before the switch (creating a worktree, recording the repo-path
// stack) check this up front, so they don't do that work only to have the
// switch refused; the switch itself (switchTo) is then unguarded.
func (self *ReposHelper) switchRefusedBecauseBusy() bool {
if self.c.GocuiGui().Busy() {
self.c.ErrorToast(self.c.Tr.CantSwitchWhileOperationInProgress)
return true
}
return false
}
// switchTo switches lazygit to the repository (or worktree) at the given path.
// It runs synchronously on the UI thread: the switch swaps gui.State (in
// resetState) and reassigns gui.git and the process cwd, all of which the UI
// thread also reads, so doing it here rather than on a worker avoids racing
// those reads. The heavy data loading is still dispatched asynchronously by the
// refresh that onNewRepo kicks off.
func (self *ReposHelper) switchTo(path string, errMsg string, contextKey types.ContextKey) error {
env.UnsetGitLocationEnvVars()
originalPath, err := os.Getwd()
if err != nil {
return nil
}
msg := utils.ResolvePlaceholderString(self.c.Tr.ChangingDirectoryTo, map[string]string{"path": path})
self.c.LogCommand(msg, false)
if err := os.Chdir(path); err != nil {
if os.IsNotExist(err) {
return errors.New(errMsg)
}
return err
}
msg := utils.ResolvePlaceholderString(self.c.Tr.ChangingDirectoryTo, map[string]string{"path": path})
self.c.LogCommand(msg, false)
if err := os.Chdir(path); err != nil {
if os.IsNotExist(err) {
return errors.New(errMsg)
}
if err := commands.VerifyInGitRepo(self.c.OS()); err != nil {
if err := os.Chdir(originalPath); err != nil {
return err
}
if err := commands.VerifyInGitRepo(self.c.OS()); err != nil {
if err := os.Chdir(originalPath); err != nil {
return err
}
return err
}
return err
}
direnvResult := self.logDirenvResult(direnv.Load(self.c.OS().Cmd))
direnvResult := self.logDirenvResult(direnv.Load(self.c.OS().Cmd))
if err := self.recordDirectoryHelper.RecordCurrentDirectory(); err != nil {
self.c.Log.Errorf("error recording current directory: %v", err)
}
if err := self.recordDirectoryHelper.RecordCurrentDirectory(); err != nil {
self.c.Log.Errorf("error recording current directory: %v", err)
}
if err := self.onNewRepo(appTypes.StartArgs{}, contextKey); err != nil {
return err
}
self.c.Mutexes().RefreshingFilesMutex.Lock()
defer self.c.Mutexes().RefreshingFilesMutex.Unlock()
if direnvResult.Blocked {
self.promptDirenvApproval(direnvResult.EnvrcPath)
return nil
}
if err := self.onNewRepo(appTypes.StartArgs{}, contextKey); err != nil {
return err
}
if direnvResult.Blocked {
self.c.OnUIThread(func() error {
self.promptDirenvApproval(direnvResult.EnvrcPath)
return nil
})
return nil
}
return direnvResult.Err
})
return direnvResult.Err
}
// logDirenvResult writes whatever direnv emitted to the command log and the
@@ -49,7 +49,7 @@ func (self *SubCommitsHelper) ViewSubCommits(opts ViewSubCommitsOpts) error {
return err
}
self.setSubCommits(commits)
self.c.Model().SubCommits = commits
self.refreshHelper.RefreshAuthors(commits)
subCommitsContext := self.c.Contexts().SubCommits
@@ -71,10 +71,3 @@ func (self *SubCommitsHelper) ViewSubCommits(opts ViewSubCommitsOpts) error {
self.c.Context().Push(self.c.Contexts().SubCommits, types.OnFocusOpts{})
return nil
}
func (self *SubCommitsHelper) setSubCommits(commits []*models.Commit) {
self.c.Mutexes().SubCommitsMutex.Lock()
defer self.c.Mutexes().SubCommitsMutex.Unlock()
self.c.Model().SubCommits = commits
}
@@ -3,6 +3,7 @@ package helpers
import (
"fmt"
"strings"
"sync/atomic"
"github.com/jesseduffield/generics/set"
"github.com/jesseduffield/lazygit/pkg/commands/git_commands"
@@ -28,14 +29,20 @@ import (
type SuggestionsHelper struct {
c *HelperCommon
// filesTrie holds the repo's file paths for file-path suggestions. It's
// rebuilt asynchronously and read from the suggestions worker goroutine, so
// it lives here as an atomic pointer rather than in the (UI-thread-only)
// model.
filesTrie atomic.Pointer[patricia.Trie]
}
func NewSuggestionsHelper(
c *HelperCommon,
) *SuggestionsHelper {
return &SuggestionsHelper{
c: c,
}
self := &SuggestionsHelper{c: c}
self.filesTrie.Store(patricia.NewTrie())
return self
}
func (self *SuggestionsHelper) getRemoteNames() []string {
@@ -138,17 +145,20 @@ func (self *SuggestionsHelper) GetFilePathSuggestionsFunc() func(string) []*type
}
// cache the trie for future use
self.c.Model().FilesTrie = trie
self.c.Contexts().Suggestions.RefreshSuggestions()
self.filesTrie.Store(trie)
self.c.OnUIThread(func() error {
self.c.Contexts().Suggestions.RefreshSuggestions()
return nil
})
return err
})
return func(input string) []*types.Suggestion {
filesTrie := self.filesTrie.Load()
matchingNames := []string{}
if self.c.UserConfig().Gui.UseFuzzySearch() {
_ = self.c.Model().FilesTrie.VisitFuzzy(patricia.Prefix(input), true, func(prefix patricia.Prefix, item patricia.Item, skipped int) error {
_ = filesTrie.VisitFuzzy(patricia.Prefix(input), true, func(prefix patricia.Prefix, item patricia.Item, skipped int) error {
matchingNames = append(matchingNames, item.(string))
return nil
})
@@ -157,7 +167,7 @@ func (self *SuggestionsHelper) GetFilePathSuggestionsFunc() func(string) []*type
matchingNames = utils.FilterStrings(input, matchingNames, true)
} else {
substrings := strings.Fields(input)
_ = self.c.Model().FilesTrie.Visit(func(prefix patricia.Prefix, item patricia.Item) error {
_ = filesTrie.Visit(func(prefix patricia.Prefix, item patricia.Item) error {
for _, sub := range substrings {
if !utils.CaseAwareContains(item.(string), sub) {
return nil
@@ -149,7 +149,12 @@ func (self *WorkingTreeHelper) handleCommit(summary string, description string,
self.c.LogAction(self.c.Tr.Actions.Commit)
return self.gpgHelper.WithGpgHandlingAndSelectHeadCommit(cmdObj, git_commands.CommitGpgSign, self.c.Tr.CommittingStatus,
func() error {
self.commitsHelper.ClearPreservedCommitMessage()
// This runs on a worker when the commit output is streamed, so
// bounce the preserved-message write to the UI thread.
self.c.OnUIThread(func() error {
self.commitsHelper.ClearPreservedCommitMessage()
return nil
})
return nil
})
}
@@ -222,15 +227,23 @@ func (self *WorkingTreeHelper) HandleCommitPress() error {
}
func (self *WorkingTreeHelper) WithEnsureCommittableFiles(handler func() error) error {
if err := self.prepareFilesForCommit(); err != nil {
return err
}
if len(self.c.Model().Files) == 0 {
return errors.New(self.c.Tr.NoFilesStagedTitle)
}
if !self.AnyStagedFiles() {
if self.c.UserConfig().Gui.SkipNoStagedFilesWarning {
self.c.LogAction(self.c.Tr.Actions.StageAllFiles)
if err := self.c.Git().WorkingTree.StageAll(false); err != nil {
return err
}
self.c.Refresh(types.RefreshOptions{
Scope: []types.RefreshableView{types.FILES},
Then: handler,
})
return nil
}
return self.promptToStageAllAndRetry(handler)
}
@@ -246,7 +259,7 @@ func (self *WorkingTreeHelper) promptToStageAllAndRetry(retry func() error) erro
if err := self.c.Git().WorkingTree.StageAll(false); err != nil {
return err
}
self.syncRefresh()
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}})
return retry()
},
@@ -255,26 +268,6 @@ func (self *WorkingTreeHelper) promptToStageAllAndRetry(retry func() error) erro
return nil
}
// for when you need to refetch files before continuing an action. Runs synchronously.
func (self *WorkingTreeHelper) syncRefresh() {
self.c.Refresh(types.RefreshOptions{Mode: types.SYNC, Scope: []types.RefreshableView{types.FILES}})
}
func (self *WorkingTreeHelper) prepareFilesForCommit() error {
noStagedFiles := !self.AnyStagedFiles()
if noStagedFiles && self.c.UserConfig().Gui.SkipNoStagedFilesWarning {
self.c.LogAction(self.c.Tr.Actions.StageAllFiles)
err := self.c.Git().WorkingTree.StageAll(false)
if err != nil {
return err
}
self.syncRefresh()
}
return nil
}
func (self *WorkingTreeHelper) commitPrefixConfigsForRepo() []config.CommitPrefixConfig {
cfg, ok := self.c.UserConfig().Git.CommitPrefixes[self.c.Git().RepoPaths.RepoName()]
if ok {
@@ -366,7 +359,7 @@ func (self *WorkingTreeHelper) CreateMergeConflictMenu(selectedFilepaths []strin
}
err := self.c.Git().WorkingTree.StageFiles(selectedFilepaths, nil)
self.c.Refresh(types.RefreshOptions{Mode: types.SYNC, Scope: []types.RefreshableView{types.FILES}})
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}})
return err
}
+18 -3
View File
@@ -163,7 +163,7 @@ func (self *WorktreeHelper) remove(worktree *models.Worktree, force bool, then f
return then(task)
}
self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.WORKTREES, types.BRANCHES, types.FILES}})
self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.WORKTREES, types.BRANCHES, types.FILES}})
return nil
})
}
@@ -181,7 +181,7 @@ func (self *WorktreeHelper) Detach(worktree *models.Worktree, then func(gocui.Ta
return then(task)
}
self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.WORKTREES, types.BRANCHES, types.FILES}})
self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.WORKTREES, types.BRANCHES, types.FILES}})
return nil
})
}
@@ -426,12 +426,27 @@ func (self *WorktreeHelper) promptForWorktreeLocation(dirName string, prompt str
}
func (self *WorktreeHelper) createWorktree(opts git_commands.NewWorktreeOpts, contextKey types.ContextKey) error {
// Check now, before we create the worktree, rather than when we come to
// switch to it afterwards: by then this operation's own waiting-status
// spinner would make Busy() true and refuse our own switch.
if self.reposHelper.switchRefusedBecauseBusy() {
return nil
}
return self.c.WithWaitingStatus(self.c.Tr.AddingWorktree, func(gocui.Task) error {
self.c.LogAction(self.c.Tr.Actions.AddWorktree)
if err := self.c.Git().Worktree.New(opts); err != nil {
return err
}
return self.reposHelper.DispatchSwitchTo(opts.Path, self.c.Tr.ErrWorktreeMovedOrRemoved, contextKey)
// The switch swaps gui.State and must run on the UI thread, but
// we're on a worker here (creating the worktree is git work), so
// dispatch it. It's unguarded (switchTo, not DispatchSwitchTo)
// because we checked above and creating the worktree is now
// complete, so switching to it is safe.
self.c.OnUIThread(func() error {
return self.reposHelper.switchTo(opts.Path, self.c.Tr.ErrWorktreeMovedOrRemoved, contextKey)
})
return nil
})
}
+157 -94
View File
@@ -340,9 +340,11 @@ func (self *LocalCommitsController) squashDown(selectedCommits []*models.Commit,
Title: self.c.Tr.Squash,
Prompt: self.c.Tr.SureSquashThisCommit,
HandleConfirm: func() error {
return self.c.WithWaitingStatus(self.c.Tr.SquashingStatus, func(gocui.Task) error {
commits := self.c.Model().Commits
self.selectRebaseResultCommit(startIdx)
return self.c.WithWaitingStatusBlockingInput(self.c.Tr.SquashingStatus, func(gocui.Task) error {
self.c.LogAction(self.c.Tr.Actions.SquashCommitDown)
return self.interactiveRebase(todo.Squash, startIdx, endIdx)
return self.interactiveRebase(commits, todo.Squash, startIdx, endIdx)
})
},
})
@@ -362,9 +364,11 @@ func (self *LocalCommitsController) fixup(selectedCommits []*models.Commit, star
Label: self.c.Tr.Fixup,
Keys: menuKey('f'),
OnPress: func() error {
return self.c.WithWaitingStatus(self.c.Tr.FixingStatus, func(gocui.Task) error {
commits := self.c.Model().Commits
self.selectRebaseResultCommit(startIdx)
return self.c.WithWaitingStatusBlockingInput(self.c.Tr.FixingStatus, func(gocui.Task) error {
self.c.LogAction(self.c.Tr.Actions.FixupCommit)
return self.interactiveRebase(todo.Fixup, startIdx, endIdx)
return self.interactiveRebase(commits, todo.Fixup, startIdx, endIdx)
})
},
Tooltip: self.c.Tr.FixupTooltip,
@@ -373,9 +377,11 @@ func (self *LocalCommitsController) fixup(selectedCommits []*models.Commit, star
Label: self.c.Tr.FixupKeepMessage,
Keys: menuKey('c'),
OnPress: func() error {
return self.c.WithWaitingStatus(self.c.Tr.FixingStatus, func(gocui.Task) error {
commits := self.c.Model().Commits
self.selectRebaseResultCommit(startIdx)
return self.c.WithWaitingStatusBlockingInput(self.c.Tr.FixingStatus, func(gocui.Task) error {
self.c.LogAction(self.c.Tr.Actions.FixupCommitKeepMessage)
return self.interactiveRebaseWithFlag(todo.Fixup, startIdx, endIdx, "-C")
return self.interactiveRebaseWithFlag(commits, todo.Fixup, startIdx, endIdx, "-C")
})
},
Tooltip: self.c.Tr.FixupKeepMessageTooltip,
@@ -470,24 +476,26 @@ func (self *LocalCommitsController) switchFromCommitMessagePanelToEditor(filepat
return err
}
self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC})
self.c.Refresh(types.RefreshOptions{})
return nil
}
func (self *LocalCommitsController) handleReword(summary string, description string) error {
if models.IsHeadCommit(self.c.Model().Commits, self.c.Contexts().LocalCommits.GetSelectedLineIdx()) {
commits := self.c.Model().Commits
selectedIdx := self.c.Contexts().LocalCommits.GetSelectedLineIdx()
if models.IsHeadCommit(commits, selectedIdx) {
// we've selected the top commit so no rebase is required
return self.c.Helpers().GPG.WithGpgHandling(self.c.Git().Commit.RewordLastCommit(summary, description),
git_commands.CommitGpgSign,
self.c.Tr.RewordingStatus, nil, nil)
}
return self.c.WithWaitingStatus(self.c.Tr.RewordingStatus, func(gocui.Task) error {
err := self.c.Git().Rebase.RewordCommit(self.c.Model().Commits, self.c.Contexts().LocalCommits.GetSelectedLineIdx(), summary, description)
return self.c.WithWaitingStatusBlockingInput(self.c.Tr.RewordingStatus, func(gocui.Task) error {
err := self.c.Git().Rebase.RewordCommit(commits, selectedIdx, summary, description)
if err != nil {
return err
}
self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC})
self.c.RefreshFromWorker(types.RefreshOptions{})
return nil
})
}
@@ -564,12 +572,16 @@ func (self *LocalCommitsController) drop(selectedCommits []*models.Commit, start
Title: self.c.Tr.DropCommitTitle,
Prompt: lo.Ternary(isMerge, self.c.Tr.DropMergeCommitPrompt, self.c.Tr.DropCommitPrompt),
HandleConfirm: func() error {
return self.c.WithWaitingStatus(self.c.Tr.DroppingStatus, func(gocui.Task) error {
commits := self.c.Model().Commits
if !isMerge {
self.selectRebaseResultCommit(startIdx)
}
return self.c.WithWaitingStatusBlockingInput(self.c.Tr.DroppingStatus, func(gocui.Task) error {
self.c.LogAction(self.c.Tr.Actions.DropCommit)
if isMerge {
return self.dropMergeCommit(startIdx)
return self.dropMergeCommit(commits, startIdx)
}
return self.interactiveRebase(todo.Drop, startIdx, endIdx)
return self.interactiveRebase(commits, todo.Drop, startIdx, endIdx)
})
},
})
@@ -577,8 +589,8 @@ func (self *LocalCommitsController) drop(selectedCommits []*models.Commit, start
return nil
}
func (self *LocalCommitsController) dropMergeCommit(commitIdx int) error {
err := self.c.Git().Rebase.DropMergeCommit(self.c.Model().Commits, commitIdx)
func (self *LocalCommitsController) dropMergeCommit(commits []*models.Commit, commitIdx int) error {
err := self.c.Git().Rebase.DropMergeCommit(commits, commitIdx)
return self.c.Helpers().MergeAndRebase.CheckMergeOrRebase(err)
}
@@ -589,9 +601,11 @@ func (self *LocalCommitsController) edit(selectedCommits []*models.Commit, start
commits := self.c.Model().Commits
if !commits[endIdx].IsMerge() {
err := self.c.Git().Rebase.InteractiveRebase(commits, startIdx, endIdx, todo.Edit, "")
return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions(
err, types.RefreshOptions{Mode: types.BLOCK_UI})
return self.c.WithWaitingStatusBlockingInput(self.c.Tr.RebasingStatus, func(gocui.Task) error {
err := self.c.Git().Rebase.InteractiveRebase(commits, startIdx, endIdx, todo.Edit, "")
return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions(
err, types.RefreshOptions{BatchUIUpdates: true})
})
}
return self.startInteractiveRebaseWithEdit(selectedCommits)
@@ -609,12 +623,12 @@ func (self *LocalCommitsController) quickStartInteractiveRebase() error {
func (self *LocalCommitsController) startInteractiveRebaseWithEdit(
commitsToEdit []*models.Commit,
) error {
return self.c.WithWaitingStatus(self.c.Tr.RebasingStatus, func(gocui.Task) error {
return self.c.WithWaitingStatusBlockingInput(self.c.Tr.RebasingStatus, func(gocui.Task) error {
self.c.LogAction(self.c.Tr.Actions.EditCommit)
err := self.c.Git().Rebase.EditRebase(commitsToEdit[len(commitsToEdit)-1].Hash())
return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions(
err,
types.RefreshOptions{Mode: types.BLOCK_UI, Then: func() {
types.RefreshOptions{BatchUIUpdates: true, Then: func() error {
todos := make([]*models.Commit, 0, len(commitsToEdit)-1)
for _, c := range commitsToEdit[:len(commitsToEdit)-1] {
// Merge commits can't be set to "edit", so just skip them
@@ -623,11 +637,9 @@ func (self *LocalCommitsController) startInteractiveRebaseWithEdit(
}
}
if len(todos) > 0 {
err := self.updateTodos(todo.Edit, todos)
if err != nil {
self.c.Log.Errorf("error when updating todos: %v", err)
}
return self.updateTodos(todo.Edit, todos)
}
return nil
}})
})
}
@@ -656,22 +668,25 @@ func (self *LocalCommitsController) pick(selectedCommits []*models.Commit) error
panic("should be disabled when not rebasing")
}
func (self *LocalCommitsController) interactiveRebase(action todo.TodoCommand, startIdx int, endIdx int) error {
return self.interactiveRebaseWithFlag(action, startIdx, endIdx, "")
func (self *LocalCommitsController) interactiveRebase(commits []*models.Commit, action todo.TodoCommand, startIdx int, endIdx int) error {
return self.interactiveRebaseWithFlag(commits, action, startIdx, endIdx, "")
}
func (self *LocalCommitsController) interactiveRebaseWithFlag(action todo.TodoCommand, startIdx int, endIdx int, flag string) error {
// When performing an action that will remove the selected commits, we need to select the
// next commit down (which will end up at the start index after the action is performed)
if action == todo.Drop || action == todo.Fixup || action == todo.Squash {
self.context().SetSelection(startIdx)
}
err := self.c.Git().Rebase.InteractiveRebase(self.c.Model().Commits, startIdx, endIdx, action, flag)
func (self *LocalCommitsController) interactiveRebaseWithFlag(commits []*models.Commit, action todo.TodoCommand, startIdx int, endIdx int, flag string) error {
err := self.c.Git().Rebase.InteractiveRebase(commits, startIdx, endIdx, action, flag)
return self.c.Helpers().MergeAndRebase.CheckMergeOrRebase(err)
}
// selectRebaseResultCommit selects the commit that a drop/fixup/squash starting
// at startIdx will leave there. It must run on the UI thread before the rebase:
// the commit currently at startIdx is removed, so the refresh's
// keep-selection-by-hash can't restore it and falls back to the index, which by
// then holds the commit that shifted up into its place.
func (self *LocalCommitsController) selectRebaseResultCommit(startIdx int) {
self.context().SetSelection(startIdx)
}
// updateTodos sees if the selected commit is in fact a rebasing
// commit meaning you are trying to edit the todo file rather than actually
// begin a rebase. It then updates the todo file with that action
@@ -685,7 +700,7 @@ func (self *LocalCommitsController) updateTodosWithFlag(action todo.TodoCommand,
}
self.c.Refresh(types.RefreshOptions{
Mode: types.SYNC, Scope: []types.RefreshableView{types.REBASE_COMMITS},
Scope: []types.RefreshableView{types.REBASE_COMMITS},
})
return nil
@@ -727,22 +742,30 @@ func (self *LocalCommitsController) moveDown(selectedCommits []*models.Commit, s
self.context().HandleFocus(types.OnFocusOpts{ScrollSelectionIntoView: true})
self.c.Refresh(types.RefreshOptions{
Mode: types.SYNC,
Scope: []types.RefreshableView{types.REBASE_COMMITS},
CommitSelection: types.KeepCommitSelectionIndex,
})
return nil
}
return self.c.WithWaitingStatusSync(self.c.Tr.MovingStatus, func() error {
commits := self.c.Model().Commits
return self.c.WithWaitingStatusBlockingInput(self.c.Tr.MovingStatus, func(gocui.Task) error {
self.c.LogAction(self.c.Tr.Actions.MoveCommitDown)
err := self.c.Git().Rebase.MoveCommitsDown(self.c.Model().Commits, startIdx, endIdx)
if err == nil {
self.context().MoveSelection(1)
self.context().HandleFocus(types.OnFocusOpts{ScrollSelectionIntoView: true})
}
err := self.c.Git().Rebase.MoveCommitsDown(commits, startIdx, endIdx)
return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions(
err, types.RefreshOptions{Mode: types.SYNC, CommitSelection: types.KeepCommitSelectionIndex})
err, types.RefreshOptions{
BatchUIUpdates: true,
CommitSelection: types.KeepCommitSelectionIndex,
// Move the selection to follow the moved commit, in Then so it
// lands in the same frame as the refreshed commit list.
Then: func() error {
if err == nil {
self.context().MoveSelection(1)
self.context().HandleFocus(types.OnFocusOpts{ScrollSelectionIntoView: true})
}
return nil
},
})
})
}
@@ -755,22 +778,30 @@ func (self *LocalCommitsController) moveUp(selectedCommits []*models.Commit, sta
self.context().HandleFocus(types.OnFocusOpts{ScrollSelectionIntoView: true})
self.c.Refresh(types.RefreshOptions{
Mode: types.SYNC,
Scope: []types.RefreshableView{types.REBASE_COMMITS},
CommitSelection: types.KeepCommitSelectionIndex,
})
return nil
}
return self.c.WithWaitingStatusSync(self.c.Tr.MovingStatus, func() error {
commits := self.c.Model().Commits
return self.c.WithWaitingStatusBlockingInput(self.c.Tr.MovingStatus, func(gocui.Task) error {
self.c.LogAction(self.c.Tr.Actions.MoveCommitUp)
err := self.c.Git().Rebase.MoveCommitsUp(self.c.Model().Commits, startIdx, endIdx)
if err == nil {
self.context().MoveSelection(-1)
self.context().HandleFocus(types.OnFocusOpts{ScrollSelectionIntoView: true})
}
err := self.c.Git().Rebase.MoveCommitsUp(commits, startIdx, endIdx)
return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions(
err, types.RefreshOptions{Mode: types.SYNC, CommitSelection: types.KeepCommitSelectionIndex})
err, types.RefreshOptions{
BatchUIUpdates: true,
CommitSelection: types.KeepCommitSelectionIndex,
// Move the selection to follow the moved commit, in Then so it
// lands in the same frame as the refreshed commit list.
Then: func() error {
if err == nil {
self.context().MoveSelection(-1)
self.context().HandleFocus(types.OnFocusOpts{ScrollSelectionIntoView: true})
}
return nil
},
})
})
}
@@ -783,16 +814,18 @@ func (self *LocalCommitsController) amendTo(commit *models.Commit) error {
if err := self.c.Helpers().AmendHelper.AmendHead(); err != nil {
return err
}
self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC})
self.c.Refresh(types.RefreshOptions{})
return nil
})
}
} else {
commits := self.c.Model().Commits
selectedIdx := self.context().GetView().SelectedLineIdx()
handleCommit = func() error {
return self.c.Helpers().WorkingTree.WithEnsureCommittableFiles(func() error {
return self.c.WithWaitingStatus(self.c.Tr.AmendingStatus, func(gocui.Task) error {
return self.c.WithWaitingStatusBlockingInput(self.c.Tr.AmendingStatus, func(gocui.Task) error {
self.c.LogAction(self.c.Tr.Actions.AmendCommit)
err := self.c.Git().Rebase.AmendTo(self.c.Model().Commits, self.context().GetView().SelectedLineIdx())
err := self.c.Git().Rebase.AmendTo(commits, selectedIdx)
return self.c.Helpers().MergeAndRebase.CheckMergeOrRebase(err)
})
})
@@ -820,26 +853,30 @@ func (self *LocalCommitsController) canAmend(_ *models.Commit) *types.DisabledRe
return self.canAmendRange(self.c.Model().Commits, idx, idx)
}
func (self *LocalCommitsController) amendAttribute(commits []*models.Commit, start, end int) error {
func (self *LocalCommitsController) amendAttribute(_ []*models.Commit, start, end int) error {
// The author operations index into the full commit list by absolute
// start/end, so capture that here on the UI thread rather than reading
// Model().Commits from the worker the menu items dispatch to.
commits := self.c.Model().Commits
opts := self.c.KeybindingsOpts()
return self.c.Menu(types.CreateMenuOptions{
Title: "Amend commit attribute",
Items: []*types.MenuItem{
{
Label: self.c.Tr.ResetAuthor,
OnPress: func() error { return self.resetAuthor(start, end) },
OnPress: func() error { return self.resetAuthor(commits, start, end) },
Keys: opts.GetKeys(opts.Config.AmendAttribute.ResetAuthor),
Tooltip: self.c.Tr.ResetAuthorTooltip,
},
{
Label: self.c.Tr.SetAuthor,
OnPress: func() error { return self.setAuthor(start, end) },
OnPress: func() error { return self.setAuthor(commits, start, end) },
Keys: opts.GetKeys(opts.Config.AmendAttribute.SetAuthor),
Tooltip: self.c.Tr.SetAuthorTooltip,
},
{
Label: self.c.Tr.AddCoAuthor,
OnPress: func() error { return self.addCoAuthor(start, end) },
OnPress: func() error { return self.addCoAuthor(commits, start, end) },
Keys: opts.GetKeys(opts.Config.AmendAttribute.AddCoAuthor),
Tooltip: self.c.Tr.AddCoAuthorTooltip,
},
@@ -847,30 +884,30 @@ func (self *LocalCommitsController) amendAttribute(commits []*models.Commit, sta
})
}
func (self *LocalCommitsController) resetAuthor(start, end int) error {
return self.c.WithWaitingStatus(self.c.Tr.AmendingStatus, func(gocui.Task) error {
func (self *LocalCommitsController) resetAuthor(commits []*models.Commit, start, end int) error {
return self.c.WithWaitingStatusBlockingInput(self.c.Tr.AmendingStatus, func(gocui.Task) error {
self.c.LogAction(self.c.Tr.Actions.ResetCommitAuthor)
if err := self.c.Git().Rebase.ResetCommitAuthor(self.c.Model().Commits, start, end); err != nil {
if err := self.c.Git().Rebase.ResetCommitAuthor(commits, start, end); err != nil {
return err
}
self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC})
self.c.RefreshFromWorker(types.RefreshOptions{})
return nil
})
}
func (self *LocalCommitsController) setAuthor(start, end int) error {
func (self *LocalCommitsController) setAuthor(commits []*models.Commit, start, end int) error {
self.c.Prompt(types.PromptOpts{
Title: self.c.Tr.SetAuthorPromptTitle,
FindSuggestionsFunc: self.c.Helpers().Suggestions.GetAuthorsSuggestionsFunc(),
HandleConfirm: func(value string) error {
return self.c.WithWaitingStatus(self.c.Tr.AmendingStatus, func(gocui.Task) error {
return self.c.WithWaitingStatusBlockingInput(self.c.Tr.AmendingStatus, func(gocui.Task) error {
self.c.LogAction(self.c.Tr.Actions.SetCommitAuthor)
if err := self.c.Git().Rebase.SetCommitAuthor(self.c.Model().Commits, start, end, value); err != nil {
if err := self.c.Git().Rebase.SetCommitAuthor(commits, start, end, value); err != nil {
return err
}
self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC})
self.c.RefreshFromWorker(types.RefreshOptions{})
return nil
})
},
@@ -879,17 +916,17 @@ func (self *LocalCommitsController) setAuthor(start, end int) error {
return nil
}
func (self *LocalCommitsController) addCoAuthor(start, end int) error {
func (self *LocalCommitsController) addCoAuthor(commits []*models.Commit, start, end int) error {
self.c.Prompt(types.PromptOpts{
Title: self.c.Tr.AddCoAuthorPromptTitle,
FindSuggestionsFunc: self.c.Helpers().Suggestions.GetAuthorsSuggestionsFunc(),
HandleConfirm: func(value string) error {
return self.c.WithWaitingStatus(self.c.Tr.AmendingStatus, func(gocui.Task) error {
return self.c.WithWaitingStatusBlockingInput(self.c.Tr.AmendingStatus, func(gocui.Task) error {
self.c.LogAction(self.c.Tr.Actions.AddCommitCoAuthor)
if err := self.c.Git().Rebase.AddCommitCoAuthor(self.c.Model().Commits, start, end, value); err != nil {
if err := self.c.Git().Rebase.AddCommitCoAuthor(commits, start, end, value); err != nil {
return err
}
self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC})
self.c.RefreshFromWorker(types.RefreshOptions{})
return nil
})
},
@@ -917,9 +954,8 @@ func (self *LocalCommitsController) revert(commits []*models.Commit, start, end
Prompt: promptText,
HandleConfirm: func() error {
self.c.LogAction(self.c.Tr.Actions.RevertCommit)
return self.c.WithWaitingStatusSync(self.c.Tr.RevertingStatus, func() error {
mustStash := helpers.IsWorkingTreeDirtyExceptSubmodules(self.c.Model().Files, self.c.Model().Submodules)
mustStash := helpers.IsWorkingTreeDirtyExceptSubmodules(self.c.Model().Files, self.c.Model().Submodules)
return self.c.WithWaitingStatusBlockingInput(self.c.Tr.RevertingStatus, func(gocui.Task) error {
if mustStash {
if err := self.c.Git().Stash.Push(self.c.Tr.AutoStashForReverting); err != nil {
return err
@@ -927,7 +963,8 @@ func (self *LocalCommitsController) revert(commits []*models.Commit, start, end
}
result := self.c.Git().Commit.Revert(hashes, isMerge)
if err := self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions(result, types.RefreshOptions{Mode: types.SYNC}); err != nil {
if err := self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions(result,
types.RefreshOptions{BatchUIUpdates: true}); err != nil {
return err
}
@@ -935,7 +972,7 @@ func (self *LocalCommitsController) revert(commits []*models.Commit, start, end
if err := self.c.Git().Stash.Pop(0); err != nil {
return err
}
self.c.Refresh(types.RefreshOptions{
self.c.RefreshFromWorker(types.RefreshOptions{
Scope: []types.RefreshableView{types.STASH, types.FILES},
})
}
@@ -966,16 +1003,19 @@ func (self *LocalCommitsController) createFixupCommit(commit *models.Commit) err
OnPress: func() error {
return self.c.Helpers().WorkingTree.WithEnsureCommittableFiles(func() error {
self.c.LogAction(self.c.Tr.Actions.CreateFixupCommit)
return self.c.WithWaitingStatusSync(self.c.Tr.CreatingFixupCommitStatus, func() error {
selectedIdx := self.context().GetSelectedLineIdx()
commits := self.c.Model().Commits
branches := self.c.Model().Branches
return self.c.WithWaitingStatusBlockingInput(self.c.Tr.CreatingFixupCommitStatus, func(gocui.Task) error {
if err := self.c.Git().Commit.CreateFixupCommit(commit.Hash()); err != nil {
return err
}
if err := self.moveFixupCommitToOwnerStackedBranch(commit); err != nil {
if err := self.moveFixupCommitToOwnerStackedBranch(commit, selectedIdx, commits, branches); err != nil {
return err
}
self.c.Refresh(types.RefreshOptions{Mode: types.SYNC})
self.c.RefreshFromWorker(types.RefreshOptions{BatchUIUpdates: true})
return nil
})
})
@@ -1004,7 +1044,12 @@ func (self *LocalCommitsController) createFixupCommit(commit *models.Commit) err
})
}
func (self *LocalCommitsController) moveFixupCommitToOwnerStackedBranch(targetCommit *models.Commit) error {
// moveFixupCommitToOwnerStackedBranch takes state captured on the UI thread
// (the selected index and the commits and branches models) so that it can run
// its rebase on a worker without reading the model there.
func (self *LocalCommitsController) moveFixupCommitToOwnerStackedBranch(
targetCommit *models.Commit, selectedIdx int, commits []*models.Commit, branches []*models.Branch,
) error {
if self.c.Git().Version.IsOlderThan(2, 38, 0) {
// Git 2.38.0 introduced the `rebase.updateRefs` config option. Don't
// move the commit down with older versions, as it would break the stack.
@@ -1032,9 +1077,9 @@ func (self *LocalCommitsController) moveFixupCommitToOwnerStackedBranch(targetCo
}
headOfOwnerBranchIdx := -1
for i := self.context().GetSelectedLineIdx(); i > 0; i-- {
if lo.SomeBy(self.c.Model().Branches, func(b *models.Branch) bool {
return b.CommitHash == self.c.Model().Commits[i].Hash()
for i := selectedIdx; i > 0; i-- {
if lo.SomeBy(branches, func(b *models.Branch) bool {
return b.CommitHash == commits[i].Hash()
}) {
headOfOwnerBranchIdx = i
break
@@ -1045,7 +1090,7 @@ func (self *LocalCommitsController) moveFixupCommitToOwnerStackedBranch(targetCo
return nil
}
return self.c.Git().Rebase.MoveFixupCommitDown(self.c.Model().Commits, headOfOwnerBranchIdx)
return self.c.Git().Rebase.MoveFixupCommitDown(commits, headOfOwnerBranchIdx)
}
func (self *LocalCommitsController) createAmendCommit(commit *models.Commit, includeFileChanges bool) error {
@@ -1066,16 +1111,19 @@ func (self *LocalCommitsController) createAmendCommit(commit *models.Commit, inc
PreserveMessage: false,
OnConfirm: func(summary string, description string) error {
self.c.LogAction(self.c.Tr.Actions.CreateFixupCommit)
return self.c.WithWaitingStatusSync(self.c.Tr.CreatingFixupCommitStatus, func() error {
selectedIdx := self.context().GetSelectedLineIdx()
commits := self.c.Model().Commits
branches := self.c.Model().Branches
return self.c.WithWaitingStatusBlockingInput(self.c.Tr.CreatingFixupCommitStatus, func(gocui.Task) error {
if err := self.c.Git().Commit.CreateAmendCommit(originalSubject, summary, description, includeFileChanges); err != nil {
return err
}
if err := self.moveFixupCommitToOwnerStackedBranch(commit); err != nil {
if err := self.moveFixupCommitToOwnerStackedBranch(commit, selectedIdx, commits, branches); err != nil {
return err
}
self.c.Refresh(types.RefreshOptions{Mode: types.SYNC})
self.c.RefreshFromWorker(types.RefreshOptions{BatchUIUpdates: true})
return nil
})
},
@@ -1123,12 +1171,28 @@ func (self *LocalCommitsController) squashAllFixupsInCurrentBranch() error {
func (self *LocalCommitsController) squashFixupsImpl(commit *models.Commit, rebaseStartIdx int) error {
selectionOffset := countSquashableCommitsAbove(self.c.Model().Commits, self.context().GetSelectedLineIdx(), rebaseStartIdx)
return self.c.WithWaitingStatusSync(self.c.Tr.SquashingStatus, func() error {
// The squashed fixups above the selection are removed, so the selection moves
// up by that many rows to stay on the same commit. Compute the target as an
// absolute index now, on the current list.
targetIdx := self.context().GetSelectedLineIdx() - selectionOffset
return self.c.WithWaitingStatusBlockingInput(self.c.Tr.SquashingStatus, func(gocui.Task) error {
self.c.LogAction(self.c.Tr.Actions.SquashAllAboveFixupCommits)
err := self.c.Git().Rebase.SquashAllAboveFixupCommits(commit)
self.context().MoveSelectedLine(-selectionOffset)
return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions(
err, types.RefreshOptions{Mode: types.SYNC})
err, types.RefreshOptions{
BatchUIUpdates: true,
// Set the selection in Then so it lands in the same frame as the
// refreshed commit list. It has to be an absolute index: the new
// list is shorter, so a relative move from the (clamped) old index
// could overshoot. PostRefreshUpdate repaints the moved selection.
Then: func() error {
if err == nil {
self.context().SetSelectedLineIdx(targetIdx)
self.c.PostRefreshUpdate(self.context())
}
return nil
},
})
})
}
@@ -1175,7 +1239,7 @@ func (self *LocalCommitsController) openSearch() error {
// we usually lazyload these commits but now that we're searching we need to load them now
if self.context().GetLimitCommits() {
self.context().SetLimitCommits(false)
self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.COMMITS}})
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.COMMITS}})
}
return self.c.Helpers().Search.OpenSearchPrompt(self.context())
@@ -1196,7 +1260,7 @@ func (self *LocalCommitsController) handleOpenLogMenu() error {
return self.c.WithWaitingStatus(self.c.Tr.LoadingCommits, func(gocui.Task) error {
self.c.Refresh(
types.RefreshOptions{Mode: types.SYNC, Scope: []types.RefreshableView{types.COMMITS}},
types.RefreshOptions{Scope: []types.RefreshableView{types.COMMITS}},
)
return nil
})
@@ -1250,7 +1314,6 @@ func (self *LocalCommitsController) handleOpenLogMenu() error {
return self.c.WithWaitingStatus(self.c.Tr.LoadingCommits, func(gocui.Task) error {
self.c.Refresh(
types.RefreshOptions{
Mode: types.SYNC,
Scope: []types.RefreshableView{types.COMMITS},
},
)
@@ -1295,7 +1358,7 @@ func (self *LocalCommitsController) GetOnFocus() func(types.OnFocusOpts) {
context := self.context()
if context.GetSelectedLineIdx() > COMMIT_THRESHOLD && context.GetLimitCommits() {
context.SetLimitCommits(false)
self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.COMMITS}})
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.COMMITS}})
}
}
}
@@ -302,7 +302,7 @@ func (self *MergeConflictsController) resolveConflict(selection mergeconflicts.S
func (self *MergeConflictsController) onLastConflictResolved() {
// as part of refreshing files, we handle the situation where a file has had
// its merge conflicts resolved.
self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.FILES}})
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}})
}
func (self *MergeConflictsController) openMergeConflictMenu() error {
@@ -223,13 +223,19 @@ func (self *PatchBuildingController) discardSelectionFromCommit() error {
return nil
}
return self.c.WithWaitingStatusSync(self.c.Tr.RebasingStatus, func() error {
commitIndex := self.getPatchCommitIndex()
commits := self.c.Model().Commits
commitIndex := self.getPatchCommitIndex()
return self.c.WithWaitingStatus(self.c.Tr.RebasingStatus, func(gocui.Task) error {
self.c.LogAction(self.c.Tr.Actions.RemovePatchFromCommit)
err := self.c.Git().Patch.DeletePatchesFromCommit(self.c.Model().Commits, commitIndex)
self.c.Helpers().PatchBuilding.Escape()
err := self.c.Git().Patch.DeletePatchesFromCommit(commits, commitIndex)
// Escape pops the patch-building context, so run it on the UI thread
// before the refresh below.
_ = self.c.GocuiGui().OnUIThreadAndWait(func() error {
self.c.Helpers().PatchBuilding.Escape()
return nil
})
return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions(
err, types.RefreshOptions{Mode: types.SYNC})
err, types.RefreshOptions{})
})
}
+2 -4
View File
@@ -2,7 +2,6 @@ package controllers
import (
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/context"
"github.com/jesseduffield/lazygit/pkg/gui/types"
)
@@ -81,9 +80,8 @@ func (self *QuitActions) Escape() error {
}
}
repoPathStack := self.c.State().GetRepoPathStack()
if !repoPathStack.IsEmpty() {
return self.c.Helpers().Repos.DispatchSwitchToRepo(repoPathStack.Pop(), context.NO_CONTEXT)
if !self.c.State().GetRepoPathStack().IsEmpty() {
return self.c.Helpers().Repos.SwitchToParentRepo()
}
if self.c.UserConfig().QuitOnTopLevelReturn {
@@ -158,7 +158,7 @@ func (self *RemoteBranchesController) createSortMenu() error {
if self.c.UserConfig().Git.RemoteBranchSortOrder != sortOrder {
self.c.UserConfig().Git.RemoteBranchSortOrder = sortOrder
self.c.Contexts().RemoteBranches.SetSelection(0)
self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.REMOTES}})
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.REMOTES}})
}
return nil
},
+32 -21
View File
@@ -156,24 +156,27 @@ func (self *RemotesController) addAndCheckoutRemote(remoteName string, remoteUrl
return err
}
// Do a sync refresh of the remotes so that we can select
// the new one. Loading remotes is not expensive, so we can
// afford it.
// Refresh the remotes so that we can select the new one. The remotes model
// update is bounced onto the UI thread, so the selection (which reads
// Model.Remotes) has to run in Then; reading it inline here would see the
// previous model. Loading remotes is not expensive, so a sync refresh is
// affordable.
self.c.Refresh(types.RefreshOptions{
Scope: []types.RefreshableView{types.REMOTES},
Mode: types.SYNC,
Then: func() error {
// Select the remote
for idx, remote := range self.c.Model().Remotes {
if remote.Name == remoteName {
self.c.Contexts().Remotes.SetSelection(idx)
break
}
}
// Fetch the remote
return self.fetchAndCheckout(self.c.Contexts().Remotes.GetSelected(), branchToCheckout)
},
})
// Select the remote
for idx, remote := range self.c.Model().Remotes {
if remote.Name == remoteName {
self.c.Contexts().Remotes.SetSelection(idx)
break
}
}
// Fetch the remote
return self.fetchAndCheckout(self.c.Contexts().Remotes.GetSelected(), branchToCheckout)
return nil
}
// Ensures the fork remote exists (matching the given URL).
@@ -367,18 +370,26 @@ func (self *RemotesController) fetchAndCheckout(remote *models.Remote, branchNam
}
refreshOptions := types.RefreshOptions{
Scope: []types.RefreshableView{types.BRANCHES, types.REMOTES},
Mode: types.ASYNC,
}
if branchName != "" {
err = self.c.Git().Branch.New(branchName, remote.Name+"/"+branchName)
if err == nil {
self.c.Context().Push(self.c.Contexts().Branches, types.OnFocusOpts{})
self.c.Helpers().Refs.SelectFirstBranchAndFirstCommit()
refreshOptions.KeepBranchSelectionIndex = true
refreshOptions.CommitSelection = types.KeepCommitSelectionIndex
// Branch.New checks the new branch out, so HEAD moves: refresh the
// reflog (and, via scope expansion, the commits) as well, and select
// the newly checked-out branch and its head commit.
refreshOptions.Scope = append(refreshOptions.Scope, types.REFLOG)
refreshOptions.BranchSelection = types.SelectCheckedOutBranch
refreshOptions.CommitSelection = types.SelectHeadCommit
refreshOptions.SelectTopReflogCommit = true
// Focus the branches panel on the UI thread once the refresh has
// selected the newly checked-out branch.
refreshOptions.Then = func() error {
self.c.Context().Push(self.c.Contexts().Branches, types.OnFocusOpts{})
return nil
}
}
}
self.c.Refresh(refreshOptions)
self.c.RefreshFromWorker(refreshOptions)
return err
})
}
+8 -3
View File
@@ -170,11 +170,16 @@ func (self *StashController) handleStashDrop(stashEntries []*models.StashEntry)
Prompt: self.c.Tr.SureDropStashEntry,
HandleConfirm: func() error {
self.c.LogAction(self.c.Tr.Actions.DropStash)
// Refresh once at the end rather than after each drop: an async
// refresh from the UI thread finishes in the background, so firing
// one per iteration lets the workers race and an earlier, stale
// result can land last. The indices are captured up front and we
// drop highest-first, so the remaining lower indices stay valid
// without an intervening refresh.
defer self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.STASH}})
for i := len(stashEntries) - 1; i >= 0; i-- {
self.c.LogCommand(fmt.Sprintf(self.c.Tr.Log.DroppingStash, stashEntries[i].Hash), false)
err := self.c.Git().Stash.Drop(stashEntries[i].Index)
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.STASH}})
if err != nil {
if err := self.c.Git().Stash.Drop(stashEntries[i].Index); err != nil {
return err
}
}
@@ -66,7 +66,7 @@ func (self *SubCommitsController) GetOnFocus() func(types.OnFocusOpts) {
context := self.context()
if context.GetSelectedLineIdx() > COMMIT_THRESHOLD && context.GetLimitCommits() {
context.SetLimitCommits(false)
self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.SUB_COMMITS}})
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.SUB_COMMITS}})
}
}
}
+8 -8
View File
@@ -164,7 +164,7 @@ func (self *SubmodulesController) add() error {
return err
}
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.SUBMODULES}})
self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.SUBMODULES}})
return nil
})
},
@@ -193,7 +193,7 @@ func (self *SubmodulesController) editURL(submodule *models.SubmoduleConfig) err
return err
}
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.SUBMODULES}})
self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.SUBMODULES}})
return nil
})
},
@@ -210,7 +210,7 @@ func (self *SubmodulesController) init(submodule *models.SubmoduleConfig) error
return err
}
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.SUBMODULES}})
self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.SUBMODULES}})
return nil
})
}
@@ -229,7 +229,7 @@ func (self *SubmodulesController) openBulkActionsMenu() error {
return err
}
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.SUBMODULES}})
self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.SUBMODULES}})
return nil
})
},
@@ -244,7 +244,7 @@ func (self *SubmodulesController) openBulkActionsMenu() error {
return err
}
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.SUBMODULES}})
self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.SUBMODULES}})
return nil
})
},
@@ -259,7 +259,7 @@ func (self *SubmodulesController) openBulkActionsMenu() error {
return err
}
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.SUBMODULES}})
self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.SUBMODULES}})
return nil
})
},
@@ -274,7 +274,7 @@ func (self *SubmodulesController) openBulkActionsMenu() error {
return err
}
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.SUBMODULES}})
self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.SUBMODULES}})
return nil
})
},
@@ -292,7 +292,7 @@ func (self *SubmodulesController) update(submodule *models.SubmoduleConfig) erro
return err
}
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.SUBMODULES}})
self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.SUBMODULES}})
return nil
})
}
@@ -90,18 +90,19 @@ func (self *SwitchToDiffFilesController) enter() error {
self.c.Refresh(types.RefreshOptions{
Scope: []types.RefreshableView{types.COMMIT_FILES},
Then: func() error {
if filterPath := self.c.Modes().Filtering.GetPath(); filterPath != "" {
path, err := filepath.Rel(self.c.Git().RepoPaths.RepoPath(), filterPath)
if err != nil {
path = filterPath
}
commitFilesContext.CommitFileTreeViewModel.SelectPath(
filepath.ToSlash(path), self.c.UserConfig().Gui.ShowRootItemInFileTree)
}
self.c.Context().Push(commitFilesContext, types.OnFocusOpts{})
return nil
},
})
if filterPath := self.c.Modes().Filtering.GetPath(); filterPath != "" {
path, err := filepath.Rel(self.c.Git().RepoPaths.RepoPath(), filterPath)
if err != nil {
path = filterPath
}
commitFilesContext.CommitFileTreeViewModel.SelectPath(
filepath.ToSlash(path), self.c.UserConfig().Gui.ShowRootItemInFileTree)
}
self.c.Context().Push(commitFilesContext, types.OnFocusOpts{})
return nil
}
+1 -1
View File
@@ -229,7 +229,7 @@ func (self *SyncController) pushAux(currentBranch *models.Branch, opts pushOpts)
}
return err
}
self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC})
self.c.RefreshFromWorker(types.RefreshOptions{})
return nil
})
}
+4 -12
View File
@@ -168,7 +168,7 @@ func (self *TagsController) localDelete(tag *models.Tag) error {
return self.c.WithWaitingStatus(self.c.Tr.DeletingStatus, func(gocui.Task) error {
self.c.LogAction(self.c.Tr.Actions.DeleteLocalTag)
err := self.c.Git().Tag.LocalDelete(tag.Name)
self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.COMMITS, types.TAGS}})
self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.COMMITS, types.TAGS}})
return err
})
}
@@ -210,7 +210,7 @@ func (self *TagsController) remoteDelete(tag *models.Tag) error {
return err
}
self.c.Toast(self.c.Tr.RemoteTagDeletedMessage)
self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.COMMITS, types.TAGS}})
self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.COMMITS, types.TAGS}})
return nil
})
},
@@ -264,7 +264,7 @@ func (self *TagsController) localAndRemoteDelete(tag *models.Tag) error {
if err := self.c.Git().Tag.LocalDelete(tag.Name); err != nil {
return err
}
self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.COMMITS, types.TAGS}})
self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.COMMITS, types.TAGS}})
return nil
})
},
@@ -332,15 +332,7 @@ func (self *TagsController) push(tag *models.Tag) error {
HandleConfirm: func(response string) error {
return self.c.WithInlineStatus(tag, types.ItemOperationPushing, context.TAGS_CONTEXT_KEY, func(task gocui.Task) error {
self.c.LogAction(self.c.Tr.Actions.PushTag)
err := self.c.Git().Tag.Push(task, response, tag.Name)
// Render again to remove the inline status:
self.c.OnUIThread(func() error {
self.c.Contexts().Tags.HandleRender()
return nil
})
return err
return self.c.Git().Tag.Push(task, response, tag.Name)
})
},
})
+1 -1
View File
@@ -271,7 +271,7 @@ func (self *UndoController) hardResetWithAutoStash(commitHash string, options ha
if err != nil {
return err
}
self.c.Refresh(types.RefreshOptions{})
self.c.RefreshFromWorker(types.RefreshOptions{})
return nil
})
}
@@ -66,12 +66,8 @@ func (self *VerticalScrollController) HandleScrollUp() error {
}
func (self *VerticalScrollController) HandleScrollDown() error {
scrollHeight := self.c.UserConfig().Gui.ScrollHeight
self.context.GetViewTrait().ScrollDown(scrollHeight)
if manager := self.c.GetViewBufferManagerForView(self.context.GetView()); manager != nil {
manager.ReadLines(scrollHeight)
}
self.context.GetViewTrait().ScrollDown(self.c.UserConfig().Gui.ScrollHeight)
self.c.ReadLinesToFillView(self.context.GetView())
return nil
}
@@ -50,17 +50,12 @@ func (self *ViewSelectionController) GetMouseKeybindings(opts types.KeybindingsO
}
func (self *ViewSelectionController) handleLineChange(delta int) {
if delta > 0 {
if manager := self.c.GetViewBufferManagerForView(self.context.GetView()); manager != nil {
manager.ReadLines(delta)
}
}
v := self.Context().GetView()
if delta < 0 {
v.ScrollUp(-delta)
} else {
v.ScrollDown(delta)
self.c.ReadLinesToFillView(v)
}
}
@@ -46,7 +46,7 @@ func (self *FilesController) createResetMenu() error {
}
self.c.Refresh(
types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.FILES}},
types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}},
)
return nil
},
@@ -68,7 +68,7 @@ func (self *FilesController) createResetMenu() error {
}
self.c.Refresh(
types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.FILES}},
types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}},
)
return nil
},
@@ -86,7 +86,7 @@ func (self *FilesController) createResetMenu() error {
}
self.c.Refresh(
types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.FILES}},
types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}},
)
return nil
},
@@ -111,7 +111,7 @@ func (self *FilesController) createResetMenu() error {
}
self.c.Refresh(
types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.FILES}},
types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}},
)
return nil
},
@@ -129,7 +129,7 @@ func (self *FilesController) createResetMenu() error {
}
self.c.Refresh(
types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.FILES}},
types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}},
)
return nil
},
@@ -147,7 +147,7 @@ func (self *FilesController) createResetMenu() error {
}
self.c.Refresh(
types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.FILES}},
types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}},
)
return nil
},
@@ -170,7 +170,7 @@ func (self *FilesController) createResetMenu() error {
}
self.c.Refresh(
types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.FILES}},
types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}},
)
return nil
},
+5 -2
View File
@@ -35,10 +35,13 @@ func (gui *Gui) promptEditor(v *gocui.View, key gocui.Key) bool {
v.RenderTextArea()
suggestionsContext := gui.State.Contexts.Suggestions
if suggestionsContext.State.FindSuggestions != nil {
// Capture the suggestions function and the input here, on the UI thread; the
// main thread rewrites State.FindSuggestions when it (re)creates a prompt
// panel, so reading it from the worker below would race that write.
if findSuggestions := suggestionsContext.State.FindSuggestions; findSuggestions != nil {
input := v.TextArea.GetContent()
suggestionsContext.State.AsyncHandler.Do(func() func() {
suggestions := suggestionsContext.State.FindSuggestions(input)
suggestions := findSuggestions(input)
return func() { suggestionsContext.SetSuggestions(suggestions) }
})
}
@@ -2,7 +2,6 @@ package filetree
import (
"strings"
"sync"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/common"
@@ -26,7 +25,6 @@ type ICommitFileTreeViewModel interface {
}
type CommitFileTreeViewModel struct {
sync.RWMutex
types.IListCursor
ICommitFileTree
@@ -144,6 +142,22 @@ func (self *CommitFileTreeViewModel) GetSelectedPath() string {
return node.GetPath()
}
// SetTree rebuilds the tree and clamps the selection so it stays in range. The
// embedded tree's SetTree only rebuilds the node list and doesn't touch the
// cursor, so after a shrinking rebuild (e.g. moving a patch out into the index)
// the selection index could be left past the end of the tree; GetSelectedItems
// would then return a nil node and crash callers such as canEditFiles when the
// options map is rendered during layout.
//
// Unlike FileTreeViewModel.SetTree we don't re-find the selected node by path
// afterwards: that walk lands on the containing directory when a file is removed
// from a dir that then collapses, whereas keeping the (clamped) index lands on
// the sibling file, which is what we want here.
func (self *CommitFileTreeViewModel) SetTree() {
self.ICommitFileTree.SetTree()
self.ClampSelection()
}
// duplicated from file_tree_view_model.go. Generics will help here
func (self *CommitFileTreeViewModel) ToggleShowTree() {
selectedNode := self.GetSelected()
@@ -0,0 +1,40 @@
package filetree
import (
"testing"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/common"
"github.com/stretchr/testify/assert"
)
// When the tree shrinks under the selection - e.g. moving a patch out into the
// index removes a file - SetTree must keep the selection in range. Otherwise
// GetSelectedItems returns a nil node, which crashes callers such as
// canEditFiles when the options map is rendered during layout.
func TestCommitFileTreeViewModelSetTreeClampsSelectionOnShrink(t *testing.T) {
files := []*models.CommitFile{
{Path: "file1"},
{Path: "file2"},
{Path: "file3"},
}
viewModel := NewCommitFileTreeViewModel(
func() []*models.CommitFile { return files },
common.NewDummyCommon(),
false, // flat list
)
viewModel.SetTree()
viewModel.SetSelectedLineIdx(viewModel.Len() - 1)
// The file under the cursor goes away and the tree shrinks.
files = []*models.CommitFile{{Path: "file1"}}
viewModel.SetTree()
assert.Less(t, viewModel.GetSelectedLineIdx(), viewModel.Len())
assert.NotNil(t, viewModel.GetSelected())
items, _, _ := viewModel.GetSelectedItems()
assert.NotEmpty(t, items)
for _, item := range items {
assert.NotNil(t, item)
}
}
-2
View File
@@ -2,7 +2,6 @@ package filetree
import (
"strings"
"sync"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/common"
@@ -22,7 +21,6 @@ type IFileTreeViewModel interface {
// which item is selected. It also contains logic for repositioning that cursor
// after the files are refreshed
type FileTreeViewModel struct {
sync.RWMutex
types.IListCursor
IFileTree
searchHistory *utils.HistoryBuffer[string]
+2 -6
View File
@@ -17,12 +17,8 @@ func (gui *Gui) scrollUpView(view *gocui.View) {
}
func (gui *Gui) scrollDownView(view *gocui.View) {
scrollHeight := gui.c.UserConfig().Gui.ScrollHeight
view.ScrollDown(scrollHeight)
if manager := gui.getViewBufferManagerForView(view); manager != nil {
manager.ReadLines(scrollHeight)
}
view.ScrollDown(gui.c.UserConfig().Gui.ScrollHeight)
gui.readLinesToFillView(view)
}
func (gui *Gui) scrollUpMain() error {
+85 -28
View File
@@ -12,6 +12,7 @@ import (
"sort"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/jesseduffield/lazycore/pkg/boxlayout"
@@ -48,7 +49,6 @@ import (
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/samber/lo"
"github.com/sasha-s/go-deadlock"
"gopkg.in/ozeidan/fuzzy-patricia.v3/patricia"
)
const StartupPopupVersion = 5
@@ -111,7 +111,10 @@ type Gui struct {
PopupHandler types.IPopupHandler
IsRefreshingFiles bool
// Bumped every time we switch to a different repository (in resetState).
// Used to drop refresh results that were computed for a repo we've since
// navigated away from. See RefreshHelper.onUIThreadUnlessRepoChanged.
repoGeneration atomic.Int32
// we use this to decide whether we'll return to the original directory that
// lazygit was opened in, or if we'll retain the one we're currently in.
@@ -171,18 +174,14 @@ func (self *StateAccessor) GetRepoState() types.IRepoStateAccessor {
return self.gui.State
}
func (self *StateAccessor) GetRepoGeneration() int {
return int(self.gui.repoGeneration.Load())
}
func (self *StateAccessor) GetPagerConfig() *config.PagerConfig {
return self.gui.pagerConfig
}
func (self *StateAccessor) GetIsRefreshingFiles() bool {
return self.gui.IsRefreshingFiles
}
func (self *StateAccessor) SetIsRefreshingFiles(value bool) {
self.gui.IsRefreshingFiles = value
}
func (self *StateAccessor) GetShowExtrasWindow() bool {
return self.gui.ShowExtrasWindow
}
@@ -234,8 +233,11 @@ type GuiRepoState struct {
SplitMainPanel bool
SearchState *types.SearchState
StartupStage types.StartupStage // Allows us to not load everything at once
SearchState *types.SearchState
// Lets us not load everything at once. Written and read from refresh
// workers (the reflog/branches load transitions it INITIAL->COMPLETE), so
// it's atomic. Holds a types.StartupStage.
startupStage atomic.Int32
ContextMgr *ContextMgr
Contexts *context.ContextTree
@@ -262,7 +264,11 @@ type GuiRepoState struct {
// continue such an operation once its conflicts are resolved if we started
// it ourselves; for an externally started one, popping up unbidden would be
// confusing. Reset whenever we observe that no operation is in progress.
mergeOrRebaseStartedInLazygit bool
//
// Written from both the files refresh worker and the merge/rebase result
// path (which runs on a worker for the async callers), and read from the
// files refresh worker, so it's atomic.
mergeOrRebaseStartedInLazygit atomic.Bool
}
var _ types.IRepoStateAccessor = new(GuiRepoState)
@@ -276,11 +282,11 @@ func (self *GuiRepoState) GetWindowViewNameMap() *utils.ThreadSafeMap[string, st
}
func (self *GuiRepoState) GetStartupStage() types.StartupStage {
return self.StartupStage
return types.StartupStage(self.startupStage.Load())
}
func (self *GuiRepoState) SetStartupStage(value types.StartupStage) {
self.StartupStage = value
self.startupStage.Store(int32(value))
}
func (self *GuiRepoState) GetCurrentPopupOpts() *types.CreatePopupPanelOpts {
@@ -292,11 +298,11 @@ func (self *GuiRepoState) SetCurrentPopupOpts(value *types.CreatePopupPanelOpts)
}
func (self *GuiRepoState) GetMergeOrRebaseStartedInLazygit() bool {
return self.mergeOrRebaseStartedInLazygit
return self.mergeOrRebaseStartedInLazygit.Load()
}
func (self *GuiRepoState) SetMergeOrRebaseStartedInLazygit(value bool) {
self.mergeOrRebaseStartedInLazygit = value
self.mergeOrRebaseStartedInLazygit.Store(value)
}
func (self *GuiRepoState) GetScreenMode() types.ScreenMode {
@@ -384,7 +390,7 @@ func (gui *Gui) onNewRepo(startArgs appTypes.StartArgs, contextKey types.Context
}
gui.c.Log.Info("Receiving focus - refreshing")
gui.helpers.Refresh.Refresh(types.RefreshOptions{Mode: types.ASYNC})
gui.helpers.Refresh.Refresh(types.RefreshOptions{})
return reloadErr
}
@@ -414,6 +420,10 @@ func (gui *Gui) onNewRepo(startArgs appTypes.StartArgs, contextKey types.Context
return nil
})
gui.g.SetUpdateQueueHighWaterMarkHandler(func(depth int) {
gui.c.Log.Infof("User-event queue reached a new high-water mark: %d", depth)
})
gui.g.SetOnSelectSearchResultFunc(func(v *gocui.View, selectedLineIdx int) {
ctx, ok := gui.helpers.View.ContextForView(v.Name())
if ok {
@@ -585,6 +595,11 @@ func (gui *Gui) checkForChangedConfigsThatDontAutoReload(oldConfig *config.UserC
// resetState reuses the repo state from our repo state map, if the repo was
// open before; otherwise it creates a new one.
func (gui *Gui) resetState(startArgs appTypes.StartArgs) types.Context {
// Bump the repo generation so that any refresh still in flight for the
// previous repo drops its model update instead of applying it here (see
// RefreshHelper.onUIThreadUnlessRepoChanged).
gui.repoGeneration.Add(1)
// Un-highlight the current view if there is one. The reason we do this is
// that the repo we are switching to might have a different view focused,
// and would then show an inactive highlight for the previous view.
@@ -604,9 +619,7 @@ func (gui *Gui) resetState(startArgs appTypes.StartArgs) types.Context {
// setting this to nil so we don't get stuck based on a popup that was
// previously opened
gui.Mutexes.PopupMutex.Lock()
gui.State.CurrentPopupOpts = nil
gui.Mutexes.PopupMutex.Unlock()
return gui.c.Context().Current()
}
@@ -625,7 +638,6 @@ func (gui *Gui) resetState(startArgs appTypes.StartArgs) types.Context {
FilteredReflogCommits: make([]*models.Commit, 0),
ReflogCommits: make([]*models.Commit, 0),
BisectInfo: git_commands.NewNullBisectInfo(),
FilesTrie: patricia.NewTrie(),
Authors: map[string]*models.Author{},
MainBranches: git_commands.NewMainBranches(gui.c.Common, gui.os.Cmd),
HashPool: &utils.StringPool{},
@@ -679,6 +691,23 @@ func (gui *Gui) getViewBufferManagerForView(view *gocui.View) *tasks.ViewBufferM
return manager
}
// When scrolling a lazy-loaded view, we read enough lines to fill the viewport
// plus this many extra screenfuls, so that further scrolling has some runway
// and doesn't have to block on reading (and re-rendering) more lines on every
// wheel notch.
const scrollReadAheadScreenfuls = 3
// readLinesToFillView reads enough lines into the view's buffer to cover
// everything currently scrolled into view, plus a few screenfuls of read-ahead.
// Reading is idempotent (see ViewBufferManager.ReadLines), so if the buffer
// already extends far enough this does nothing.
func (gui *Gui) readLinesToFillView(view *gocui.View) {
if manager := gui.getViewBufferManagerForView(view); manager != nil {
viewportBottom := view.OriginY() + view.InnerHeight()
manager.ReadLines(viewportBottom + scrollReadAheadScreenfuls*view.InnerHeight())
}
}
func (gui *Gui) initialWindowViewNameMap(contextTree *context.ContextTree) *utils.ThreadSafeMap[string, string] {
result := utils.NewThreadSafeMap[string, string]()
@@ -779,16 +808,28 @@ func NewGui(
gui.PopupHandler = popup.NewPopupHandler(
cmn,
// Raising a popup or menu pushes a context and mutates the popup views,
// and it can be triggered from a worker goroutine (e.g. a
// WithWaitingStatus handler that hits a merge conflict and asks the user
// how to proceed). Bounce the creation onto the UI thread so it can't
// race the layout/draw code. Doing it here, at the one point where these
// producers are injected, keeps every caller oblivious to the threading.
func(ctx goContext.Context, opts types.CreatePopupPanelOpts) {
gui.helpers.Confirmation.CreatePopupPanel(ctx, opts)
gui.onUIThread(func() error {
gui.helpers.Confirmation.CreatePopupPanel(ctx, opts)
return nil
})
},
func() error { gui.c.Refresh(types.RefreshOptions{Mode: types.ASYNC}); return nil },
func() error { gui.c.Refresh(types.RefreshOptions{}); return nil },
func() { gui.State.ContextMgr.Pop() },
func() types.Context { return gui.State.ContextMgr.Current() },
gui.createMenu,
func(opts types.CreateMenuOptions) error {
gui.onUIThread(func() error { return gui.createMenu(opts) })
return nil
},
func(message string, f func(gocui.Task) error) { gui.helpers.AppStatus.WithWaitingStatus(message, f) },
func(message string, f func() error) error {
return gui.helpers.AppStatus.WithWaitingStatusSync(message, f)
func(message string, f func(gocui.Task) error) {
gui.helpers.AppStatus.WithWaitingStatusBlockingInput(message, f)
},
func(message string, kind types.ToastKind) { gui.helpers.AppStatus.Toast(message, kind) },
func() string { return gui.Views.Prompt.TextArea.GetContent() },
@@ -990,7 +1031,7 @@ func (gui *Gui) runSubprocessWithSuspenseAndRefresh(subprocess *oscommands.CmdOb
return err
}
gui.c.Refresh(types.RefreshOptions{Mode: types.ASYNC})
gui.c.Refresh(types.RefreshOptions{})
return nil
}
@@ -1067,7 +1108,7 @@ func (gui *Gui) loadNewRepo() error {
return err
}
gui.c.Refresh(types.RefreshOptions{Mode: types.ASYNC})
gui.c.Refresh(types.RefreshOptions{})
if err := gui.os.UpdateWindowTitle(); err != nil {
return err
@@ -1189,16 +1230,32 @@ func (gui *Gui) onUIThread(f func() error) {
})
}
func (gui *Gui) onUIThreadBackground(f func() error) {
gui.g.UpdateBackground(func(*gocui.Gui) error {
return f()
})
}
func (gui *Gui) onUIThreadContentOnly(f func() error) {
gui.g.UpdateContentOnly(func(*gocui.Gui) error {
return f()
})
}
func (gui *Gui) onUIThreadContentOnlyBackground(f func() error) {
gui.g.UpdateContentOnlyBackground(func(*gocui.Gui) error {
return f()
})
}
func (gui *Gui) onWorker(f func(gocui.Task) error) {
gui.g.OnWorker(f)
}
func (gui *Gui) onWorkerBackground(f func(gocui.Task) error) {
gui.g.OnWorkerBackground(f)
}
func (gui *Gui) getWindowDimensions(informationStr string, appStatus string) map[string]boxlayout.Dimensions {
return gui.helpers.WindowArrangement.GetWindowDimensions(informationStr, appStatus)
}
+32
View File
@@ -30,6 +30,10 @@ func (self *guiCommon) Refresh(opts types.RefreshOptions) {
self.gui.helpers.Refresh.Refresh(opts)
}
func (self *guiCommon) RefreshFromWorker(opts types.RefreshOptions) {
self.gui.helpers.Refresh.RefreshFromWorker(opts)
}
func (self *guiCommon) PostRefreshUpdate(context types.Context) {
self.gui.postRefreshUpdate(context)
}
@@ -54,7 +58,18 @@ func (self *guiCommon) PauseBackgroundRefreshes(pause bool) {
self.gui.BackgroundRoutineMgr.PauseBackgroundRefreshes(pause)
}
// assertOnUIThread panics (in debug builds) if called from a worker goroutine.
// Use it to guard accessors for state that only the UI thread may touch, so
// that a stray worker access fails deterministically -- and points at itself --
// rather than surfacing later as a probabilistic data race.
func (self *guiCommon) assertOnUIThread(accessor string) {
if self.GetConfig().GetDebug() && !self.GocuiGui().IsUIThread() {
panic(accessor + " accessed from a worker")
}
}
func (self *guiCommon) Context() types.IContextMgr {
self.assertOnUIThread("Context()")
return self.gui.State.ContextMgr
}
@@ -109,6 +124,7 @@ func (self *guiCommon) Modes() *types.Modes {
}
func (self *guiCommon) Model() *types.Model {
self.assertOnUIThread("Model()")
return self.gui.State.Model
}
@@ -124,14 +140,26 @@ func (self *guiCommon) OnUIThread(f func() error) {
self.gui.onUIThread(f)
}
func (self *guiCommon) OnUIThreadBackground(f func() error) {
self.gui.onUIThreadBackground(f)
}
func (self *guiCommon) OnUIThreadContentOnly(f func() error) {
self.gui.onUIThreadContentOnly(f)
}
func (self *guiCommon) OnUIThreadContentOnlyBackground(f func() error) {
self.gui.onUIThreadContentOnlyBackground(f)
}
func (self *guiCommon) OnWorker(f func(gocui.Task) error) {
self.gui.onWorker(f)
}
func (self *guiCommon) OnWorkerBackground(f func(gocui.Task) error) {
self.gui.onWorkerBackground(f)
}
func (self *guiCommon) RenderToMainViews(opts types.RefreshMainOpts) {
self.gui.refreshMainViews(opts)
}
@@ -149,6 +177,10 @@ func (self *guiCommon) GetViewBufferManagerForView(view *gocui.View) *tasks.View
return self.gui.getViewBufferManagerForView(view)
}
func (self *guiCommon) ReadLinesToFillView(view *gocui.View) {
self.gui.readLinesToFillView(view)
}
func (self *guiCommon) State() types.IStateAccessor {
return self.gui.stateAccessor
}
+16 -14
View File
@@ -17,10 +17,9 @@ import (
// this gives our integration test a way of interacting with the gui for sending keypresses
// and reading state.
type GuiDriver struct {
gui *Gui
isIdleChan chan struct{}
toastChan chan string
headless bool
gui *Gui
toastChan chan string
headless bool
}
var _ integrationTypes.GuiDriver = &GuiDriver{}
@@ -33,10 +32,10 @@ func (self *GuiDriver) PressKey(keyStr string) {
self.Fail("Unrecognized key: " + keyStr)
}
self.gui.g.ReplayedEvents.Keys <- gocui.NewTcellKeyEventWrapper(
self.gui.g.ReplayKeyEvent(gocui.NewTcellKeyEventWrapper(
tcell.NewEventKey(tcell.Key(key.KeyName()), key.Str(), tcell.ModMask(key.Mod())),
0,
)
))
self.waitTillIdle()
}
@@ -44,15 +43,15 @@ func (self *GuiDriver) PressKey(keyStr string) {
func (self *GuiDriver) Click(x, y int) {
self.CheckAllToastsAcknowledged()
self.gui.g.ReplayedEvents.MouseEvents <- gocui.NewTcellMouseEventWrapper(
self.gui.g.ReplayMouseEvent(gocui.NewTcellMouseEventWrapper(
tcell.NewEventMouse(x, y, tcell.ButtonPrimary, 0),
0,
)
))
self.waitTillIdle()
self.gui.g.ReplayedEvents.MouseEvents <- gocui.NewTcellMouseEventWrapper(
self.gui.g.ReplayMouseEvent(gocui.NewTcellMouseEventWrapper(
tcell.NewEventMouse(x, y, tcell.ButtonNone, 0),
0,
)
))
self.waitTillIdle()
}
@@ -60,10 +59,10 @@ func (self *GuiDriver) Click(x, y int) {
// learns to reload changed config files. Tests use it to exercise the live
// config-reload path.
func (self *GuiDriver) FocusIn() {
self.gui.g.ReplayedEvents.FocusEvents <- gocui.NewTcellFocusEventWrapper(
self.gui.g.ReplayFocusEvent(gocui.NewTcellFocusEventWrapper(
tcell.NewEventFocus(true),
0,
)
))
self.waitTillIdle()
}
@@ -79,7 +78,7 @@ func (self *GuiDriver) PretendMergeOrRebaseStartedInLazygit() {
// wait until lazygit is idle (i.e. all processing is done) before continuing
func (self *GuiDriver) waitTillIdle() {
<-self.isIdleChan
self.gui.g.WaitUntilIdle()
}
func (self *GuiDriver) CheckAllToastsAcknowledged() {
@@ -93,7 +92,10 @@ func (self *GuiDriver) Keys() config.KeybindingConfig {
}
func (self *GuiDriver) CurrentContext() types.Context {
return self.gui.c.Context().Current()
// Read the context manager directly rather than through c.Context(): the
// driver runs on the test goroutine, not the UI thread, so it must bypass
// the UI-thread assertion that accessor carries.
return self.gui.State.ContextMgr.Current()
}
func (self *GuiDriver) ContextForView(viewName string) types.Context {
+17 -5
View File
@@ -37,13 +37,18 @@ func (gui *Gui) layout(g *gocui.Gui) error {
if prevMainView != nil {
prevMainHeight := prevMainView.Height()
newMainHeight := viewDimensions["main"].Y1 - viewDimensions["main"].Y0 + 1
heightDiff := newMainHeight - prevMainHeight
if heightDiff > 0 {
if newMainHeight > prevMainHeight {
// The main views have grown taller, so make sure enough lines are
// loaded to fill them. The views haven't been resized yet at this
// point, so we can't rely on their current height; compute the target
// total from the new height instead. (Reading past the actual content
// is harmless: ReadLines stops at the end of input.)
linesToRead := prevMainView.OriginY() + newMainHeight
if manager := gui.getViewBufferManagerForView(gui.Views.Main); manager != nil {
manager.ReadLines(heightDiff)
manager.ReadLines(linesToRead)
}
if manager := gui.getViewBufferManagerForView(gui.Views.Secondary); manager != nil {
manager.ReadLines(heightDiff)
manager.ReadLines(linesToRead)
}
}
}
@@ -149,7 +154,14 @@ func (gui *Gui) layout(g *gocui.Gui) error {
if err != nil && !errors.Is(err, gocui.ErrUnknownView) {
return err
}
view.Visible = gui.helpers.Window.GetViewNameForWindow(context.GetWindowName()) == context.GetViewName()
// A transient view is visible if it is the view its window is currently
// showing — but only if that window is part of the layout at all. For a
// window without dimensions, setViewFromDimensions parks the view at full
// screen size in the background, so making it visible would cover all
// windows below it.
_, windowHasDimensions := viewDimensions[context.GetWindowName()]
view.Visible = windowHasDimensions &&
gui.helpers.Window.GetViewNameForWindow(context.GetWindowName()) == context.GetViewName()
}
if gui.PrevLayout.Information != informationStr {
+25 -24
View File
@@ -13,16 +13,16 @@ import (
type PopupHandler struct {
*common.Common
createPopupPanelFn func(context.Context, types.CreatePopupPanelOpts)
onErrorFn func() error
popContextFn func()
currentContextFn func() types.Context
createMenuFn func(types.CreateMenuOptions) error
withWaitingStatusFn func(message string, f func(gocui.Task) error)
withWaitingStatusSyncFn func(message string, f func() error) error
toastFn func(message string, kind types.ToastKind)
getPromptInputFn func() string
inDemo func() bool
createPopupPanelFn func(context.Context, types.CreatePopupPanelOpts)
onErrorFn func() error
popContextFn func()
currentContextFn func() types.Context
createMenuFn func(types.CreateMenuOptions) error
withWaitingStatusFn func(message string, f func(gocui.Task) error)
withWaitingStatusBlockingInputFn func(message string, f func(gocui.Task) error)
toastFn func(message string, kind types.ToastKind)
getPromptInputFn func() string
inDemo func() bool
}
var _ types.IPopupHandler = &PopupHandler{}
@@ -35,23 +35,23 @@ func NewPopupHandler(
currentContextFn func() types.Context,
createMenuFn func(types.CreateMenuOptions) error,
withWaitingStatusFn func(message string, f func(gocui.Task) error),
withWaitingStatusSyncFn func(message string, f func() error) error,
withWaitingStatusBlockingInputFn func(message string, f func(gocui.Task) error),
toastFn func(message string, kind types.ToastKind),
getPromptInputFn func() string,
inDemo func() bool,
) *PopupHandler {
return &PopupHandler{
Common: common,
createPopupPanelFn: createPopupPanelFn,
onErrorFn: onErrorFn,
popContextFn: popContextFn,
currentContextFn: currentContextFn,
createMenuFn: createMenuFn,
withWaitingStatusFn: withWaitingStatusFn,
withWaitingStatusSyncFn: withWaitingStatusSyncFn,
toastFn: toastFn,
getPromptInputFn: getPromptInputFn,
inDemo: inDemo,
Common: common,
createPopupPanelFn: createPopupPanelFn,
onErrorFn: onErrorFn,
popContextFn: popContextFn,
currentContextFn: currentContextFn,
createMenuFn: createMenuFn,
withWaitingStatusFn: withWaitingStatusFn,
withWaitingStatusBlockingInputFn: withWaitingStatusBlockingInputFn,
toastFn: toastFn,
getPromptInputFn: getPromptInputFn,
inDemo: inDemo,
}
}
@@ -76,8 +76,9 @@ func (self *PopupHandler) WithWaitingStatus(message string, f func(gocui.Task) e
return nil
}
func (self *PopupHandler) WithWaitingStatusSync(message string, f func() error) error {
return self.withWaitingStatusSyncFn(message, f)
func (self *PopupHandler) WithWaitingStatusBlockingInput(message string, f func(gocui.Task) error) error {
self.withWaitingStatusBlockingInputFn(message, f)
return nil
}
func (self *PopupHandler) ErrorHandler(err error) error {
+10 -1
View File
@@ -93,9 +93,18 @@ func (gui *Gui) newPtyTask(view *gocui.View, cmd *exec.Cmd, prefix string) error
manager := gui.getManager(view)
// Size the pty from the view's dimensions here, on the UI thread; the
// start func below runs on the task's goroutine, which must not read the
// view's live dimensions while the UI thread is laying it out.
cols, rows := gui.desiredPtySize(view)
var p oscommands.Pty
start := func() (tasks.Cmd, io.Reader) {
cols, rows := gui.desiredPtySize(view)
// The pty (and pager) wrap to this width; apply it here, on the
// task's goroutine once the previous task has stopped, so it doesn't
// race that task's writes (see View.SetContentWidth).
view.SetContentWidth(width)
sp, err := oscommands.StartPty(cmd, cols, rows)
if err != nil {
gui.c.Log.Error(err)
@@ -314,7 +314,7 @@ func (self *HandlerCreator) finalHandler(customCommand config.CustomCommand, ses
}
output, err := cmdObj.RunWithOutput()
self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC})
self.c.RefreshFromWorker(types.RefreshOptions{})
if err != nil {
if customCommand.After != nil && customCommand.After.CheckForConflicts {
+9
View File
@@ -125,4 +125,13 @@ func (gui *Gui) assignSidePanelWindows(contextTree *context.ContextTree) {
ctx.SetWindowName(name)
}
}
// The transient contexts take over the window of the context they are
// drilled into from, but they need a valid initial window before their
// first use. Assign the window hosting branches or commits, respectively;
// unlike e.g. remotes, those tabs can't be hidden, so their windows are
// always part of the layout.
contextTree.RemoteBranches.SetWindowName(contextTree.Branches.GetWindowName())
contextTree.SubCommits.SetWindowName(contextTree.Branches.GetWindowName())
contextTree.CommitFiles.SetWindowName(contextTree.LocalCommits.GetWindowName())
}
+20
View File
@@ -28,3 +28,23 @@ func TestSidePanelLookupsCoverAllValidTabs(t *testing.T) {
assert.Equal(t, want, sortedKeys(gui.sidePanelTabTitles()))
assert.Equal(t, want, sortedKeys(sidePanelContexts(gui.contextTree())))
}
// The transient contexts must end up in windows that exist under the configured
// panel layout, or their views would be laid out for a window that is never
// shown.
func TestAssignSidePanelWindowsCoversTransientContexts(t *testing.T) {
gui := NewDummyGui()
gui.c.UserConfig().Gui.SidePanels = []config.SidePanel{
{"worktrees", "branches", "remotes"},
{"files"},
{"tags", "commits"},
{"stash"},
}
contextTree := gui.contextTree()
gui.assignSidePanelWindows(contextTree)
assert.Equal(t, "worktrees", contextTree.RemoteBranches.GetWindowName())
assert.Equal(t, "worktrees", contextTree.SubCommits.GetWindowName())
assert.Equal(t, "tags", contextTree.CommitFiles.GetWindowName())
}
+44
View File
@@ -17,6 +17,11 @@ type StatusManager struct {
statuses []appStatus
nextId int
mutex deadlock.Mutex
// Whether a render loop is currently drawing the statuses. Guarded by
// mutex, so that claiming and releasing the loop stay atomic with the
// changes to statuses; see ClaimRenderLoop and ReleaseRenderLoopIfEmpty.
renderLoopRunning bool
}
// Can be used to manipulate a waiting status while it is running (e.g. pause
@@ -70,6 +75,9 @@ func (self *StatusManager) AddToastStatus(message string, kind types.ToastKind)
}
func (self *StatusManager) GetStatusString(userConfig *config.UserConfig) (string, gocui.Attribute) {
self.mutex.Lock()
defer self.mutex.Unlock()
if len(self.statuses) == 0 {
return "", gocui.ColorDefault
}
@@ -81,9 +89,45 @@ func (self *StatusManager) GetStatusString(userConfig *config.UserConfig) (strin
}
func (self *StatusManager) HasStatus() bool {
self.mutex.Lock()
defer self.mutex.Unlock()
return len(self.statuses) > 0
}
// ClaimRenderLoop is called by whoever just added a status; it reports whether
// they must start the render loop. When it returns false, a loop is already
// running and will pick the new status up on its next tick.
func (self *StatusManager) ClaimRenderLoop() bool {
self.mutex.Lock()
defer self.mutex.Unlock()
if self.renderLoopRunning {
return false
}
self.renderLoopRunning = true
return true
}
// ReleaseRenderLoopIfEmpty is called by the render loop after each frame it
// draws; a true result releases the loop's claim and tells it to exit, because
// there are no statuses left to draw. The emptiness check and the release are
// atomic with respect to ClaimRenderLoop, so a status added around this moment
// either sees the still-running loop or starts a fresh one — it can't end up
// unrendered.
func (self *StatusManager) ReleaseRenderLoopIfEmpty() bool {
self.mutex.Lock()
defer self.mutex.Unlock()
if len(self.statuses) > 0 {
return false
}
self.renderLoopRunning = false
return true
}
func (self *StatusManager) addStatus(message string, statusType string, kind types.ToastKind) int {
self.mutex.Lock()
defer self.mutex.Unlock()
+40 -10
View File
@@ -18,8 +18,17 @@ func (gui *Gui) newCmdTask(view *gocui.View, cmd *exec.Cmd, prefix string) error
manager := gui.getManager(view)
// Snapshot the view width here, on the UI thread, so the task goroutine
// doesn't read the view's live dimensions while it streams output. It's
// applied inside start() below rather than now, because start() runs once
// the previous task has stopped -- applying it here would race that task's
// still-running writes (see View.SetContentWidth).
contentWidth := view.InnerWidth()
var r io.ReadCloser
start := func() (tasks.Cmd, io.Reader) {
view.SetContentWidth(contentWidth)
var err error
r, err = cmd.StdoutPipe()
if err != nil {
@@ -59,8 +68,10 @@ func (gui *Gui) newStringTaskWithoutScroll(view *gocui.View, str string) error {
manager := gui.getManager(view)
f := func(tasks.TaskOpts) error {
gui.c.SetViewContent(view, str)
return nil
return gui.g.OnUIThreadAndWaitBackground(func() error {
gui.c.SetViewContent(view, str)
return nil
})
}
if err := manager.NewTask(f, manager.GetTaskKey()); err != nil {
@@ -74,9 +85,11 @@ func (gui *Gui) newStringTaskWithScroll(view *gocui.View, str string, originX in
manager := gui.getManager(view)
f := func(tasks.TaskOpts) error {
gui.c.SetViewContent(view, str)
view.SetOrigin(originX, originY)
return nil
return gui.g.OnUIThreadAndWaitBackground(func() error {
gui.c.SetViewContent(view, str)
view.SetOrigin(originX, originY)
return nil
})
}
if err := manager.NewTask(f, manager.GetTaskKey()); err != nil {
@@ -90,9 +103,11 @@ func (gui *Gui) newStringTaskWithKey(view *gocui.View, str string, key string) e
manager := gui.getManager(view)
f := func(tasks.TaskOpts) error {
gui.c.ResetViewOrigin(view)
gui.c.SetViewContent(view, str)
return nil
return gui.g.OnUIThreadAndWaitBackground(func() error {
gui.c.ResetViewOrigin(view)
gui.c.SetViewContent(view, str)
return nil
})
}
if err := manager.NewTask(f, key); err != nil {
@@ -118,7 +133,12 @@ func (gui *Gui) getManager(view *gocui.View) *tasks.ViewBufferManager {
view.Reset()
},
func() {
gui.render()
// As the task reads more lines, the only thing that changes is the
// view's content (and its scrollbar); the window layout doesn't. So a
// content-only render is enough, and it's much cheaper than a full
// layout-and-redraw on every read - which matters a lot when reading
// a long diff, where reads happen repeatedly as the user scrolls.
gui.renderContentOnly()
},
func() {
// Need to check if the content of the view is well past the origin.
@@ -136,8 +156,18 @@ func (gui *Gui) getManager(view *gocui.View) *tasks.ViewBufferManager {
view.SetOrigin(0, 0)
},
func() gocui.Task {
return gui.c.GocuiGui().NewTask()
// A background task: rendering content into a view is display
// work, not lazygit driving a git operation, so it must not
// count towards being busy and block a repo switch. These
// renders fire on nearly every focus/selection change, including
// the context activation that happens right before a menu/prompt
// handler runs (e.g. confirming worktree creation), which would
// otherwise make the switch that handler triggers refuse itself.
return gui.c.GocuiGui().NewBackgroundTask()
},
// Rendering is background work too (see above), so the view mutations
// it bounces onto the UI thread mustn't count towards being busy.
gui.g.OnUIThreadAndWaitBackground,
)
gui.viewBufferManagerMap[view.Name()] = manager
}
+4 -11
View File
@@ -23,12 +23,8 @@ func (gui *Gui) handleTestMode() {
}
if test != nil {
isIdleChan := make(chan struct{})
gui.c.GocuiGui().AddIdleListener(isIdleChan)
waitUntilIdle := func() {
<-isIdleChan
gui.c.GocuiGui().WaitUntilIdle()
}
go func() {
@@ -38,17 +34,14 @@ func (gui *Gui) handleTestMode() {
gui.PopupHandler.(*popup.PopupHandler).SetToastFunc(
func(message string, kind types.ToastKind) { toastChan <- message })
test.Run(&GuiDriver{gui: gui, isIdleChan: isIdleChan, toastChan: toastChan, headless: Headless()})
test.Run(&GuiDriver{gui: gui, toastChan: toastChan, headless: Headless()})
gui.g.Update(func(*gocui.Gui) error {
return gocui.ErrQuit
})
waitUntilIdle()
time.Sleep(time.Second * 1)
log.Fatal("gocui should have already exited")
// Wait for the event loop to actually exit.
<-gui.g.LoopExited()
}()
if os.Getenv(components.WAIT_FOR_DEBUGGER_ENV_VAR) == "" {
+29 -17
View File
@@ -11,7 +11,6 @@ import (
"github.com/jesseduffield/lazygit/pkg/tasks"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/sasha-s/go-deadlock"
"gopkg.in/ozeidan/fuzzy-patricia.v3/patricia"
)
type HelperCommon struct {
@@ -30,6 +29,12 @@ type IGuiCommon interface {
LogCommand(cmdStr string, isCommandLine bool)
// we call this when we want to refetch some models and render the result. Internally calls PostRefreshUpdate
Refresh(RefreshOptions)
// Like Refresh, but for callers running on a worker goroutine (e.g. inside
// a WithWaitingStatus handler) rather than the UI thread. The refresh
// captures the model/context state it needs on the UI thread before doing
// its git work; knowing which thread the caller is on lets it capture
// inline (UI thread) or hop across (worker) without racing or deadlocking.
RefreshFromWorker(RefreshOptions)
// we call this when we've changed something in the view model but not the actual model,
// e.g. expanding or collapsing a folder in a file view. Calling 'Refresh' in this
// case would be overkill, although refresh will internally call 'PostRefreshUpdate'
@@ -52,6 +57,10 @@ type IGuiCommon interface {
// return the view buffer manager for the given view, or nil if it doesn't have one
GetViewBufferManagerForView(view *gocui.View) *tasks.ViewBufferManager
// read enough lines into the given view's buffer to fill it at its current
// scroll position, plus some read-ahead for smooth scrolling
ReadLinesToFillView(view *gocui.View)
// returns true if command completed successfully
RunSubprocess(cmdObj *oscommands.CmdObj) (bool, error)
RunSubprocessAndRefresh(*oscommands.CmdObj) error
@@ -75,13 +84,22 @@ type IGuiCommon interface {
// Only necessary to call if you're not already on the UI thread i.e. you're inside a goroutine.
// All controller handlers are executed on the UI thread.
OnUIThread(f func() error)
// Like OnUIThread, but for work triggered by a background routine, so it
// doesn't count towards lazygit being busy (see the *Background methods on
// gocui.Gui and repo-switch safety).
OnUIThreadBackground(f func() error)
// Like OnUIThread, but signals that the callback only modifies view
// content (e.g. spinner), allows the event loop to skip
// the expensive layout recalculation when only content changed.
OnUIThreadContentOnly(f func() error)
// Like OnUIThreadContentOnly, but for background work (see OnUIThreadBackground).
OnUIThreadContentOnlyBackground(f func() error)
// Runs a function in a goroutine. Use this whenever you want to run a goroutine and keep track of the fact
// that lazygit is still busy. See docs/dev/Busy.md
OnWorker(f func(gocui.Task) error)
// Like OnWorker, but for a background routine (or work it triggers), so it
// doesn't count towards lazygit being busy (see OnUIThreadBackground).
OnWorkerBackground(f func(gocui.Task) error)
// Function to call at the end of our 'layout' function which renders views
// For example, you may want a view's line to be focused only after that view is
// resized, if in accordion mode.
@@ -142,7 +160,7 @@ type IPopupHandler interface {
// Shows a popup prompting the user for input.
Prompt(opts PromptOpts)
WithWaitingStatus(message string, f func(gocui.Task) error) error
WithWaitingStatusSync(message string, f func() error) error
WithWaitingStatusBlockingInput(message string, f func(gocui.Task) error) error
Menu(opts CreateMenuOptions) error
Toast(message string)
ErrorToast(message string)
@@ -329,25 +347,14 @@ type Model struct {
MainBranches *git_commands.MainBranches
// for displaying suggestions while typing in a file name
FilesTrie *patricia.Trie
Authors map[string]*models.Author
HashPool *utils.StringPool
}
type Mutexes struct {
RefreshingFilesMutex deadlock.Mutex
RefreshingBranchesMutex deadlock.Mutex
RefreshingStatusMutex deadlock.Mutex
RefreshingPullRequestsMutex deadlock.Mutex
LocalCommitsMutex deadlock.Mutex
SubCommitsMutex deadlock.Mutex
AuthorsMutex deadlock.Mutex
SubprocessMutex deadlock.Mutex
PopupMutex deadlock.Mutex
PtyMutex deadlock.Mutex
SubprocessMutex deadlock.Mutex
PtyMutex deadlock.Mutex
}
// A long-running operation associated with an item. For example, we'll show
@@ -377,8 +384,6 @@ type IStateAccessor interface {
// tells us whether we're currently updating lazygit
GetUpdating() bool
SetUpdating(bool)
SetIsRefreshingFiles(bool)
GetIsRefreshingFiles() bool
GetShowExtrasWindow() bool
SetShowExtrasWindow(bool)
GetRetainOriginalDir() bool
@@ -386,6 +391,13 @@ type IStateAccessor interface {
GetItemOperation(item HasUrn) ItemOperation
SetItemOperation(item HasUrn, operation ItemOperation)
ClearItemOperation(item HasUrn)
// A counter that is bumped every time we switch to a different repository
// (see Gui.resetState). A refresh captures it when it starts and carries it
// through to onUIThreadUnlessRepoChanged, so that a model update computed for
// one repo can be dropped rather than applied to another if the user switched
// repos while the refresh was in flight.
GetRepoGeneration() int
}
type IRepoStateAccessor interface {
+33 -18
View File
@@ -25,14 +25,6 @@ const (
PULL_REQUESTS
)
type RefreshMode int
const (
SYNC RefreshMode = iota // wait until everything is done before returning
ASYNC // return immediately, allowing each independent thing to update itself
BLOCK_UI // wrap code in an update call to ensure UI updates all at once and keybindings aren't executed till complete
)
// CommitSelectionBehavior controls which local commit is selected after the
// commits list is reloaded by a refresh.
type CommitSelectionBehavior int
@@ -55,22 +47,45 @@ const (
SelectHeadCommit
)
type RefreshOptions struct {
Then func()
Scope []RefreshableView // e.g. []RefreshableView{COMMITS, BRANCHES}. Leave empty to refresh everything
Mode RefreshMode // one of SYNC (default), ASYNC, and BLOCK_UI
// BranchSelectionBehavior controls which local branch is selected after the
// branches list is reloaded by a refresh.
type BranchSelectionBehavior int
// Normally a refresh of the branches tries to keep the same branch selected
// (by name); this is usually important in case the order of branches
// changes. Passing true for KeepBranchSelectionIndex suppresses this and
// keeps the selection index the same. Useful after checking out a detached
// head, and selecting index 0.
KeepBranchSelectionIndex bool
const (
// Keep the same branch selected by name, restoring it at its new position if
// the order changed. This is the right default whenever the list reloads
// underneath a selection the user hasn't deliberately changed.
KeepBranchSelectionByName BranchSelectionBehavior = iota
// Select the checked-out branch (the one at the top of the list). Used after
// operations that check something out - checkout, creating a branch, moving
// commits to a new branch - so the newly checked-out ref ends up selected.
SelectCheckedOutBranch
)
type RefreshOptions struct {
Then func() error
Scope []RefreshableView // e.g. []RefreshableView{COMMITS, BRANCHES}. Leave empty to refresh everything
// If true, hold off on updating the UI until all scopes have finished
// refreshing and then apply them together in a single frame, rather than
// letting each scope update the UI as soon as it's done.
BatchUIUpdates bool
// Controls which local branch is selected after the refresh. Defaults to
// KeepBranchSelectionByName.
BranchSelection BranchSelectionBehavior
// Controls which local commit is selected after the refresh. Defaults to
// KeepCommitSelectionByHash.
CommitSelection CommitSelectionBehavior
// When true, select the top (most recent) reflog entry after the refresh.
// Used alongside SelectCheckedOutBranch by operations that check something
// out, since the checkout adds a new reflog entry at the top. Defaults to
// keeping the reflog selection where it is.
SelectTopReflogCommit bool
// When true, this refresh was initiated by a background routine rather than
// by a user action. Every git command suppresses optional locks by default
// so it can't contend for index.lock (see git_commands.OptionalLocksEnvVar);
+8
View File
@@ -121,6 +121,14 @@ func (gui *Gui) render() {
gui.c.OnUIThread(func() error { return nil })
}
// renderContentOnly triggers a re-render that skips the layout pass and only
// redraws the views whose content changed (relying on tcell's cell-level dirty
// tracking to emit just the cells that actually differ). Use it when only a
// view's content changed, not the window layout.
func (gui *Gui) renderContentOnly() {
gui.c.OnUIThreadContentOnly(func() error { return nil })
}
// postRefreshUpdate is to be called on a context after the state that it depends on has been refreshed
// if the context's view is set to another context we do nothing.
// if the context's view is the current view we trigger a focus; re-selecting the current item.
+7 -6
View File
@@ -427,9 +427,11 @@ type TranslationSet struct {
UndoingStatus string
RedoingStatus string
CheckingOutStatus string
CreatingBranchStatus string
CommittingStatus string
RewordingStatus string
RevertingStatus string
ResettingStatus string
CreatingFixupCommitStatus string
MovingCommitsToNewBranchStatus string
CommitFiles string
@@ -768,6 +770,7 @@ type TranslationSet struct {
ErrStageDirWithInlineMergeConflicts string
ErrRepositoryMovedOrDeleted string
ErrWorktreeMovedOrRemoved string
CantSwitchWhileOperationInProgress string
CommandLog string
ToggleShowCommandLog string
FocusCommandLog string
@@ -1128,12 +1131,7 @@ Thanks for using lazygit! Seriously you rock. Three things to share with you:
2) Be sure to read the latest release notes at:
https://github.com/jesseduffield/lazygit/releases
3) If you're using git, that makes you a programmer! With your help we can make
lazygit better, so consider becoming a contributor and joining the fun at
https://github.com/jesseduffield/lazygit
Or even just star the repo to share the love!
4) If lazygit has made your life easier, you can say thanks by clicking the
3) If lazygit has made your life easier, you can say thanks by clicking the
donate button at the bottom right. Donation does not grant priority support,
but it is much appreciated.
@@ -1579,9 +1577,11 @@ func EnglishTranslationSet() *TranslationSet {
UndoingStatus: "Undoing",
RedoingStatus: "Redoing",
CheckingOutStatus: "Checking out",
CreatingBranchStatus: "Creating branch",
CommittingStatus: "Committing",
RewordingStatus: "Rewording",
RevertingStatus: "Reverting",
ResettingStatus: "Resetting",
CreatingFixupCommitStatus: "Creating fixup commit",
MovingCommitsToNewBranchStatus: "Moving commits to new branch",
CommitFiles: "Commit files",
@@ -1919,6 +1919,7 @@ func EnglishTranslationSet() *TranslationSet {
ErrRepositoryMovedOrDeleted: "Cannot find repo. It might have been moved or deleted ¯\\_(ツ)_/¯",
CommandLog: "Command log",
ErrWorktreeMovedOrRemoved: "Cannot find worktree. It might have been moved or removed ¯\\_(ツ)_/¯",
CantSwitchWhileOperationInProgress: "Can't switch repositories while an operation is in progress",
ToggleShowCommandLog: "Toggle show/hide command log",
FocusCommandLog: "Focus command log",
CommandLogHeader: "You can hide/focus this panel by pressing '%s'\n",
+1 -1
View File
@@ -56,7 +56,7 @@ func TestIntegration(t *testing.T) {
CodeCoverageDir: codeCoverageDir,
InputDelay: 0,
// Allow two attempts at each test to get around flakiness
MaxAttempts: 2,
MaxAttempts: 1,
})
assert.NoError(t, err)
+1 -1
View File
@@ -256,7 +256,7 @@ func (self *Shell) CreateNCommitsStartingAt(n, startIndex int) *Shell {
fmt.Sprintf("file%02d.txt", i),
fmt.Sprintf("file%02d content", i),
).
Commit(fmt.Sprintf("commit %02d", i))
Commit(fmt.Sprintf("commit-%02d", i))
}
return self
+8 -8
View File
@@ -34,29 +34,29 @@ var Basic = NewIntegrationTest(NewIntegrationTestArgs{
t.Views().Commits().
Focus().
SelectedLine(Contains("CI commit 10")).
NavigateToLine(Contains("CI commit 09")).
SelectedLine(Contains("CI commit-10")).
NavigateToLine(Contains("CI commit-09")).
Tap(func() {
markCommitAsBad()
t.Views().Information().Content(Contains("Bisecting"))
}).
SelectedLine(Contains("<-- bad")).
NavigateToLine(Contains("CI commit 02")).
NavigateToLine(Contains("CI commit-02")).
Tap(markCommitAsGood).
TopLines(Contains("CI commit 10")).
TopLines(Contains("CI commit-10")).
// lazygit will land us in the commit between our good and bad commits.
SelectedLine(Contains("CI commit 05").Contains("<-- current")).
SelectedLine(Contains("CI commit-05").Contains("<-- current")).
Tap(markCommitAsBad).
SelectedLine(Contains("CI commit 04").Contains("<-- current")).
SelectedLine(Contains("CI commit-04").Contains("<-- current")).
Tap(func() {
markCommitAsGood()
// commit 5 is the culprit because we marked 4 as good and 5 as bad.
t.ExpectPopup().Alert().Title(Equals("Bisect complete")).Content(MatchesRegexp("(?s)commit 05.*Do you want to reset")).Confirm()
t.ExpectPopup().Alert().Title(Equals("Bisect complete")).Content(MatchesRegexp("(?s)commit-05.*Do you want to reset")).Confirm()
}).
IsFocused().
Content(Contains("CI commit 04"))
Content(Contains("CI commit-04"))
t.Views().Information().Content(DoesNotContain("Bisecting"))
},

Some files were not shown because too many files have changed in this diff Show More