From 252a26cbc45d253e133227e626fd56af28b71ebb Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 19 Jun 2026 19:35:12 +0200 Subject: [PATCH] Hide the focused main view's selection when there's no diff to act on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- pkg/gui/controllers/files_controller.go | 10 +++++ .../helpers/diff_line_navigation.go | 14 ++++++ pkg/gui/controllers/main_view_controller.go | 25 +++++++++++ pkg/integration/components/view_driver.go | 22 ++++++++++ ..._selection_after_discarding_last_change.go | 43 +++++++++++++++++++ .../staging/no_selection_when_no_changes.go | 33 ++++++++++++++ pkg/integration/tests/test_list.go | 2 + 7 files changed, 149 insertions(+) create mode 100644 pkg/integration/tests/staging/hide_selection_after_discarding_last_change.go create mode 100644 pkg/integration/tests/staging/no_selection_when_no_changes.go diff --git a/pkg/gui/controllers/files_controller.go b/pkg/gui/controllers/files_controller.go index 10c480a0e..5a176db41 100644 --- a/pkg/gui/controllers/files_controller.go +++ b/pkg/gui/controllers/files_controller.go @@ -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) } diff --git a/pkg/gui/controllers/helpers/diff_line_navigation.go b/pkg/gui/controllers/helpers/diff_line_navigation.go index a448ceaa1..6b17643eb 100644 --- a/pkg/gui/controllers/helpers/diff_line_navigation.go +++ b/pkg/gui/controllers/helpers/diff_line_navigation.go @@ -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 diff --git a/pkg/gui/controllers/main_view_controller.go b/pkg/gui/controllers/main_view_controller.go index 71b3d24f4..3cadd118a 100644 --- a/pkg/gui/controllers/main_view_controller.go +++ b/pkg/gui/controllers/main_view_controller.go @@ -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 diff --git a/pkg/integration/components/view_driver.go b/pkg/integration/components/view_driver.go index 937c19b7b..d8b69cb1b 100644 --- a/pkg/integration/components/view_driver.go +++ b/pkg/integration/components/view_driver.go @@ -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() diff --git a/pkg/integration/tests/staging/hide_selection_after_discarding_last_change.go b/pkg/integration/tests/staging/hide_selection_after_discarding_last_change.go new file mode 100644 index 000000000..158c84d78 --- /dev/null +++ b/pkg/integration/tests/staging/hide_selection_after_discarding_last_change.go @@ -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() + }, +}) diff --git a/pkg/integration/tests/staging/no_selection_when_no_changes.go b/pkg/integration/tests/staging/no_selection_when_no_changes.go new file mode 100644 index 000000000..b1a74cc96 --- /dev/null +++ b/pkg/integration/tests/staging/no_selection_when_no_changes.go @@ -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() + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 8142f74b2..cab022e53 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -419,6 +419,8 @@ var tests = []*components.IntegrationTest{ staging.DiscardFromStagedMainView, staging.FocusFollowsStagedSideToSecondaryAfterUnstaging, staging.FocusReturnsToMainAfterUnstagingLastStagedHunk, + staging.HideSelectionAfterDiscardingLastChange, + staging.NoSelectionWhenNoChanges, staging.Search, staging.SelectHunkOnFocusingMainView, staging.SelectNextChangeAfterUnstagingADeletion,