Commit Graph
3468 Commits
Author SHA1 Message Date
Stefan HallerandClaude Opus 5 54465f6cbb Hold input back until the selection has moved on
Two space presses in quick succession only staged one hunk. Input is
withheld until the refresh has landed, which is enough where the diff is
rebuilt on the spot, but the main view re-renders asynchronously: the
selection only moves to the next change once that render is on screen, so
the second press acted on lines that were no longer in the diff, and
staged nothing.

So the wait is now for the selection to be where the work carries on from,
rather than for the model. A restore therefore has to say when it is done —
which it can be either way, since a view given a message rather than a
re-render now gives up the restore it was holding instead of leaving it to
claim some later render.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 17:07:53 +02:00
Stefan Haller ce8dc1dc21 Replay withheld keys on a later pass of the event loop
EndBlockingEvents dispatches the keys buffered during a block from inside its
own call, so a keybinding handler runs in the middle of whatever the caller was
doing. If a caller ends the block partway through updating the screen, that
handler acts on state the caller has yet to finish writing.

No caller does that today; both of them end their block from a UI-thread
callback of their own, with nothing left to do afterwards. The commit after this
one adds a caller that can. It ends the block from a render restore, and a
render of the two main panes resolves that restore halfway through laying the
panes out.

Queue the replay through Update instead, so the buffered keys arrive on a later
pass of the loop, as they would have if the user had pressed them then. Input
stays withheld until that pass runs. Gui events are dispatched in preference to
queued work, so a key pressed in between would otherwise be handled ahead of the
keys buffered before it.

The replay's error now reaches the error handler along with every other
handler's, and EndBlockingEvents has nothing left to return.
2026-10-01 17:07:53 +02:00
Stefan HallerandClaude Opus 5 8d1333656f Show a selection only over the diff the panel offers
Some merge conflicts can only be resolved by picking a side. The files
panel explains those rather than diffing them, and where one side deleted
the file and the other modified it, git's diff of that modification is
shown below the explanation. The focused main view took those change lines
for a diff of its own. It drew a selection over them and offered to stage
hunks of a file whose conflict staging can't resolve.

So have a render say whether it holds the diff the panel offers in the
main view, and put a selection only on one that does. Every diff render
already goes through NewMainViewDiffTask, so it says so for itself; the
custom patch preview, assembled as text rather than run as a command, says
so through NewMainViewDiffStringTask. Establishing a selection asks the
pane the same question rather than looking for change lines itself.

The selection has been wrong over this content since "Show a selection in
the focused main view" introduced it, and the fix belongs there. It lands
here instead because a render had no way to say what it holds until "Show
git's own diff when the renderer's can't be acted on" gave every diff
render one constructor to go through.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 17:07:53 +02:00
Stefan HallerandClaude Opus 5 74aa1a05ac Show git's own diff when the renderer's can't be acted on
A diff renderer may lay a diff out however it likes: line numbers in a
gutter, the +/- column replaced by colour, the two sides in columns. Once
it has, we can only tell which line of which file a row shows if the
renderer says so. Under a renderer that doesn't say, the main view holds
a diff that can be read but not staged, edited or copied from. That is no
good now that the main view is where you stage.

So focusing it brings git's own diff instead, and every re-render while it
stays focused keeps to that, so staging a hunk doesn't flip back. Browsing
is untouched: you see what the renderer produced until you focus the view
to act on it.

Whether the renderer says anything is settled by asking it rather than by
watching it work: run it on empty input and see whether it announces the
protocol. Announcing is a property of the renderer, so the answer is known
before we render anything, and a diff with no lines to describe can't fool
it. Watching would have to see a diff go by first, and a binary file's diff
holds nothing that would tell the two cases apart. The answer is remembered
until the renderer changes. git itself is asked the same question, with the
renderer's own arguments, since it announces itself for exactly the formats
whose output can't be read back as a diff.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 17:07:53 +02:00
Stefan HallerandClaude Opus 5 104ebaa612 Say what a diff command's output is for
Two things about a diff command follow from what its output is for: whether
the diff renderer produces it, and whether it is coloured. That was a
`plain` flag, which covers two of the three cases — the diff as configured,
and git's own uncoloured diff for building patches out of — and leaves no
room for the third, which is about to be needed: git's own diff, coloured,
for showing where the renderer's version of it can't be acted on.

So the flag becomes a mode. It also takes over deciding the colour, which
each command spelled out for itself, and it settles a question the flag
couldn't put: whether ignoring whitespace applies. It is about what the user
wants to see, so it holds for anything shown, and not for a diff a patch is
built from, which has to describe every change.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 17:07:53 +02:00
Stefan HallerandClaude Opus 5 473eb43e6c Commit and find a fixup base from the focused main view
Staging in the main view leaves you looking at the working tree's diff with
everything you meant to stage in the index — and, until now, having to go
back to the files panel to commit it. The commit keys, and the one that
finds the commit a fixup belongs to, are offered there too, so that the
whole staging-to-committing round happens in one place.

They act on the working tree, which the main view only sometimes shows, so
they do nothing over a commit's or a stash's diff — browsing history can't
commit by accident — and are listed only where they do something. The check
happens per press: what the main view is showing changes as the user moves
about, while the bindings are registered once.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 17:07:53 +02:00
Stefan HallerandClaude Opus 5 d8cd1aa215 Discard the selected diff lines from the focused main view
The remove key now works on a diff selection the way it does in the staging
view: on the unstaged side it throws the selected lines away, which it asks
about first, and on the staged side it takes them out of the index, which is
unstaging and needs no warning.

It goes through the same path as staging, so that everything around the
action behaves identically — the selection lands on the change that took the
place of the discarded one, and the focus follows the side it acted on.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 17:07:53 +02:00
Stefan HallerandClaude Opus 5 2eee18ea74 Carry the acted-on lines into the pane the work lands in
Each side of a file's diff has a pane of its own, but a pane is only shown
while its side has something in it: staging the last unstaged change takes
the upper pane away, and unstaging the last staged one takes the lower one
away. The focus follows into the pane that is left, and the selection has to
be waiting there when it arrives — on the lines just acted on, which is where
they are now, unless they were discarded rather than moved, in which case on
what is left of the file. The pane being moved to shows no selection until
the restore places one, so that the selection it was left with the last time
it was used doesn't appear for a frame.

Whether the acted-on side still holds anything is a question the model can't
answer: a refresh only queues its update, and by the time it lands the
re-render this has to ride is already under way. So the answer is worked out
from what we just did — the files we changed report whether the selection
covered all of their changes, and the ones it didn't touch are as the model
describes them.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 17:07:53 +02:00
Stefan HallerandClaude Opus 5 eddb1d5236 Leave a pane that only the permanent split keeps around
Configured to always split the diff, a pane is shown whether or not its side
of the file holds anything, so emptying the side the focus is on no longer
takes the pane away — but it does take away everything there was to do there,
which is the question the focus is really asking.

So the render says which of the panes it is giving something to act on,
rather than the focus reading that off which panes are shown.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 17:07:53 +02:00
Stefan HallerandClaude Opus 5 472aa1a9aa Stop a render restore saying whether it placed the view
Nothing has read that since the paint started settling the scroll position
before consulting the restore: what the answer was for was deciding whether
the reset the new content was owed still had to happen, and by then it has.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 17:07:53 +02:00
Stefan HallerandClaude Opus 5 9782c4abfd Follow the focus into the pane that is left
Each side of a file's diff has a pane of its own, and a pane is only shown
while its side has something in it. So anything that empties the side the
focus is on takes that pane away with it: committing what was staged, or a
commit or a discard happening outside lazygit and arriving with a refresh.
The focus was left on a pane that isn't there any more, where the next
keypress acted on nothing.

The render is what decides which panes are shown, so the question is asked
there, of every render rather than of the handful of actions that could think
to ask it themselves. The pane the focus moves into gets its selection once
that render has finished and there is something to put one on, the way
focusing it by hand would — and shows none until then, so that the selection
it was left with the last time it was used doesn't appear for a frame.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 17:07:53 +02:00
Stefan HallerandClaude Opus 5 7df7bc6276 Give the focused main view's selection a home outside the controllers
Where the selection starts out, and how it widens to a whole change block,
are questions about what the view is showing — the same rendered diff the
queries next door read. Nothing about them belongs to a keybinding, and the
render funnel is about to need them too, from a layer that can reach a helper
but not a controller.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 17:07:53 +02:00
Stefan HallerandClaude Opus 5 4146c52f4f Carry the selection to the next change after staging
Staging takes the lines it acted on out of the diff, so the selection has
nothing to sit on afterwards and would be left wherever those lines used to
be. What the user wants is the change that moved up into their place, so
that pressing the key again goes on to the next one — which is how staging
line by line through a file works.

The line acted on is gone, so it can't be remembered by identity the way a
re-render of the same diff remembers one; what is remembered instead is its
place in the sequence of the diff's changes, which the change after it
inherits. Staging the last change is the one case with nothing to inherit
it, and there the selection stays on the last change there is.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 17:07:53 +02:00
Stefan HallerandClaude Opus 5 a677104871 Extract the shared core of the diff-line restores
Putting a view back on a remembered line as it re-renders is two things: the
plumbing that watches the content arrive, reveals it at the right moment and
places the view, and the search that says which row to land on. Only the
second is specific to what is being remembered, and a second kind of it is
about to arrive — the change line an action leaves the selection on, which
is found by counting rather than by identity.

Behaviour-preserving.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 17:07:52 +02:00
Stefan HallerandClaude Opus 5 1d5ff34a71 Act on a whole file's changes as acting on the file
The diff of a deleted file is its content going away, so putting every
line of it into the index leaves an empty file there (modified in the
index, deleted in the working tree) rather than the deletion the user
selected. The reverse case matches: the diff of an added file is its
whole content, and taking all of it back out of the index leaves the file
tracked and empty rather than untracked again. In the staging view you
had to enter such a file deliberately to reach these cases, but stepping
through a directory's diff hunk by hunk runs into them routinely.

Selecting every change of a file says "this file", so stage or unstage
the file itself. This applies to any file, since applying a file's whole
diff amounts to the same thing everywhere else.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 17:07:52 +02:00
Stefan HallerandClaude Opus 5 8e0a02a3b8 Stage and unstage diff lines from the focused main view
Space in the focused main view now acts on the selected lines the way it
does in the staging view: over the working tree's unstaged changes it puts
them into the index, and over the staged ones it takes them back out. Which
of the two it does is a property of the pane, each side of the diff having
one of its own. Nothing has to be entered first, and a selection reaching
across several files of a directory's diff is applied as one patch per file.

The rows on screen are only a picture of the diff, so the patch is built
from the diff itself: each selected row's identity — which file, which line
of it, and whether it is a deletion — is looked for in the file's own diff,
and the lines that match are the ones the patch includes. Matching by
position rather than by counting rows is what tells the two halves of a
modified line apart, since the deletion and the addition replacing it sit at
the same place in the new file.

Panels other than the working tree offer no action on their diff yet, so
space says nothing and does nothing there.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 17:07:52 +02:00
Stefan HallerandClaude Opus 5 38945aa11e Leave a command with nothing to say out of the options bar
A command that acts on a diff selection has nothing to say over content that
isn't a diff, and says so by describing itself as nothing — but a binding
that is displayed on screen is displayed whatever its description, so it
would show as a key with an empty label. Leave it out instead.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 17:07:52 +02:00
Stefan HallerandClaude Opus 5 b6fa82c7ac Copy the selected diff lines from the focused main view
The main view shows a diff renderer's picture of a diff, and that is not
what you want on your clipboard: a renderer may drop the +/- column, move
the line numbers into a gutter, group a hunk's deletions before its
additions, or leave lines out altogether. So copying takes the lines from
the diff itself, locating them by the identity of the selected rows.

Only the panel that produced the diff can produce it again, though: the
working tree's staged or unstaged side, a commit's, a stash entry's. The
new seam is there for that. It asks per file, so that copying three lines
of a commit's diff doesn't fetch the whole of it, and it will grow the
actions on a selection as staging and patch building arrive.

The clipboard gets the run of diff lines from the first selected line of
a file to the last, so that lines the renderer hid come along and the
result still reads as a diff. Headers count as selected lines too. A hunk
header names the first line of its hunk, so it is looked for the way a
line of the file is. A file header names no line at all, and a rendering
may draw it over any number of rows, so a selection touching one of them
takes the whole header.

As in the patch explorers, a selection that is all additions or all
deletions loses its +/- column, ready to be pasted into code. One that
reaches into a header keeps the columns, and what comes out is a patch
fragment rather than lines of code.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 17:07:52 +02:00
Stefan HallerandClaude Opus 5 1cd8a1e7c2 Share how a ref's diff endpoints are derived
The commit files context works out the two ends to diff from the ref (or
range of refs) it was entered for. The panels that hold those refs
themselves — commits, sub-commits, stash, reflog — are about to need the
same two ends, to hand out the diff behind what they render into the
focused main view. Pull the derivation out of the context so they can ask
for it rather than each spelling out the parent-of-from rule again.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 17:07:52 +02:00
Stefan HallerandClaude Opus 5 937f8f09db Always show a file's staged changes in the lower pane
Which pane a side of a file's diff appeared in depended on what else the file
had: the staged side had the lower pane while there were unstaged changes
above it, and took over the upper one when there weren't. So the same content
moved about, and which side a pane was showing was something the code had to
work out from the file's status rather than knowing from the pane.

Now each side has a pane of its own — unstaged above, staged below — shown
when there is something on it. A file with nothing unstaged shows its staged
changes in the lower pane alone, which then has the whole space, including
the label of the key that focuses it.

Nothing about this is visible to the user: the same diff appears in the same
place, with the same title and the same label, and the same keys focus and
scroll it. What it is for is the code, which no longer has to ask the file
what a pane is showing — and, once the diff can be staged from, no longer has
to make one key mean opposite things in the same pane.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 17:07:52 +02:00
Stefan HallerandClaude Opus 5 0dfd7ec1e4 Let the main section show the secondary pane alone
The main section shows one pane or two, which was enough while the second
pane only ever accompanied the first. It is about to have to show the second
one by itself: the working tree's staged changes are moving there for good,
and a file with nothing but staged changes has only that side to show — it
should have the whole section rather than sit under an empty pane.

So which panes are shown becomes a three-way answer, derived from which of
them the render has content for.

Behaviour-preserving: nothing renders into the secondary pane alone yet.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 17:07:52 +02:00
Stefan HallerandClaude Opus 5 6b51922bb9 Let an emptied main pane forget what it was showing
A pane that has been emptied is showing nothing, but it kept the scroll
position it was left at and went on claiming the render it used to show,
so the next render into it — the same command's output, the file it was
showing being selected again — counted as content the view already had,
and was revealed partway down.

So say what the empty pane is: at the top, and showing nothing that a
render can be a re-render of.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 17:07:52 +02:00
Stefan HallerandClaude Opus 5 8f25e160e8 Keep both ends of a selected range across a re-render
A range or hunk selection covers a stretch of the diff, and restoring only
the cursor left the other end pointing at whatever line the new rendering
happened to put there — with more context lines above, a selected hunk
would grow a tail of context it never covered.

So the other end is remembered by identity too, and put back before the
cursor. If it is a line the re-render dropped, the selection is left as the
single line we landed on: there is no telling which line inherits a
selection's edge, and a wrong guess acts on lines the user never chose.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 17:07:52 +02:00
Stefan HallerandClaude Opus 5 b49aaef0db Index a rendering by the diff lines it shows
Prep: pull the index out of the candidate search, so that looking up a
single diff line doesn't have to phrase itself as a search for the nearest
survivor among one candidate.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 17:07:52 +02:00
Stefan HallerandClaude Opus 5 cb040e3532 Keep your place in the diff when ignoring whitespace
Ignoring whitespace is how you ask whether what you are looking at is more
than reindentation, so being dropped at the top of the diff is a poor
answer: you have to find your way back to the change you were asking about
to see what became of it.

Unlike the other ways of re-rendering a diff, this one can take the line
you were on away for good, along with the hunk or even the file it was in.
When it does, the view lands on the nearest line the diff kept, wherever in
it that is — the walk of fallbacks doesn't stop at the file's edge, since a
file of nothing but reindentation leaves nothing nearer to land on. And
when ignoring whitespace empties the diff altogether, there is nothing to
keep and the view simply shows what is left.

A whitespace-only change that shares a hunk with a real one is a happier
case: it is shown as a context line rather than as a change, but it is
still the same line of the same file, so we stay on it.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 17:07:52 +02:00
Stefan HallerandClaude Opus 5 8aa1d93089 Keep your place in the diff when switching diff renderers
Cycling through the diff renderers is for comparing how they show the same
change, which is hard to do from the top of the diff each time. The line
you were on is the same line of the same file whichever renderer draws it,
so the restore finds it again — by the records a renderer states, where it
speaks the protocol, and by parsing its output as a diff otherwise.

The restore sits in DiffHelper.RenderToMainAgain. A switch between a
dark and a light terminal background renders the diff again through the
same helper, so it keeps your place as well.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 17:07:52 +02:00
Stefan HallerandClaude Opus 5 60666bdc30 Keep your place in the diff when changing the context size
Pressing { or } re-rendered the diff with less or more context around each
change and dropped you back at the top of it, so finding your way back to
what you were reading was on you.

Remember where the view is before triggering the re-render, and have the
restore put it back there. The line to remember is the selected one when a
selection is showing, and the middle visible line otherwise — what you are
looking at, rather than the view's top edge. It is remembered by identity,
because the new rendering puts it on a different line of the view, and it
is found again by the records a diff renderer states for its rows, or by
parsing the rendering as a diff where it still looks like one.

The line may not be there at all afterwards: shrinking the context takes
context lines away. So the lines around it come along as fallbacks, nearest
first, and the view lands on the nearest one that survived, put back on the
screen row it was on — leaving what you were reading where it was, give or
take the line that went. The walk that collects them covers the whole diff
rather than stopping at the nearest change on either side: those always
survive a context-size change, but not everything a re-render can do to a
diff is that considerate.

Under a diff renderer that says nothing about its rows there is nothing to
look for at all, and the view is left at the offset it had instead: often not
the line it was on, but always closer to it than the top of the diff. That is
what the re-render asks for besides the restore, since it is a different
command from the one that produced what is on screen and would otherwise be
taken for a diff the user has never seen.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 17:07:52 +02:00
Stefan HallerandClaude Opus 5 6ace682e50 Follow the selection with the search in the focused main view
Search the diff, then move the selection with the arrow keys, and
pressing "n" goes to the match after the one the search was last on
rather than to the one after the selection. So walking through the diff
and then picking up the search where you got to takes as many presses as
there are matches behind you.

List views and the staging view keep the current match in step with their
selection; do the same here.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 17:07:52 +02:00
Stefan HallerandClaude Opus 5 8c51a920b2 Draw the diff selection as a narrow bar rather than across the line
Now that a diff view always carries a selection, the highlight fights the
diff itself for the line's colours. Painting the selection across the whole
line takes over the background, and a selected hunk becomes one solid block
with no boundary between what was removed and what replaced it — the more
lines you select, the less you can read.

This isn't specific to renderers like delta that say which side of the diff
a line is on by colouring its background, though they suffer most: git's own
output puts red and green text on that background, which reads badly too.
Since there is no rendering of a diff that a full-width highlight doesn't
degrade, there is nothing here worth configuring — the bar is simply what
diff views use.

Left edge only, and two columns wide: every convention for marking a row of
coloured content — change bars, diff gutters, selection gutters — puts the
marker on the left, and bracketing both edges reads as framing instead.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 17:07:52 +02:00
Stefan HallerandClaude Opus 5 cfde175e8a Edit the selected line of a diff from the focused main view
Reading a diff is often how you notice something to fix, and the file and
line are right there in front of you — so pressing edit opens the file at
the line under the selection, as it does in the staging view.

The line number the diff shows is the line number in the version the diff
is of, which for a commit's diff is not where that line sits today, so it
is carried forward the same way clicking a diff-renderer hyperlink already
does. A file header names no line, so it just opens the file.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 17:07:52 +02:00
Stefan HallerandClaude Opus 5 cfb88005c8 Jump by hunk and by file in the focused main view
Reading a diff of any size means moving in bigger steps than a line at a
time, which the staging view offers and the main view didn't. So the same
hunk keys work here, and n / N step from file to file — worth having only
here, since a diff spanning several files is something the staging view
never showed.

Where "the next file" begins isn't in the text: a diff renderer may print
whatever it likes above a file's content. So navigation lands on the first
row that states which file it belongs to, which is the file's header when
the source says so and its first content line otherwise. The anchor's own
file is found by scanning down rather than to the nearest row either way —
having just landed on a header, the nearest row above belongs to the file
we came from, and taking it would send the next press back where we
started.

A file you go to is brought to the top of the view, since the file is what
you went there for and the more of it is on screen the better. That only
applies where the view has to scroll at all: a file already on screen
leaves it where it is. In hunk mode what ends up selected is the file's
first change rather than the row the file begins at, and a large context
size can put that change further down than a screenful; the selection is
scrolled into view afterwards as any other jump's is, and the alignment
gives way where the two can't both hold.

The diff loads lazily, so a target below the loaded portion isn't there to
be found; rather than doing nothing, we read the rest in and look again.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 17:07:52 +02:00
Stefan HallerandClaude Opus 5 a5e72581f9 Select a range of diff lines by dragging
Selecting a range with the mouse is the obvious gesture once clicking
selects a line, and it's the only way to get a range without knowing the
keybindings.

The range has to be anchored where the mouse went down, which the view
can't tell us: a click in hunk mode selects a whole block, leaving the
view's own range anchor at the block's far end, so a drag from there would
grow the selection from the wrong end. So the clicked line is remembered
when the click happens, and the drag anchors there.

A drag that reaches the edge of the view keeps scrolling, using the same
autoscroller the staging view does — mouse capture means the pointer can be
dragged past the edge, and there is more diff down there than fits on
screen. Unlike the staging view, whose content is a string that is always
there in full, this diff loads lazily, so scrolling down has to keep
reading it in or the autoscroll would stop at the loaded edge.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 16:56:58 +02:00
Stefan HallerandClaude Opus 5 11a899852c Select a whole change block when focusing the main view in hunk mode
Users who set hunk mode as their default get it when entering the staging
view, and the focused main view is on its way to replacing that view, so it
has to behave the same: focusing selects a whole change block, and clicking
a change line selects that line's block, ready to act on. A click on a
context line still selects only that line, since the click points at it
precisely — you may well want to edit it. A click inside the block that is
already selected does the same, giving hunk mode up and going back to line
by line. That keeps a single line reachable with the mouse in a block too
long to see the end of, and matches how a click inside a range selection
already collapses it.

The block offered up is the first one that begins on screen, so that its
whole extent can be seen before acting on it. Only when none does — a change
too long to fit on the screen — is the block that reaches into the view from
above taken instead, and the selection then extends to its first line off
screen: focusing a diff must not move it, so nothing here scrolls.

The staging view makes one exception, and so must we, or a file that is one
solid block of changes (a file you just created, or deleted) would come up
with all of it selected. The staging view asks the parsed patch; we ask the
rendered diff the same question, which needs no second git invocation and
works over a whole commit's diff, where the answer differs per file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 16:56:58 +02:00
Stefan HallerandClaude Opus 5 83599e143b Make selecting a change block reachable without a controller
Focusing the main view is about to establish a hunk selection, and that
happens in a free function shared with the controller that focuses the
main view from a side panel, which has no main view controller to hand.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 16:56:58 +02:00
Stefan HallerandClaude Opus 5 ad95b633eb Let a main pane be named without the context package
The mode of a diff view's selection lives on the context of the pane showing
it, and until now only the controllers that drive the selection needed to
name that pane, all of which have it as the concrete context it is.

A side panel is about to be handed the pane a command was invoked in, so
that it can act on what is selected there — and it can't be handed a
context, the interface it is handed one through being declared where a
context's concrete type isn't available. So the state moves to where such an
interface can speak of it, and the panes gain one: a context that has a diff
selection.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 16:56:58 +02:00
Stefan HallerandClaude Opus 5 6a6683729f Show a selection in the focused main view
The focused main view could be scrolled but not pointed at: there was no
way to say "this line" or "these lines", which is what every line-level
interaction needs — editing a line, copying part of a diff, and, later,
staging. So a diff main view now always carries a selection while focused,
starting at the first change already on screen so that focusing doesn't
move the view.

The mode of the selection lives on the main context (a single line, a
range from a fixed anchor, or the change block around the cursor); the
selected line and the range anchor stay in the view itself, whose native
range select draws them, so there is no new highlight machinery. The modes
and their keys are the ones the staging view has, including that the arrow
keys step from block to block in hunk mode.

A pane holds something to select only when what it is showing is a diff
with changes in it, which the render works out once its content is final: a
placeholder message is not a diff, and neither is a diff with nothing in it
at all, such as a binary file's or an empty commit's. Whether a selection
is then drawn there follows from the context stack, as it does for every
other view.

A click in the focused main view now has a meaning again: it selects the
line it points at.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 16:56:57 +02:00
Stefan HallerandClaude Opus 5 ad3a337675 Take the focused main view's context by its concrete type
Both callers pass a main context, and focusing one is about to need more
of it than the Context interface offers. Saying so in the signature also
retires the type assertion that was there only to reach ClearSearchString.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 16:56:57 +02:00
Stefan HallerandClaude Opus 5 50d9900b72 Remove the plumbing for clicking the focused main view
With both implementations gone, nothing is left that lets a side panel
handle a click in the focused main view, so the mechanism for attaching
one to a context can go as well.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 16:56:57 +02:00
Stefan HallerandClaude Opus 5 ba3643149a Stop diving into a patch explorer when clicking the focused main view
Clicking a line of the focused main view entered the staging or patch
building panel at that line. The focused main view is about to gain a
selection of its own, which is what a click there should set — and with
the explorers on their way out, the dive has nowhere to go.

Nothing replaces the gesture yet, so for the next couple of commits a
click in the focused main view does nothing.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 16:56:57 +02:00
Stefan HallerandClaude Opus 5 75d153edf5 Classify which side panels show a diff in their main view
The focused main view is about to show a selection, but only where there
are diff lines to act on: a branch's commit log or the status dashboard
has nothing to select. Rather than have the main view guess from the
rendered content, let the side panels say so, since each of them knows
what it renders.

The classification is finer than a yes/no because acting on a selection
means different things per panel — staging into the working tree for the
files panel, taking lines into a custom patch for the commit panels — and
those actions want the same one answer as this. Only whether a panel
shows a diff at all is read for now.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 16:56:57 +02:00
Stefan HallerandClaude Opus 5 46bd2ddb23 Fold ViewSelectionController into MainViewController
The focused main view is about to get a real diff selection, which makes
its up/down/page/top/bottom keys mode-aware: what they do depends on the
select state the main view controller owns. Keeping them in a separate
controller would mean either duplicating that state or reaching across
controllers for it, so move them to where the state will live. The two
controllers were attached to the same pair of contexts and nothing else,
so nothing else can notice.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 16:56:57 +02:00
Stefan HallerandClaude Opus 5 ea6d77c3e6 Ask diff renderers for OSC 1717 metadata
A renderer that speaks the protocol emits nothing unless it is asked to,
so that its output stays plain wherever it is used outside lazygit. The
variable names the versions we understand.

git is one of the renderers we ask. It has no pager to spawn, and so no
terminal to spawn one in, but it still renders the diff itself — for the
word-diff formats, whose markup nothing else could resolve — so the
request has to be made before we decide a pty isn't needed.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 16:56:57 +02:00
Stefan HallerandClaude Opus 5 eeaf94c79e Take a diff renderer at its word about a diff line
Where a renderer states which line of which file it is rendering, take
that as the line's identity. The renderer knows, and for a rendering
that no longer looks like a diff nothing else does. Reading the rendered
text stays the way for the renderings that keep a diff's structure, and
for the renderers that say nothing at all.

Which of the two is used is settled for the rendering as a whole, not
row by row. A rendering with records is not parsed at all, not even for
the rows the renderer says nothing about. Its text is no unified diff,
and a row of it can read like one. Under delta, a commit that adds a
test whose input is a diff shows the test's "diff --git a/img.png
b/img.png" line as a row of its own; parsed, that row opens a file
section that runs to the end of the rendering, and every row delta puts
between hunks and files comes out as a header of a file called img.png.
So the untagged rows of such a rendering stay unresolved, as the
protocol has it (spec section 6.4).

Header records are accepted too. This lets a renderer point at a file
with no content lines, such as a pure rename, a mode change or a binary
file: for those files the header is the only row in the diff.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-01 16:56:57 +02:00
Stefan HallerandClaude Opus 5 828c73bad4 Rename parsedDiffLine.RelPath to Path
A diff line's identity is about to become recoverable from a second
source, a diff renderer's own records, and a renderer states the path
however it likes — absolute paths included. The field can't promise
repo-relative any more.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 16:56:57 +02:00
Stefan HallerandClaude Opus 5 d3acac38c4 Read the OSC 1717 records a diff renderer emits
A diff renderer that restructures the diff — into columns, or with the
+/- markers replaced by colour — leaves us no way to tell which line of
which file a rendered row came from, which is what acting on the row
requires. The OSC 1717 protocol has the renderer say so directly: it
prefixes each line it renders with a record naming the file and the
line's position in the old and new versions of it.

Attach each record to the cells it precedes, so that a row's records
survive wrapping and the columns of a side-by-side rendering, and hand
them to readers together with the row's text: the two have to describe
the same buffer, and a re-render can rebuild it between two reads.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 16:56:57 +02:00
Stefan HallerandClaude Opus 5 36755f07f1 Decode the path of a diff header the way git writes it
git doesn't just print the path: it terminates it with a tab when the
path contains a space, and C-quotes the whole field when the path
contains anything it won't print raw — with core.quotePath, which is on
by default, that means any non-ASCII byte, so a file called café shows
up as "b/caf\303\251".

Taking the field verbatim therefore gives a path that doesn't exist, for
a whole class of perfectly ordinary file names. The quoting is Go's own
string syntax, octal escapes and all, so decoding it is a call to
strconv.Unquote.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 16:56:57 +02:00
Stefan HallerandClaude Opus 5 1cf479d651 Recover the identity of a diff line from the rendered diff
To act on the line the user is pointing at in a diff view — to stage it,
to open it in an editor, to keep the cursor on it while the diff is
regenerated — we need to know which file it belongs to and where it sits
in that file. Only the diff the view was rendered from knows that, and
by the time it's on screen all we have is text.

So parse it back: the view's contents are (usually) a unified diff, and
running them through the patch parser recovers each row's file, kind and
line numbers. "Usually" is why this goes behind a seam, and why it
answers "I don't know" rather than guessing: a diff renderer is free to
restructure what it prints, and a wrong answer here means acting on the
wrong line of the wrong file.

Parsing a whole buffer is a separate entry point from parsing a single
line, because a caller resolving every row of a large diff must not
re-parse a file's section once per line of it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 16:56:57 +02:00
Stefan HallerandClaude Opus 5.5 0afb94e97b Lay git's own diff out to the width the layout gives the view
Changing the screen mode renders the main view again, and so does
anything else that changes its size along with what it shows. With git's
own diff, that render laid a commit's diffstat out to the width the view
had before. The graph then wrapped onto rows of its own in a view that
got narrower, and stopped short in one that got wider.

A diff renderer's render is created after the layout pass, since only
the layout settles the view's size, and takes its width there. git's own
diff took its width straight away instead, when the render was asked
for. Create its task after the layout as well.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 16:50:48 +02:00
Stefan HallerandClaude Opus 5.5 09b53e9a85 Allow pushing a range selection of tags
Tags can only be pushed one at a time. Let the push command of the tags
panel work on a range selection of tags too. It asks for one remote and
pushes all selected tags to it with a single git command.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 11:19:25 +02:00
Stefan HallerandClaude Opus 5.5 3080747fba Let the command for pushing tags take several tags
This lets pushing a range selection of tags push them all with a single
git command.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 11:19:25 +02:00