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>
This commit is contained in:
Stefan Haller
2026-10-01 17:07:53 +02:00
co-authored by Claude Opus 5
parent 472aa1a9aa
commit eddb1d5236
5 changed files with 93 additions and 30 deletions
+8 -6
View File
@@ -386,18 +386,20 @@ func (self *FilesController) renderWorkingTreeDiff(node *filetree.FileNode) {
if showUnstaged {
cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, false, false, paths)
refreshOpts.Main = &types.ViewUpdateOpts{
Task: types.NewRunDiffRendererTask(cmdObj.GetCmd()),
SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(),
Title: self.c.Tr.UnstagedChanges,
Task: types.NewRunDiffRendererTask(cmdObj.GetCmd()),
SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(),
Title: self.c.Tr.UnstagedChanges,
NothingToActOn: !node.GetHasUnstagedChanges(),
}
}
if showStaged {
cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, false, true, paths)
refreshOpts.Secondary = &types.ViewUpdateOpts{
Task: types.NewRunDiffRendererTask(cmdObj.GetCmd()),
SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(),
Title: self.c.Tr.StagedChanges,
Task: types.NewRunDiffRendererTask(cmdObj.GetCmd()),
SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(),
Title: self.c.Tr.StagedChanges,
NothingToActOn: !node.GetHasStagedChanges(),
}
}
+26 -24
View File
@@ -113,7 +113,7 @@ func (gui *Gui) refreshMainViews(opts types.RefreshMainOpts) {
// Before the render is triggered, so that the pane the focus moves into can be
// told where to put its selection as it renders.
gui.followFocusIntoShownPane(opts.Pair, panes)
gui.followFocusIntoWorkablePane(opts)
gui.moveMainContextPairToTop(opts.Pair)
@@ -191,38 +191,38 @@ func mainPanesFor(opts types.RefreshMainOpts) types.MainPanes {
}
}
// followFocusIntoShownPane moves the focus out of a main pane that the render about to
// happen leaves nothing in, and into the one it does.
// followFocusIntoWorkablePane moves the focus out of a main pane that the render about
// to happen leaves nothing to work on, and into the one it does.
//
// 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: staging the last unstaged change, committing what was
// Each side of a file's diff has a pane of its own, and a pane holds something only
// while its side of the file does. So anything that empties the side the focus is on
// leaves that pane with nothing: staging the last unstaged change, committing what was
// staged, or either of those happening outside lazygit and arriving with a refresh.
// Left where it was, the focus would be on a pane that isn't there, and the next
// keypress would act on nothing.
// Usually the pane goes away with its content; configured to always split the diff it
// stays, empty. Either way the focus has nothing left to act on where it is.
//
// The pane moved into gets its selection once the render has finished and there is
// something to put one on, 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. A pane that has
// already been told where to put its selection — by the action that caused all this —
// keeps what it was told.
func (gui *Gui) followFocusIntoShownPane(pair types.MainContextPair, panes types.MainPanes) {
func (gui *Gui) followFocusIntoWorkablePane(opts types.RefreshMainOpts) {
// The focused main view's two panes only: the staging and patch-building views
// arrange theirs for themselves, and the merge-conflicts view has just the one.
if pair.Main.GetKey() != context.NORMAL_MAIN_CONTEXT_KEY {
if opts.Pair.Main.GetKey() != context.NORMAL_MAIN_CONTEXT_KEY {
return
}
current := gui.State.ContextMgr.CurrentStatic().GetKey()
if current != pair.Main.GetKey() && current != pair.Secondary.GetKey() {
if current != opts.Pair.Main.GetKey() && current != opts.Pair.Secondary.GetKey() {
return
}
shown := onlyShownPane(pair, panes)
if shown == nil || shown.GetKey() == current {
pane := onlyWorkablePane(opts)
if pane == nil || pane.GetKey() == current {
return
}
target := gui.mainContextForView(shown.GetView())
target := gui.mainContextForView(pane.GetView())
target.SetHasSelectableContent(false)
gui.State.ContextMgr.UpdateSelectionHighlights()
if manager := gui.getManager(target.GetView()); !manager.HasRestoreForNextTask() {
@@ -240,18 +240,20 @@ func (gui *Gui) followFocusIntoShownPane(pair types.MainContextPair, panes types
gui.State.ContextMgr.Push(target, types.OnFocusOpts{})
}
// onlyShownPane returns the main pane a render leaves showing on its own, or nil when
// it leaves both showing.
func onlyShownPane(pair types.MainContextPair, panes types.MainPanes) types.Context {
switch panes {
case types.MainPaneOnly:
return pair.Main
case types.SecondaryPaneOnly:
return pair.Secondary
case types.BothMainPanes:
// onlyWorkablePane returns the main pane a render leaves as the only one worth having
// the focus in, or nil when that is true of both of them or of neither. Being shown is
// not the same as being worth working in: a pane the layout keeps around for the sake
// of always splitting the diff shows an empty side of the file.
func onlyWorkablePane(opts types.RefreshMainOpts) types.Context {
main := opts.Main != nil && !opts.Main.NothingToActOn
secondary := opts.Secondary != nil && !opts.Secondary.NothingToActOn
if main == secondary {
return nil
}
return nil
if main {
return opts.Pair.Main
}
return opts.Pair.Secondary
}
// clampDiffSelectionToContent brings the focused main view's selection back onto the
+5
View File
@@ -38,6 +38,11 @@ type ViewUpdateOpts struct {
SubTitle string
Task UpdateTask
// NothingToActOn marks a pane that is being shown only because the layout is
// configured to always split the diff: its side of the file holds nothing, so it
// is not a pane to leave the focus in.
NothingToActOn bool
}
type RefreshMainOpts struct {
@@ -0,0 +1,53 @@
package main_view
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var FocusLeavesAnAlwaysSplitEmptyPane = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Configured to always split the diff, the emptied pane stays but the focus still leaves it",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(cfg *config.AppConfig) {
cfg.GetUserConfig().Gui.UseHunkModeInStagingView = false
cfg.GetUserConfig().Gui.SplitDiff = "always"
},
SetupRepo: func(shell *Shell) {
shell.CreateFileAndAdd("file1", "one\ntwo\nthree\nfour\nfive\n")
shell.Commit("one")
shell.UpdateFileAndAdd("file1", "one\nSTAGED\ntwo\nthree\nfour\nfive\n")
shell.UpdateFile("file1", "one\nSTAGED\ntwo\nthree\nUNSTAGED\nfour\nfive\n")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Files().
IsFocused().
Press(keys.Universal.FocusMainView)
t.Views().Main().
IsFocused().
Press(keys.Universal.TogglePanel)
t.Views().Secondary().
IsFocused().
SelectedLines(
Contains("+STAGED"),
)
t.Shell().Commit("two")
t.GlobalPress(keys.Universal.Refresh)
// The staged side is empty now, but its pane is still shown because the split
// is configured as permanent. There is nothing left to act on in it, so the
// focus goes where there is.
t.Views().Secondary().
IsVisible().
Content(DoesNotContain("STAGED"))
t.Views().Main().
IsFocused().
SelectedLines(
Contains("+UNSTAGED"),
)
},
})
+1
View File
@@ -377,6 +377,7 @@ var tests = []*components.IntegrationTest{
main_view.FileNavigationScrollsToTheTop,
main_view.FocusFollowsAPaneEmptiedFromOutside,
main_view.FocusFollowsIntoAPaneTakingOver,
main_view.FocusLeavesAnAlwaysSplitEmptyPane,
main_view.HideSelectionWhenChangesVanish,
main_view.KeepAWrappedLineCoveredAcrossARerender,
main_view.KeepBothHalvesOfAChangeSelected,