Commit Graph
8157 Commits
Author SHA1 Message Date
github-actions[bot] 42a0f9ec91 README.md: Update Sponsors 2026-08-18 07:47:51 +00:00
Stefan HallerandGitHub ea91639546 Show renames when selecting a directory that a file was moved into or out of (#5924)
In a commit that moves a bunch of files from one directory to another,
showing the commit's files and selecting the target directory of those
moves would show these files as newly added rather than moved in the
main view's diff. Selecting the source directory would show them as
removed. Fix this to keep showing them as moved in both cases. The same
applies to the files panel when staging the move of a file, and when
filtering the file list down to just the source or target directory
using the `/` filter in either panel.

The decision to show them as renames when selecting the "moved-from"
directory was not an easy one; it's slightly weird because the list of
files in the side panel doesn't show them there (they appear in the
target directory), but the main view does. An alternative would have
been not to show them in that case, to match the side panel. However,
the point of selecting a directory is to see all the changes that affect
it, and the moved-out files are relevant changes you want to see there.

See
https://github.com/jesseduffield/lazygit/discussions/4899#discussioncomment-17976172.
2026-08-18 09:47:38 +02:00
Stefan Haller d7401559f0 Collapse the paths of a moved directory into the directory itself
A commit that moves an entire package elsewhere renames hundreds of
files, and passing every one of their old paths can push the command past
the length limit the OS imposes (~32k characters on Windows). Their
common parent directory does just as well whenever everything it holds
ends up in the diff anyway.

Deciding that needs to consider every file of the diff, not only those on
display, so the paths are now derived from the model rather than from the
tree; a status filter must not make a directory look emptier than it is.
2026-08-17 09:32:30 +02:00
Stefan Haller bee03d3b98 Show renames when diffing a directory that a file was moved into or out of
Git limits its tree diff by the pathspec before it looks for renames, so
a directory only ever gets one end of a rename whose other end is outside
it. Nothing is left to pair up, and the file turns into an addition or a
deletion that the commit doesn't contain.

Pass the other end along with the directory. This is bounded by the
number of renames that cross the directory's boundary, so it costs
nothing at all for the vast majority of commits.
2026-08-17 09:32:30 +02:00
Stefan Haller 40cb4bb24d Extract a single helper for the paths a node's diff is limited to
The files and commit files panels each had their own copy of this, one of
which used to be missing the previous path of a rename. Growing them
apart again is the last thing we want, since the next commit needs to
teach both of them about renames that cross a directory boundary.

The files panel version only returned paths for the filtered case, and
left it to WorktreeFileDiffCmdObj to derive the rest from the node; now
that all callers pass the paths in, that command doesn't need to know
about renames at all.
2026-08-17 09:32:30 +02:00
Stefan Haller 2c9187acb9 Pass the previous path when diffing a filtered directory in the files panel
Restricting the diff to the files that a filter leaves visible drops the
delete-side entry of a staged rename, so git shows the file as an
addition instead. Its commit files counterpart already passes both paths;
this brings the files panel in line.
2026-08-17 09:32:30 +02:00
Stefan Haller 6913f2afce Add tests for diffing a directory that files were renamed into or out of
Pathspec limiting happens before rename detection in git's tree diff, so
filtering the diff to a directory hides the delete-side entry of a rename
whose other end is outside that directory. Git then has nothing to pair
up, and reports a file moved into the directory as an addition and one
moved out of it as a deletion.

Selecting a directory is supposed to filter the commit's diff down, never
to change it, so both are wrong.
2026-08-17 09:32:30 +02:00
Stefan HallerandGitHub c199ac69f5 Keep showing files whose conflicts have been resolved (#5940)
When several files have conflicts, resolving one of them makes it vanish
from the files panel as soon as it is auto-staged, and it only comes
back once the last conflict is resolved and the filter turns off again.
By then it sits among all the other changed files of the merge, so it is
hard to find the ones whose resulting diff you still wanted to check.

So remember which files had conflicts while the conflicted-files filter
is on, and keep showing them once they are resolved. This is the general
solution that #5936 called for; that PR only helped for the case of a
single conflicted file.

The consequence is that the selection no longer moves on to the next
conflicted file when one is resolved: it stays on the file you just
resolved, which shows you its diff right away.
2026-08-16 17:09:25 +02:00
Stefan HallerandClaude Opus 5 43b47d16dd Keep showing files whose conflicts have been resolved
When several files have conflicts, resolving one of them makes it vanish
from the files panel as soon as it is auto-staged, and it only comes back
once the last conflict is resolved and the filter turns off again. By
then it sits among all the other changed files of the merge, so it is
hard to find the ones whose resulting diff you still wanted to check.

So remember which files had conflicts while the conflicted-files filter
is on, and keep showing them once they are resolved. This is the general
solution that 39513d244d called for; that commit only helped for the
case of a single conflicted file.

The consequence is that the selection no longer moves on to the next
conflicted file when one is resolved: it stays on the file you just
resolved, which shows you its diff right away.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 16:45:50 +02:00
Stefan HallerandClaude Opus 5 c10bc3b697 Collect the paths of the files with conflicts, rather than only counting them
The next commit needs to know which files have conflicts, not just how
many of them there are.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 16:45:50 +02:00
Stefan Haller adf59703ac AGENTS.md additions 2026-08-16 16:45:50 +02:00
Stefan HallerandGitHub 431993b606 Bump golangci-lint to v2.12.2 (#5941) 2026-08-16 16:45:04 +02:00
Stefan Haller 37c53ac1bf Bump golangci-lint to 2.12.2 2026-08-16 16:35:11 +02:00
Stefan Haller 486d8536f2 Preallocate arrays where the new linter version would warn about it 2026-08-16 16:35:11 +02:00
Stefan Haller 92e50a5d9a Avoid appending to an array literal
Instead, create the one dynamic element beforehand and include it in the
literal. This avoids a preallocation warning from the linter.
2026-08-16 16:35:11 +02:00
Stefan Haller f0ccb937d3 Use lo.Map instead of manual append loops
Not only is this nicer code (and more idiomatic at least in this code
base), but it also avoids linter warnings about missing preallocations
(lo.Map does preallocate the result array).
2026-08-16 16:35:11 +02:00
Stefan Haller 1db9f9cdb8 Fix linter warning about WriteString(fmt.Sprintf(...)) 2026-08-16 16:35:11 +02:00
Stefan Haller a26899f5ab Fix use of reflect.Ptr
Apparently Ptr is a deprecated name; with the new golangci-lint version
this would cause

  inline: Constant reflect.Ptr should be inlined
2026-08-16 16:35:11 +02:00
Stefan Haller 4820241caf Remove the common-false-positives and legacy linter exception presets
I don't really know what they are for, but they don't trigger any
errors.
2026-08-16 15:44:36 +02:00
Stefan HallerandGitHub 6032225472 Fix flicker, scroll glitches, and crashes in async diff rendering (#5938)
This is a preparation PR for the upcoming fold-staging-into-main-view
work; see the individual commit messages for details.

The most notable change is probably that we switch to a double-buffering
approach for flicker-free view updates; previously we would overwrite
the view from the top, and keep the existing viewlines below untouched
to update without flicker. This caused numerous problems though that
will become more painful when we start using the main view for more
operations (especially staging); telling whether the selected line still
belongs to the previous task or already to the new one is tricky.
Rendering into an offscreen buffer and swapping it in as soon as we have
enough to fill the screen makes this much easier.
2026-08-15 15:52:42 +02:00
Stefan HallerandClaude Opus 5 ebfa8c71b2 Drop FlushStaleCells, which no longer has anything to flush
It existed for the incremental re-render: a shorter render left the previous
one's view lines in the tail (deliberately, to avoid a blank frame), and this
cleared them once the new content was fully read. Async renders now build
off-screen and swap in whole, so refreshViewLinesIfNeeded truncates the view
lines to the buffer and no tail can form. All the call at end-of-input still
did was discard every wrapped line and force the whole buffer to be re-wrapped
on the next draw, which is pure work on a large diff.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 15:30:27 +02:00
Stefan HallerandClaude Opus 5 3cbf40d4ef Reset the scroll to the top at first paint, not when the task starts
When a main view re-renders content different from what it last showed, the
scroll resets to the top. That reset fired synchronously when the task started —
but with the off-screen render the previous content stays displayed until the
swap, so resetting the origin up front scrolled that still-visible content to the
top before the new content replaced it: a distracting jump when switching commits
(or any item) while scrolled down.

Defer the reset to the first paint that reveals the new content, so the previous
content stays at its scroll until the new content takes its place, and then the
new content appears at the top. Swap and reset happen in one hop on the UI
thread, so no draw can land between them and show the new content at the old
scroll. A same-content re-render keeps its scroll. The "loading..." indicator
path also resets the origin now, since it clears the previous content to show the
message and must put it at the top.

The reset moves out of NewTask into the read loop, keying off the flag that
already records whether the render's content is new. NewTask still decides,
from the same command-key comparison as before and under the same lock. It has
to be that flag rather than per-task state, because a task can be stopped and
replaced before it ever paints — a background refresh landing just after the
user clicked a different item, which is the ordering a VS Code terminal
produces, since it delivers the focus-in event (and so the refresh) before the
click. The replacement renders the same content and so sets nothing of its own,
and the click's reset would be lost with the task that owed it.

The manager's onNewKey callback is renamed resetOrigin to match its now-decoupled
timing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 15:30:27 +02:00
Stefan HallerandClaude Opus 5 bf6c34798c Don't run end-of-input handling for a render that was stopped
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>
2026-08-15 15:30:27 +02:00
Stefan HallerandClaude Opus 5 9e23111172 Render async content into an off-screen buffer and swap it in
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>
2026-08-15 15:30:27 +02:00
Stefan HallerandClaude Opus 5 87b30d9581 Don't take the view over for "loading..." when the content isn't changing
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>
2026-08-15 15:30:27 +02:00
Stefan HallerandClaude Opus 5 cc5d5057a7 Make the buffer-writing methods operate on a viewBuffer
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>
2026-08-15 15:30:27 +02:00
Stefan HallerandClaude Opus 5 2a6cb8d78e Bundle a view's cell buffer and write state into a viewBuffer
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>
2026-08-15 15:30:27 +02:00
Stefan HallerandClaude Opus 5 114d18dc04 Reset other main views' scroll after copying content, not before
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>
2026-08-15 15:30:27 +02:00
Stefan HallerandClaude Opus 5 fbadbbf99a Don't scroll a view up to fill blank space while its content is loading
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>
2026-08-15 15:30:27 +02:00
Stefan HallerandClaude Opus 5 85cff19a2a Fire queued ReadToEnd callbacks when the initial read reaches EOF
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>
2026-08-15 15:30:27 +02:00
Stefan HallerandClaude Opus 5 9293f03c83 Add a test for a read request queued while a task reaches EOF
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 15:30:27 +02:00
Stefan HallerandClaude Opus 5 86c9e6a20a Lock the view while reading viewLines on the event-handling thread
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>
2026-08-15 15:30:27 +02:00
Stefan HallerandClaude Opus 5 e3fe321080 Move the click-path hyperlink lookup onto View
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>
2026-08-15 15:30:27 +02:00
Stefan HallerandClaude Opus 5 d65ee95942 Add LAZYGIT_SLOW_RENDER debug knob for watching async render frames
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>
2026-08-15 15:30:27 +02:00
Stefan HallerandClaude Opus 5 94018de3e8 Route all view origin writes through SetOriginX and SetOriginY
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>
2026-08-15 15:30:27 +02:00
Stefan HallerandClaude Opus 5 01600042cd Guard taskKey with the mutex that already guards the task ID
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>
2026-08-15 15:30:27 +02:00
Stefan HallerandGitHub 1868167a7a Auto-select the conflicted commit when stopping in rebase (#5937)
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.
2026-08-15 15:29:31 +02:00
Stefan Haller 1c17ee1a92 Auto-select the conflicted commit when stopping in rebase
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.
2026-08-15 15:24:36 +02:00
Stefan Haller 6417da5319 Add selection assertions to tests involving conflicts
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.
2026-08-15 15:24:15 +02:00
Stefan HallerandGitHub aa38daff98 Keep the last conflict file selected after resolution (#5936)
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.
2026-08-15 15:24:01 +02:00
Stefan Haller 39513d244d Keep the last conflict file selected after resolution
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.
2026-08-15 15:03:29 +02:00
Stefan Haller 4e2a1cd5b0 Add a function SetStatusFilterPreservingSelection 2026-08-15 14:58:27 +02:00
Stefan Haller b2b9519bcc Extract a private helper function preserveSelection
The operation around which the selection should be preserved is passed
in so that it can be reused for different purposes.
2026-08-15 14:58:10 +02:00
Stefan HallerandGitHub a1f1a4ee8c Draw the UI in a more inactive look when the window is not focused (#5935)
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.
2026-08-15 12:39:51 +02:00
Stefan Haller a5a2bd0699 Draw the UI in a more inactive look when the window is not focused
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.
2026-08-15 12:17:40 +02:00
Stefan HallerandGitHub ae1007612b Improve startup time (#5934)
This PR has two separate improvements for the startup time, in
particular for the time until the Files panel shows the modified files:

- avoid walking the `.git/workspaces` dir recursively, looking for the
gitdir files of linked worktrees. This code was not used to populate the
worktrees panel, but only for the decision in the Files panel whether to
show an entry with the worktree icon; it was doing unnecessary work,
because walking the `.git/workspaces` dir recursively is pointless (git
stores the worktree gitdir files only at the top level, so a flat read
of that directory would have been enough), and can take significant time
in large repos, especially when they have many submodules. Instead of
fixing that code, remove it entirely and rearrange the refresh code so
that we can use the regular worktree model for this Files panel
decision.
- at startup we were doing two full refreshes at the same time: the
regular one that we always do after loading a repo for the first time,
and then also a focus-in refresh. I didn't realize that a terminal will
send us a focus-in event right at the moment we request these events.
Nothing bad happens from doing those two refreshes at the same time, but
it slows things down a bit when we have two "git status" calls running
concurrently.

Both of these together reduce the time it takes for the Files panel to
show its files at startup (in my regular work repo with three worktrees
and ~30 submodules) from 860ms to 440ms, so almost a factor of two. If
you want to measure this in your own repo, here's a small throwaway
patch that you can use for that:

```diff
diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go
index 40ce86eae..fbb482f39 100644
--- a/pkg/gui/controllers/helpers/refresh_helper.go
+++ b/pkg/gui/controllers/helpers/refresh_helper.go
@@ -26,6 +26,11 @@ import (
 	"github.com/sasha-s/go-deadlock"
 )
 
+var (
+	applicationStartTime     = time.Now()
+	applicationStartTimeOnce sync.Once
+)
+
 type RefreshHelper struct {
 	c                    *HelperCommon
 	refsHelper           *RefsHelper
@@ -1213,6 +1218,10 @@ func (self *RefreshHelper) refreshFilesAndSubmodules(captured capturedFilesState
 	self.refreshView(self.c.Contexts().Submodules, env)
 	self.refreshView(self.c.Contexts().Files, env)
 
+	applicationStartTimeOnce.Do(func() {
+		self.c.Log.Infof("Time until first files refresh: %s", time.Since(applicationStartTime))
+	})
+
 	return nil
 }
 
```

To use it, run `./lazygit -l | grep "first files refresh"` in one
terminal, and `lazygit -d` in the one that you want to test. I'm curious
about your before/after measurements, feel free to post them below in
the comments.
2026-08-15 12:02:33 +02:00
Stefan HallerandClaude Opus 5 312a5f2cc1 Only react to focus reports that change whether we're 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>
2026-08-15 11:26:58 +02:00
Stefan HallerandClaude Opus 5 80ad2db71a Recognize worktrees among the files from the worktrees model
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>
2026-08-15 11:26:58 +02:00
Stefan Haller a1f5db6bce Add test for how we show a worktree that is inside the main work tree
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.
2026-08-15 11:26:58 +02:00
Stefan HallerandClaude Opus 5 62caf427ac Refresh the worktrees in their own scope again
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>
2026-08-15 11:18:23 +02:00