When a task is stopped to make way for a newer one, stopping closes
opts.Stop, and the scanner goroutine then closes lineChan. The read loop's
select between those two channels is therefore non-deterministic: it can
land on the closed lineChan (ok == false) instead of the opts.Stop case,
sending a stopped task into the end-of-input branch.
There it runs the full finalize — swapping its half-read off-screen buffer
in, clamping the origin to the truncated content, and clearing the loading
flag — all of which corrupt what the incoming task is about to render. The
most visible symptom is a brief frame of truncated content with the scroll
yanked to the top, seen when re-renders overlap rapidly (e.g. the periodic
background refresh re-rendering a main view faster than it can load, very
easy to hit under LAZYGIT_SLOW_RENDER).
The underlying bug predates the off-screen render (the EOF branch always
clamped the origin via onEndOfInput), but that change made it far worse by
also swapping a truncated buffer into the display. Fix it at the source: in
the EOF branch, check whether we were stopped and, if so, bail out like the
explicit stop case, leaving the view entirely to the task that replaces us.
There's no test because the bug is the non-deterministic select itself:
any test would have to win a coin flip to observe it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A cmd/pty re-render used to overwrite the displayed buffer from the top
down as lines arrived, relying on keeping the previous render's view-line
tail to avoid a blank frame. That left the view showing a mixture of old
and new content while loading, and any reader (draw, clicks, the
view-line mapping) could observe a half-written buffer at the wrong
scroll.
Instead, build the new content in a second, off-screen viewBuffer: until
the task has read enough to paint, writes go there and the displayed
buffer — and so everything every reader sees — is left untouched. Once the
task reaches its first-paint point (InitialRefreshAfter, or EOF for short
content) it swaps the off-screen buffer in atomically, so the view jumps
straight from the previous render to the new one with no intermediate
frame. Subsequent lines append to the now-displayed buffer.
Swapping at the first-paint point means the displayed buffer is only a
viewport tall when it appears and then grows as the rest streams in toward
the count needed for an accurate scrollbar. The scrollbar is sized from the
displayed buffer's height, so left to itself the thumb would shrink and
snap back during that growth (most visibly: the files panel's periodic
refresh making the thumb jump while scrolled down). The total height the
scrollbar needs is a strictly later quantity than the viewport-fill paint,
so no single early swap can have both right. FreezeScrollbarHeight therefore
records the view's height when a load begins and the scrollbar is held there
— growing only if the new content turns out taller — until the load ends; a
synchronous render superseding the load releases it. This mirrors the layout
clamp, which already ignores the partial content height while a view loads.
With the swap doing a wholesale replace, refreshViewLinesIfNeeded can
truncate the view lines to the current buffer: there is no longer a
half-loaded shorter buffer whose tail we must keep showing, so a stale
tail never forms. clear()/Reset() abandon any in-progress off-screen
render so a synchronous SetContent after a stopped task writes to the
display.
The swap holds writeMutex for now; it could later move to the main thread.
Flicker behaviour still needs interactive verification (LAZYGIT_SLOW_RENDER
+ a real diff renderer).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A render that takes more than 200ms to produce its first line takes the view
over to say "loading...", which clears the buffer it was showing. That is
worth doing when the content coming is different — the view is showing
something the user has moved on from, and saying so beats leaving it there
silently. It is pure flicker when the content isn't changing: the view is
already showing exactly what the render is about to put back, and a slow
re-render of unchanged content is common (a background refresh over a repo
with submodules that have uncommitted changes, say).
So track whether the render in flight has content the view isn't already
showing, and only let the indicator take over when it does.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
write, writeCells, makeWriteable, parseInput and
autoRenderHyperlinksInCurrentLine produced cells into v.buf; move them onto
viewBuffer so they can write into any buffer, not just the displayed one.
The display-side effects that don't belong to content production —
tainting, clearing hover, updating search positions — stay behind in the
View.write wrapper, which delegates the actual writing to v.buf.write(v).
Render config the writer needs (Editable, colors, width, tab width,
hyperlink auto-render) is read from the passed View. Behaviour-preserving:
the wrapper still always targets v.buf.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The fields that make up a view's content and the act of writing to it —
the cell buffer (lines), the write cursor (wx/wy), the escape-sequence
decoder (ei) and the held-newline flag (pendingNewline) — were loose
fields on View. Bundle them into a viewBuffer struct that View holds by
pointer. This is a behaviour-preserving prep refactor: every access just
goes through v.buf now. It sets up rendering into a second, off-screen
viewBuffer that can be swapped in atomically, so an async re-render never
exposes a half-written buffer to readers.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
refreshMainViews reset the scroll position of every other main view at the
very top, before moveMainContextPairToTop runs its CopyContent. CopyContent
copies the previously-shown view's content into the now-visible one to avoid a
blank frame during the async re-render — but because the reset ran first, it
had already zeroed the origin of that soon-to-be-copied source view. The
placeholder therefore always appeared scrolled to the top, jumping away from
wherever the screen actually was, on every cross-pair transition.
Move the reset to after the copy. The end state is unchanged (each other main
view still ends at origin 0, and the destination always re-renders), but the
brief placeholder now stays at the source view's real scroll position until
the real content paints.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The layout scrolls a view up if its origin is past the bottom of its
content, to avoid showing blank space (e.g. after a resize). But it measures
content height by the lines loaded so far, and command/pty tasks load
asynchronously. So when a view is re-rendered while scrolled down, the layout
would yank it to the top because only a fraction of the content has been read
yet, then leave it there once loading finished.
Track whether a command task is actively reading (set synchronously when the
task is created, so a layout pass in between sees it; cleared at EOF, but not
when stopped, since that means a newer task is taking over) and skip the
scroll-up clamp for such views. onEndOfInput already re-clamps once loading
completes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A task's read loop processes one LinesToRead request at a time. The initial
request has a large line count and no Then callback; if the content is shorter
than that, the loop hits EOF on the initial request and breaks out, abandoning
any further requests still sitting in the readLines channel. So a ReadToEnd
call that races a still-loading-but-shorter-than-its-initial-read view has its
Then silently dropped: it isn't fired immediately (the channel was non-nil at
call time) and it's never dequeued.
On EOF, drain the queued requests and fire their Then callbacks before
breaking out, since reaching EOF trivially satisfies any "read more" request.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
hyperlinkAt (the click path) and onMouseMove/findHyperlinkAt (hover) read
v.viewLines without holding writeMutex, unlike every other reader. They run
on the event-handling goroutine, so a re-render on the task goroutine can
shrink or rebuild viewLines between the bounds check and the indexing,
causing an out-of-range panic (observed: "index out of range [60] with
length 0" while hovering during a diff re-render).
Take writeMutex for the duration, like the other viewLines readers do, so
the check and the access see the same slice.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reading a view's internal buffer belongs on the view itself, next to
findHyperlinkAt, rather than in the event loop; and the view is where the
lock that guards that buffer can be taken.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Re-rendering a diff into a main view is asynchronous and lazy: the read
loop fills the view a screenful at a time and refreshes as it goes. When
debugging scroll-restore and flicker behaviour, the individual frames go by
too fast to see. Setting LAZYGIT_SLOW_RENDER=<milliseconds> sleeps that long
after each line is written, stretching the load out so the frames become
visible. It has no effect when unset, so it's safe to leave in.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Several methods assigned v.ox and v.oy directly: SetOrigin, CopyContent,
the wrap/autoscroll branches in draw, FocusPoint, and
Scroll{Up,Down,Left,Right}. Funnelling them all through SetOriginX and
SetOriginY gives a single place to observe (or set a breakpoint on)
every change to a view's scroll position, which makes debugging scroll
behaviour much easier.
This means those call sites now also get the setters' `< 0` clamps, but
that is behaviour-preserving in every case: each assigned value is
already >= 0. calculateNewOrigin never returns a negative number;
CopyContent copies origins that are themselves always >= 0; and the draw
and scroll writes are all guarded (or fed only non-negative amounts) so
the result can't go below zero.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
taskKey is written on the goroutine NewTask spawns, under taskIDMutex, but
GetTaskKey read it without the lock — and the string renders in
tasks_adapter.go call that from the UI thread while a previous task's
goroutine may be writing. A Go string is a two-word value, so a torn read
can pair one string's pointer with another's length and index out of
bounds, not merely return the wrong key.
Take the lock in GetTaskKey, and read the field directly at the one call
site that already holds it.
No test: the failure needs two goroutines to interleave inside a
two-word assignment, which nothing can schedule deterministically.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
When a rebase (or multi-commit cherry-pick or revert) stops with a
conflict, it is often useful to look at the diff of the "<-- CONFLICT"
commit to double-check that the conflict resolution matches the diff of
the original commit. To make that easier, select that commit
automatically.
This doesn't change anything, we just pin down the selection behavior
around conflicts; we are going to change that behavior, and these tests
will make it obvious how when they change in the next commit.
When there is a single conflicting file left to be resolved, lazygit
dismisses the conflicted-files-only filter when the file no longer has
conflict markers. However, the selection moved to the top, which is
annoying because very often it is useful to look at that file's
resulting diff once more to confirm that conflicts were resolved
correctly, and finding it again can be cumbersome when there are many
changed files. So keep it selected.
Of course, this only helps for the last (or only) conflicted files; when
there are multiple, a resolved file disappears from the panel until all
are resolved, which makes it hard to double-check the resulting diffs.
Doing it afterwards is not easy because you'd have to remember which
files were conflicting. This needs a different solution, but for the
special case of only a single conflicting file this is already a big
improvement.
When using lazygit in a multi-tab terminal it is useful to see if the
lazygit tab is currently active; ghostty does a very good job at dimming
down the inactive tabs, but VS Code's builtin terminal does not, so
indicate this on our side by removing the green highlight from panel
frames and tab titles, and showing the selection as inactive like we do
for a side panel when the main view is focused.
A terminal that supports focus reporting answers with the state it is
already in when we turn reporting on, so at startup we were told that we
had gained focus that we never lost, and refreshed everything a second
time on top of the refresh that loading the repo had just started. The
two ran at once, each with its own `git status`, which made both of them
slower than the one refresh needed to be.
Keep track of what the reports say, then, and pass on only the ones that
change it. Assuming that we start out focused costs us nothing when we
don't: that same first report says so, so a lazygit started in a window
that isn't in front knows it from the start.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Finding out which of the files are worktrees of ours had its own answer
to where this repo's worktrees are, walking the directory that git keeps
them in. The worktrees panel asks git itself, and that is the better
answer: it is the one git gives for the same question elsewhere in the
app, and it doesn't need to know where git records what.
The model that panel fills is all the files need, so mark them from it.
That takes the work out of the file loader, whose other two callers were
paying for it without wanting it, and it costs no git call at all: both
models are written on the UI thread, so whichever of the two refreshes
lands second marks the files against the other's fresh data.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
We show this with a worktree icon (which is only shown when nerd fonts
are used, so turn these on), but also we strip the trailing `/` that
"git status" reports, so that it shows as a file rather than a directory
with a bogus file in it.
The reason for adding the test is that we are going to touch the logic
that determines whether an item in the Files panel is a linked worktree,
and this guards against regressing.
The worktrees were loaded and written by the branches refresh whenever
both were in scope, because the branches view shows worktrees against
branches: refreshing them separately rendered that view twice, once
with worktrees that were still stale.
Ordering the two is enough for that, and it leaves each scope owning
its own model again. The worktrees refresh now runs first and queues
its model write before it reports being done, so a branches refresh
that waits for it queues its own write behind that one, and renders
once with both. The worktrees scope only renders the branches view
itself when nobody else is going to.
As a side effect the two loads now run concurrently, where the branches
refresh used to load the worktrees after its own branches.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Everything in performRefresh is meant to read as "if this scope was
asked for, refresh it", with the scopes that always change together
expanded into each other up front so that each check can name a single
one. The commits and the branches were the exception: one condition
asking for either of them refreshed both, so what that block does only
followed from reading it together with the expansion at the top of the
function. The rebase commits hung off the same condition as an else,
even though it is the commits refresh they are an alternative to.
Expand those two into each other like the other pairs, and give each of
them a check of its own. They now capture their inputs separately,
which is what every other scope has always done.
The reflog stays with the branches rather than getting a check of its
own, because sorting the branches by recency needs it loaded first.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
It's overkill for most purposes, but I'd say it doesn't hurt to have the
extra resolution available in the raw JSON data for the few cases where
it's useful. Have "lazygit -l" print them with milli-second resolution;
that seems to be a good middle ground.
Besides the misleading error about submodules, the command crashes when
it runs before the first files refresh has come in: the file tree
doesn't exist yet at that point, and staging all of a tree that isn't
there dereferences a nil root node. That is easy to hit in a big repo,
where `git status` takes a moment while the panel sits there empty.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The stage-all command acts on the whole file tree, and nothing stops it
from doing that when the tree is empty. It ends up in the branch that
explains why a submodule couldn't be staged, which has nothing to do
with what the user did.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every one of these did by hand what focusing the list now does on its
own: five hand-added scroll requests, and four origin resets that paired
a "select the first item" with a "and show the top of the list".
The scroll that the commits refresh performed when it found the selected
commit at a new index goes too. It is now unconditional for a foreground
refresh, and deliberately absent for a background one: when an agent
commits in another window, we would rather see the new commits arrive
than have the view yank itself back to the commit we had selected.
The one origin reset that stays is the one in ReApplyFilter, which runs
as part of a refresh and so can't rely on the refresh scrolling.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ever since scrolling the selection into view became opt-in, we have been
fixing the same class of regression by hand, five times so far: a
controller moves the selection somewhere new, doesn't say that it wants
the view to follow, and the selection ends up off screen. The decision
needs facts from two places — whether the selection went somewhere new is
known to the list, whether the scroll position is the caller's to manage
is known to the caller — and asking every caller for both is what keeps
going wrong. The callers that get it wrong are usually not even the ones
that moved the selection: they are pass-throughs like postRefreshUpdate,
which can't know what a refresh did to the selection.
So default to scrolling, and let the two callers that maintain the scroll
position themselves say so.
The one case where scrolling is always wrong is a refresh that no user
action is behind: a background poll, or a reload of state on window
focus, after a subprocess, or after a repo switch. Those must leave the
viewport wherever the user last scrolled it to — that is what made the
scrolling opt-in in the first place. Both are already marked in
RefreshOptions, so the refresh can decide it once, centrally, instead of
each caller judging it.
A user action that ends in a foreground refresh does now yank the view
back to the selection if the user had scrolled away from it. That's a
behaviour change, and there may be actions where it turns out to be
unwelcome; those we can fix individually, and it beats the ones that
don't scroll today.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Since scrolling the selection into view became opt-in, five places have
had to be fixed by hand after the fact, none of them with a test. Cover
them now: making the scrolling automatic has to keep all five working,
and once it does, the hand-added scroll calls can go.
Two of them assert that the selection is visible rather than on an exact
scroll position, because the panel they look at changes height along the
way (filtering mode switches to half screen), or because what matters is
only that the commit we jumped to can be seen.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This is the other place that manages its own scroll position: while a
drag extends the selection to a line below the viewport, the view stays
put, and the drag autoscroller scrolls it one line at a time for as long
as the pointer stays there. Making the scroll automatic would centre the
selection instead, i.e. jump the view rather than scroll it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The one behaviour that made scrolling the selection into view opt-in in
the first place — a background refresh must not yank the view back to a
selection the user scrolled away from — has never been covered by a test.
It's about to become the one case that the automatic scrolling has to
suppress, so cover it first.
Getting there needs two things from the test harness: mouse wheel events,
which are the only way to scroll a list panel without moving the
selection, and a way to trigger a background refresh. The periodic
routine that issues it is turned off in tests, and turning it on would
mean waiting for its timer and hoping it fires while we're looking, so
drive the refresh directly instead.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
We are about to make list panels scroll their selection into view
automatically. Page up and down are one of the few places that manage
the scroll position themselves, keeping the selection at the edge of the
viewport rather than in its middle, and nothing covers that today.
Asserting on it needs an exact scroll position assertion; only
OriginYAtLeast existed so far.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
go-gh reads gh's config file once per process and answers from that
snapshot for the rest of the process's life. gh rewrites the file
whenever the active account changes, and stores the active account's
token either in it or in the system keyring, depending on the account.
A lazygit that has been running for a while therefore consults a
snapshot that no longer describes reality: it either keeps using a
token for an account that is no longer active, or, when the snapshot
was taken while a keyring-backed account was active, finds no token at
all and silently stops showing pull requests until it is restarted.
Asking gh resolves the token afresh on every refresh, from whichever of
the environment, the keyring or the config file currently holds it.
go-gh's lookup stays behind as a fallback for setups without the gh
binary, where it still picks up GH_TOKEN and friends.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Quitting with confirmOnQuit set hung for three seconds and printed
"cannot kill child process", but only with a clean working tree. Closing
the confirmation pops the context before running its handler, so the
files panel is re-focused and re-renders the main view, and only then
does the handler return ErrQuit. With no changed files that render is a
string task, whose whole body is one hop to the UI thread — a hop that
is never served, because the handler's ErrQuit has meanwhile brought the
main loop down. The task can't finish, so the ViewBufferManager.Close
that follows waits for it until it times out. (With changed files it's a
command task instead, and every blocking point in one of those selects
on the stop channel, so Close gets through.)
A wait for the UI thread now ends when the loop does. That also covers
the command task's own hops, which are stopped only in between them.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Nothing dequeues user events once MainLoop has returned, so a worker
blocked in OnUIThreadAndWait is blocked for good. The assertion records
that; the next commit makes the wait give up instead.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Right now the function always returns nil, but this will change later in
this branch, so handle errors properly. Without that, the first capture
that assigns env.git would not run, leave env.git nil, and subsequent
code would crash.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every caller passes an f that unconditionally returns nil, so f's error
return has never carried anything: the value is dead weight, and it
occupies the one channel the wait itself needs to report that it couldn't
run f at all. Drop it, so that the error the wait returns can only ever
mean that.
Work that can fail hands its error back through a captured variable, the
way the background fetch already hands back four values, which keeps the
two outcomes distinguishable at a call site that has both.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Git only writes the space after a marker when there is a label to write
after it, and the label can be empty: `git checkout -m` with the diff3
conflict style, for instance, has no name for the common ancestor, so it
writes a bare "|||||||" line.
Ask git for the attribute of every conflicted file whenever we load the
file status, so that we recognize the markers it actually wrote. Files
that are set up this way are precisely the ones whose regular content
tends to contain marker-looking lines, so matching a run of at least
seven characters instead is not an option: we'd take the file's own
content for markers and then never consider its conflicts resolved.
One `git check-attr` call covers all conflicted files at once; asking per
file would take seconds when hundreds of files are conflicted, and it
would hurt worst on Windows, where spawning a process is expensive.
Because the lookup rides along with the file status, it costs nothing
when there are no conflicts, and editing .gitattributes during a merge
takes effect on the next refresh.
When a file's conflict markers aren't seven characters long we don't
recognize them at all. Two things go wrong: we consider the file's
conflicts resolved, so we stage it and offer to continue the merge a
moment after stopping at it; and pressing enter on it shows its diff
instead of the merge conflicts view, leaving no way to resolve it in
lazygit.
Git doesn't always write conflict markers of seven characters: the
conflict-marker-size gitattribute overrides that per file, and it is set
for good reasons — for file types whose regular content tends to contain
marker-looking lines, such as documentation about merging, or test
scripts. We hard-code seven characters everywhere we look for markers,
so none of that works.
Prepare for honoring the attribute by threading the marker size through
everything that recognizes a marker, carried on the file model. Nothing
fills it in yet, so we still use git's default size of seven everywhere,
and matching is unchanged: a marker consists of exactly that many marker
characters, and all but the "=======" one are followed by a space and a
label.
Creating and applying a stash both touch every changed file, so in a
large repo they can take long enough to be noticeable — and running them
on the UI thread meant the confirmation popup stayed on screen, frozen,
for the whole operation. Run them on a worker instead, with a spinner,
and keep blocking input for their duration so that the type-ahead
guarantee the refresh used to provide still holds.
Dropping stays on the UI thread: it only rewrites the stash reflog, so
it's fast no matter how big the stashes are.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Collapsing the range before kicking off the refresh paints the new
selection against the list as it was before the drop, so for a frame the
entries that were just dropped are still on screen (and, with
gui.shrinkSidePanelsToContent, the panel is still at its old size).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Pushing the files context right after kicking off the refresh moves the
focus (and, with gui.shrinkSidePanelsToContent, resizes the panels) a
frame before the refreshed stash and files lists arrive. Doing it from
Then puts it in the same frame as the data it belongs to.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Stashing and popping change both the stash list and the files list.
With each scope updating the UI as soon as its own refresh is done, the
two panels visibly change at different times; with
gui.shrinkSidePanelsToContent that also means their sizes change at
different times than their contents.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Running lazygit in a .git dir got you told you were in a bare repo,
which you weren't: the worktree was sitting right there, one directory
up. git's own convention is that a git dir called .git belongs to the
directory holding it — that's how `git worktree list` names the main
worktree — so ask that directory, and if it is a worktree, open the repo
we were really being asked about.
The git dirs that aren't called .git keep the answer they had. A linked
worktree's and a submodule's do have a worktree, but nothing we look at
says where, so we would be guessing; a bare repo's has none to find.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>