Hide the focused main view's selection when there's no diff to act on

The focused main view always showed a selection while it held focus, even when
its content was a placeholder ("No changed files", a merge-conflict message)
rather than a diff — so pressing a navigation key conjured a highlighted line
over "No changed files", and discarding the last change (or changes vanishing
outside lazygit) left a stale selection behind.

A selection should only show when there are change lines to act on. Two
moments establish or change the content:

- Focus: showInitialDiffSelection now leaves the selection off when the view
  has no change lines (ViewHasChangeLines), rather than highlighting a stray
  line at the top.
- Render: the side panel's render-to-main is where it decides between a diff
  and a placeholder, so that's where the selection's visibility is set —
  updateFocusedMainViewSelectionVisibility shows it only on the focused pane
  and only when a diff is being rendered. This covers the refresh cases that
  focus can't: discarding the last change, and changes disappearing or
  reappearing outside lazygit, all hide or restore the selection on the next
  refresh. (Focusing reuses the already-rendered content rather than
  re-rendering, which is why focus needs its own check.)

Wired into the files panel, where placeholders occur; the commit panels always
render a diff. Adds a SelectionIsShown/SelectionIsHidden test assertion.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stefan Haller 2026-06-19 19:35:12 +02:00
parent 26dcab23ae
commit 252a26cbc4
7 changed files with 149 additions and 0 deletions

View file

@ -250,6 +250,11 @@ func (self *FilesController) GetOnRenderToMain() func() {
self.c.Helpers().Diff.WithDiffModeCheck(func() {
node := self.context().GetSelected()
// Default the focused-main-view selection off; the real-diff branch below
// turns it back on. Every other outcome (no file, merge conflict) renders
// non-diff content with nothing to select.
updateFocusedMainViewSelectionVisibility(self.c, false, false)
if node == nil {
self.renderToMainWithTask(types.NewRenderStringTask(self.c.Tr.NoChangedFiles))
return
@ -398,6 +403,11 @@ func (self *FilesController) renderWorkingTreeDiff(node *filetree.FileNode) {
}
}
// A real diff is being shown, so the focused main view has something to select:
// restore the selection on whichever pane is focused (the secondary only when
// the diff is split across both).
updateFocusedMainViewSelectionVisibility(self.c, true, split)
self.c.RenderToMainViews(refreshOpts)
}

View file

@ -81,6 +81,20 @@ func (self *StagingHelper) FirstChangeLineInView(view *gocui.View) (int, bool) {
return 0, false
}
// ViewHasChangeLines reports whether view's displayed diff contains any change line
// (an addition or deletion), i.e. whether there's anything to select. It's false when
// the main view shows a non-diff placeholder ("No changed files", a merge message) or a
// diff that is somehow all context — the cases where the focused main view should show
// no selection.
func (self *StagingHelper) ViewHasChangeLines(view *gocui.View) bool {
for _, resolved := range self.resolveDiffLines(view.DiffLineContents()) {
if resolved.ok && resolved.info.IsChange() {
return true
}
}
return false
}
// ChangeBlockBounds returns the view-line range [start, end] of the change block
// (lazygit's notion of a hunk; see AdjacentChangeBlock) to select when entering or
// moving in hunk mode in view's displayed diff. The block is the one containing

View file

@ -251,6 +251,12 @@ func (self *MainViewController) togglePanel() error {
func showInitialDiffSelection(c *ControllerCommon, mainContext *context.MainContext) {
resetDiffSelectMode(mainContext)
view := mainContext.GetView()
// Nothing to act on (the main view shows "No changed files" or another non-diff
// placeholder): show no selection at all rather than highlighting a stray line.
if !c.Helpers().Staging.ViewHasChangeLines(view) {
view.Highlight = false
return
}
target, ok := c.Helpers().Staging.FirstChangeLineInView(view)
if !ok {
showSelectionAtLine(view, view.OriginY(), true)
@ -264,6 +270,25 @@ func showInitialDiffSelection(c *ControllerCommon, mainContext *context.MainCont
showSelectionAtLine(view, target, true)
}
// updateFocusedMainViewSelectionVisibility shows or hides the focused-main-view selection
// to match what a side panel is rendering into the main view, called from the panel's
// render-to-main so the selection tracks content changes (a refresh after the last change
// is discarded, or changes vanishing / appearing outside lazygit). A selection is shown
// only on the main pane that currently holds focus, and only when it's rendering a diff
// (something to act on) — never over "No changed files" or a merge-conflict message.
// normalHasDiff/secondaryHasDiff say whether each pane is being given a diff; the caller
// knows this from which content it's about to render (the rendered content can't be read
// here, since the render it triggers is asynchronous). Initial keyboard/click focus is
// handled separately by showInitialDiffSelection, since focusing reuses the already-
// rendered content rather than re-rendering.
func updateFocusedMainViewSelectionVisibility(c *ControllerCommon, normalHasDiff bool, secondaryHasDiff bool) {
focusedKey := c.Context().CurrentStatic().GetKey()
normal := c.Contexts().Normal
secondary := c.Contexts().NormalSecondary
normal.GetView().Highlight = normalHasDiff && focusedKey == normal.GetKey()
secondary.GetView().Highlight = secondaryHasDiff && focusedKey == secondary.GetKey()
}
// resetDiffSelectMode returns the focused main view to its default select mode — a
// single line, no range — used whenever the selection is (re-)established from
// scratch (on focus, on a click). The view's range anchor is cleared too so the

View file

@ -367,6 +367,28 @@ func (self *ViewDriver) OriginYAtLeast(expected int) *ViewDriver {
return self
}
// SelectionIsShown asserts that the view is showing an active selection highlight.
func (self *ViewDriver) SelectionIsShown() *ViewDriver {
self.t.assertWithRetries(func() (bool, string) {
view := self.getView()
ok := view.Highlight && !view.HighlightInactive
return ok, fmt.Sprintf("%s: expected an active selection to be shown, but it wasn't", self.context)
})
return self
}
// SelectionIsHidden asserts that the view is showing no selection highlight (e.g. the
// focused main view over "No changed files", where there's nothing to select).
func (self *ViewDriver) SelectionIsHidden() *ViewDriver {
self.t.assertWithRetries(func() (bool, string) {
ok := !self.getView().Highlight
return ok, fmt.Sprintf("%s: expected no selection to be shown, but one was", self.context)
})
return self
}
// focus the view (assumes the view is a side-view)
func (self *ViewDriver) Focus() *ViewDriver {
viewName := self.getView().Name()

View file

@ -0,0 +1,43 @@
package staging
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var HideSelectionAfterDiscardingLastChange = NewIntegrationTest(NewIntegrationTestArgs{
Description: "After discarding the last change from the focused main view, the now-empty diff shows no selection",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {
config.GetUserConfig().Gui.UseHunkModeInStagingView = true
config.GetUserConfig().Gui.SkipDiscardChangeWarning = true
},
SetupRepo: func(shell *Shell) {
shell.CreateFileAndAdd("file1", "one\ntwo\nthree\n")
shell.Commit("one")
// A single working-tree change, so discarding it empties the diff entirely.
shell.UpdateFile("file1", "one\nTWO\nthree\n")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Files().
IsFocused().
Press(keys.Universal.FocusMainView)
t.Views().Main().
IsFocused().
SelectionIsShown().
SelectedLines(
Contains("-two"),
Contains("+TWO"),
).
Press(keys.Universal.Remove)
// The only change is gone, so the main view shows the placeholder with no
// lingering selection.
t.Views().Main().
Content(Contains("No changed files")).
SelectionIsHidden()
},
})

View file

@ -0,0 +1,33 @@
package staging
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var NoSelectionWhenNoChanges = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Focusing the main view when there are no changes shows no selection, and navigating doesn't conjure one",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
shell.EmptyCommit("one")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Files().
IsFocused().
IsEmpty().
Press(keys.Universal.FocusMainView)
// There's nothing to act on, so the focused main view shows the placeholder with
// no selection — and a navigation key just scrolls rather than conjuring one.
t.Views().Main().
IsFocused().
Content(Contains("No changed files")).
SelectionIsHidden().
Press(keys.Universal.GotoTop)
t.Views().Main().
SelectionIsHidden()
},
})

View file

@ -419,6 +419,8 @@ var tests = []*components.IntegrationTest{
staging.DiscardFromStagedMainView,
staging.FocusFollowsStagedSideToSecondaryAfterUnstaging,
staging.FocusReturnsToMainAfterUnstagingLastStagedHunk,
staging.HideSelectionAfterDiscardingLastChange,
staging.NoSelectionWhenNoChanges,
staging.Search,
staging.SelectHunkOnFocusingMainView,
staging.SelectNextChangeAfterUnstagingADeletion,