Stage, unstage and discard changes directly from the focused main view - #6035
Open
stefanhaller wants to merge 21 commits into
Open
stefanhaller wants to merge 21 commits into
stefanhaller wants to merge 21 commits into
Conversation
stefanhaller
added this pull request to stack #6027
September 20, 2026 08:24
stefanhaller
force-pushed
the
stage-changes-in-main-view
branch
from
September 20, 2026 17:51
5b19092 to
f4951ac
Compare
stefanhaller
force-pushed
the
stage-changes-in-main-view
branch
from
September 21, 2026 12:44
f4951ac to
eb1b3c1
Compare
stefanhaller
force-pushed
the
stage-changes-in-main-view
branch
2 times, most recently
from
September 25, 2026 09:32
0a18b18 to
98d5acd
Compare
stefanhaller
force-pushed
the
stage-changes-in-main-view
branch
from
September 25, 2026 10:14
98d5acd to
711814c
Compare
stefanhaller
force-pushed
the
stage-changes-in-main-view
branch
2 times, most recently
from
September 27, 2026 10:56
091f457 to
cc24858
Compare
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
Three cases where landing on the change that took the place of the one acted on is not the same as landing on the next line, or on the same line number: staging an inserted line moves every later line of the file, so the hunk below it is somewhere else afterwards; consecutive deletions all sit at the same place in the new file, so nothing but their order tells them apart; and unstaging half of a modification carries on in the pane the staged side has, which is where the work was already. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.
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>
Staging a selection walks the file's diff to find the lines the user pointed at, by where each of them sits in the file. The next commit needs the same answer for a single row, so pull the walk out of the staging path before there are two copies of it. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Editing a hunk points an editor at a patch we wrote to a temp file and waits. Nothing about the repo has changed when the editor returns: the change lands when the caller applies what came back. But the suspend path this went through refreshes as soon as the subprocess exits, so it reads the state from before the patch is applied and then races the caller's own refresh to publish it. Whichever lands last wins, and when it is the stale one the files panel goes on showing the file as it was before the edit until something else refreshes. The caller is the one that knows when there is something new to see, and both callers already refresh once they have applied the patch, so the refresh in the middle only ever had a wrong answer to give. Being a publish-order race, it doesn't reproduce reliably enough for a test; holding a background refresh between its read and its publish shows it every time. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The staging view can hand the hunk you are on to an editor and apply what comes back, which is how you stage something the diff cannot express: half of a changed line, or a change written differently from either side. The focused main view has to be able to do the same before that view can go. The hunk is git's own, context and all, rather than lazygit's block of adjacent changes: an editable patch is one that still applies, and the context lines are what let git place it. What the editor leaves behind is applied whole rather than matched against the file's diff again, the point being that it says something the diff didn't. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
stefanhaller
force-pushed
the
stage-changes-in-main-view
branch
from
September 27, 2026 16:27
cc24858 to
7f00059
Compare
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Seventh PR in the stack of PRs towards staging hunks directly from the main view. This one is stacked on #6034.
This is the change the series is building towards. Focus a file's diff and act on it there, without entering a separate staging panel.
In the files panel's focused main view:
spacestages the selected line, range or hunk from the upper pane, and unstages it from the lower one. Selections spanning several files work.ddiscards the selection. This works the same as in the staging panel: discarding from the staged pane unstages; discarding from the unstaged pane reverse-applies, with the usual confirmation.Eedits the selected hunk in your editor and applies what you save.After an action the selection advances to the next change, and the focus follows the lines into the other pane when the one you acted in goes away. If your selection covered every change of a file, the file itself is staged, so staging all of a deleted file's content gives you
Drather thanMD. Applying a file's entire diff to the index amounts togit addon the file.Renderers that can't be acted on fall back to git's own diff. Whether a renderer can be acted on is settled by running it on empty input and looking for the OSC 1717 handshake, and the verdict is cached per renderer signature. If a renderer restructures the diff but doesn't support OSC 1717, a raw diff replaces it the moment you focus the view, so staging always works; the same renderer keeps rendering the unfocused view as before.
rawGitentries are probed the same way, git announcing itself for exactly the formats it describes; this prepares us for a future git version that supports OSC 1717 for--word-diffor--color-words. Entries with no arguments are already raw and skip the probe.