From 1b2bd85fc5adaaaa7fa3e071f670ade0ebb10a9e Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 21 Aug 2026 13:18:36 +0200 Subject: [PATCH 1/8] Let a test assert how a view draws its selection The selected line of a view says nothing about whether a selection is drawn over it, or which of the two ways it is drawn in, and those are what the tests coming up are about. Co-authored-by: Claude Opus 5 (1M context) --- pkg/integration/components/view_driver.go | 36 +++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/pkg/integration/components/view_driver.go b/pkg/integration/components/view_driver.go index 102a8562a..dfae58c4b 100644 --- a/pkg/integration/components/view_driver.go +++ b/pkg/integration/components/view_driver.go @@ -360,6 +360,42 @@ func (self *ViewDriver) Content(matcher *TextMatcher) *ViewDriver { return self } +// SelectionIsActive asserts that the view draws its selection as the one the user +// is working in. These three assertions read the highlight flags rather than the +// selected lines, which say nothing about whether the selection is drawn at all. +func (self *ViewDriver) SelectionIsActive() *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 +} + +// SelectionIsInactive asserts that the view draws its selection dimmed, as a panel +// does while the focus is somewhere else. +func (self *ViewDriver) SelectionIsInactive() *ViewDriver { + self.t.assertWithRetries(func() (bool, string) { + view := self.getView() + ok := view.Highlight && view.HighlightInactive + return ok, fmt.Sprintf("%s: expected an inactive selection to be shown, but it wasn't", self.context) + }) + + return self +} + +// SelectionIsHidden asserts that the view draws no selection at all, e.g. a list +// with nothing in it, where there is 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 +} + // asserts on the selected line of the view. If you are selecting a range, // you should use the SelectedLines method instead. func (self *ViewDriver) SelectedLine(matcher *TextMatcher) *ViewDriver { From eb760ee92896a32e902c9bac128a96a611df4333 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 21 Aug 2026 13:18:55 +0200 Subject: [PATCH 2/8] Cover how a view's selection follows the focus in and out of a context Nothing said that a context leaving the stack takes its selection with it, which the work coming up is about to make the rule for every view. Two places already depend on it and are held together by hand: switching repos, where the view focused in the repo being left is not the one focused in the repo being entered, and tabbing from the suggestions list back to the prompt, which replaces the top of the stack rather than popping it. Co-authored-by: Claude Opus 5 (1M context) --- pkg/integration/tests/test_list.go | 2 + ...suggestions_selection_follows_the_focus.go | 42 ++++++++++++++++ .../ui/switch_repo_moves_the_selection.go | 48 +++++++++++++++++++ 3 files changed, 92 insertions(+) create mode 100644 pkg/integration/tests/ui/suggestions_selection_follows_the_focus.go create mode 100644 pkg/integration/tests/ui/switch_repo_moves_the_selection.go diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 5ac4bdcba..434eba2b6 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -526,6 +526,8 @@ var tests = []*components.IntegrationTest{ ui.ReloadSidePanels, ui.ReorderSidePanels, ui.SubCommitsScrollPositionIsReset, + ui.SuggestionsSelectionFollowsTheFocus, + ui.SwitchRepoMovesTheSelection, ui.SwitchTabFromMenu, ui.SwitchTabWithPanelJumpKeys, undo.UndoCheckoutAndDrop, diff --git a/pkg/integration/tests/ui/suggestions_selection_follows_the_focus.go b/pkg/integration/tests/ui/suggestions_selection_follows_the_focus.go new file mode 100644 index 000000000..793c58bf8 --- /dev/null +++ b/pkg/integration/tests/ui/suggestions_selection_follows_the_focus.go @@ -0,0 +1,42 @@ +package ui + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var SuggestionsSelectionFollowsTheFocus = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "The suggestions list only shows a selection while it, rather than the prompt, has the focus", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell. + EmptyCommit("one"). + NewBranch("branch-to-checkout") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Branches(). + Focus(). + Press(keys.Branches.CheckoutBranchByName) + + t.ExpectPopup().Prompt(). + Title(Equals("Branch name:")). + Type("branch-to"). + SuggestionTopLines(Contains("branch-to-checkout")) + + t.Views().Suggestions().SelectionIsHidden() + + t.Views().Prompt().Press(keys.Universal.TogglePanel) + t.Views().Suggestions(). + IsFocused(). + SelectionIsActive(). + Press(keys.Universal.TogglePanel) + + t.Views().Prompt().IsFocused() + t.Views().Suggestions().SelectionIsHidden() + + t.Views().Prompt().Press(keys.Universal.Return) + t.Views().Branches().IsFocused() + }, +}) diff --git a/pkg/integration/tests/ui/switch_repo_moves_the_selection.go b/pkg/integration/tests/ui/switch_repo_moves_the_selection.go new file mode 100644 index 000000000..eb7c4b2df --- /dev/null +++ b/pkg/integration/tests/ui/switch_repo_moves_the_selection.go @@ -0,0 +1,48 @@ +package ui + +import ( + "path/filepath" + + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var SwitchRepoMovesTheSelection = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "The selection follows the focus of the repo being switched to, rather than the one being left", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) { + otherRepo, _ := filepath.Abs("../other") + config.GetAppState().RecentRepos = []string{otherRepo} + }, + SetupRepo: func(shell *Shell) { + shell.CloneNonBare("other") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + switchToRepo := func(repo string) { + t.GlobalPress(keys.Universal.OpenRecentRepos) + t.ExpectPopup().Menu().Title(Equals("Recent repositories")). + Lines( + Contains(repo).IsSelected(), + Contains("Cancel"), + ).Confirm() + t.Views().Status().Content(Contains(repo + " → master")) + } + + t.Views().Branches(). + Focus(). + SelectionIsActive() + + // The other repo has its own focus, which is the files panel it starts in + switchToRepo("other") + t.Views().Files().IsFocused() + t.Views().Branches().SelectionIsHidden() + + // And coming back, this repo still has the focus we left it with + switchToRepo("repo") + t.Views().Branches(). + IsFocused(). + SelectionIsActive() + t.Views().Files().SelectionIsHidden() + }, +}) From 4b391acec05276730f4903d68a9f79b4dbed7892 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 21 Aug 2026 10:46:41 +0200 Subject: [PATCH 3/8] Ask each context whether it has content to select MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Whether a view draws a selection is about to be derived in one place from the context stack, which needs to ask any context — list or not — whether there is something for a selection to sit on. Name the existing flag after that question, and let a list context answer it from its length. Co-authored-by: Claude Opus 5 (1M context) --- pkg/gui/context/base_context.go | 10 +++++++--- pkg/gui/context/list_context_trait.go | 6 +++++- pkg/gui/context/main_context.go | 12 ++++++------ pkg/gui/context/merge_conflicts_context.go | 12 ++++++------ pkg/gui/context/patch_explorer_context.go | 2 +- pkg/gui/context/simple_context.go | 2 +- pkg/gui/types/context.go | 4 ++++ 7 files changed, 30 insertions(+), 18 deletions(-) diff --git a/pkg/gui/context/base_context.go b/pkg/gui/context/base_context.go index 51d2473c3..b5fbf76ce 100644 --- a/pkg/gui/context/base_context.go +++ b/pkg/gui/context/base_context.go @@ -28,7 +28,7 @@ type BaseContext struct { hasControlledBounds bool needsRerenderOnWidthChange types.NeedsRerenderOnWidthChangeLevel needsRerenderOnHeightChange bool - highlightOnFocus bool + hasSelectableContent bool *ParentContextMgr } @@ -49,7 +49,7 @@ type NewBaseContextOpts struct { Focusable bool Transient bool HasUncontrolledBounds bool // negating for the sake of making false the default - HighlightOnFocus bool + HasSelectableContent bool NeedsRerenderOnWidthChange types.NeedsRerenderOnWidthChangeLevel NeedsRerenderOnHeightChange bool @@ -70,7 +70,7 @@ func NewBaseContext(opts NewBaseContextOpts) *BaseContext { focusable: opts.Focusable, transient: opts.Transient, hasControlledBounds: hasControlledBounds, - highlightOnFocus: opts.HighlightOnFocus, + hasSelectableContent: opts.HasSelectableContent, needsRerenderOnWidthChange: opts.NeedsRerenderOnWidthChange, needsRerenderOnHeightChange: opts.NeedsRerenderOnHeightChange, ParentContextMgr: &ParentContextMgr{}, @@ -118,6 +118,10 @@ func (self *BaseContext) GetKind() types.ContextKind { return self.kind } +func (self *BaseContext) HasSelectableContent() bool { + return self.hasSelectableContent +} + func (self *BaseContext) GetKey() types.ContextKey { return self.key } diff --git a/pkg/gui/context/list_context_trait.go b/pkg/gui/context/list_context_trait.go index 77e071991..102684e58 100644 --- a/pkg/gui/context/list_context_trait.go +++ b/pkg/gui/context/list_context_trait.go @@ -37,6 +37,10 @@ type ListContextTrait struct { func (self *ListContextTrait) IsListContext() {} +func (self *ListContextTrait) HasSelectableContent() bool { + return self.list.Len() > 0 +} + func (self *ListContextTrait) FocusLine(scrollIntoView bool) { self.Context.FocusLine(scrollIntoView) @@ -102,7 +106,7 @@ func formatListFooter(selectedLineIdx int, length int) string { func (self *ListContextTrait) HandleFocus(opts types.OnFocusOpts) { self.FocusLine(!opts.KeepScrollPosition) - self.GetViewTrait().SetHighlight(self.list.Len() > 0) + self.GetViewTrait().SetHighlight(self.HasSelectableContent()) self.Context.HandleFocus(opts) } diff --git a/pkg/gui/context/main_context.go b/pkg/gui/context/main_context.go index c8b6edade..692c6dd5c 100644 --- a/pkg/gui/context/main_context.go +++ b/pkg/gui/context/main_context.go @@ -21,12 +21,12 @@ func NewMainContext( ctx := &MainContext{ SimpleContext: NewSimpleContext( NewBaseContext(NewBaseContextOpts{ - Kind: types.MAIN_CONTEXT, - View: view, - WindowName: windowName, - Key: key, - Focusable: true, - HighlightOnFocus: false, + Kind: types.MAIN_CONTEXT, + View: view, + WindowName: windowName, + Key: key, + Focusable: true, + HasSelectableContent: false, })), SearchTrait: NewSearchTrait(c), } diff --git a/pkg/gui/context/merge_conflicts_context.go b/pkg/gui/context/merge_conflicts_context.go index 2ab446c06..dd1060288 100644 --- a/pkg/gui/context/merge_conflicts_context.go +++ b/pkg/gui/context/merge_conflicts_context.go @@ -35,12 +35,12 @@ func NewMergeConflictsContext( viewModel: viewModel, Context: NewSimpleContext( NewBaseContext(NewBaseContextOpts{ - Kind: types.MAIN_CONTEXT, - View: c.Views().MergeConflicts, - WindowName: "main", - Key: MERGE_CONFLICTS_CONTEXT_KEY, - Focusable: true, - HighlightOnFocus: true, + Kind: types.MAIN_CONTEXT, + View: c.Views().MergeConflicts, + WindowName: "main", + Key: MERGE_CONFLICTS_CONTEXT_KEY, + Focusable: true, + HasSelectableContent: true, }), ), c: c, diff --git a/pkg/gui/context/patch_explorer_context.go b/pkg/gui/context/patch_explorer_context.go index 334c2e374..434de6e58 100644 --- a/pkg/gui/context/patch_explorer_context.go +++ b/pkg/gui/context/patch_explorer_context.go @@ -47,7 +47,7 @@ func NewPatchExplorerContext( Key: key, Kind: types.MAIN_CONTEXT, Focusable: true, - HighlightOnFocus: true, + HasSelectableContent: true, NeedsRerenderOnWidthChange: types.NEEDS_RERENDER_ON_WIDTH_CHANGE_WHEN_WIDTH_CHANGES, })), SearchTrait: NewSearchTrait(c), diff --git a/pkg/gui/context/simple_context.go b/pkg/gui/context/simple_context.go index 626d5dfcf..36cd90d96 100644 --- a/pkg/gui/context/simple_context.go +++ b/pkg/gui/context/simple_context.go @@ -33,7 +33,7 @@ func NewDisplayContext(key types.ContextKey, view *gocui.View, windowName string } func (self *SimpleContext) HandleFocus(opts types.OnFocusOpts) { - if self.highlightOnFocus { + if self.hasSelectableContent { self.GetViewTrait().SetHighlight(true) } diff --git a/pkg/gui/types/context.go b/pkg/gui/types/context.go index 2ef798a34..ddf7b345c 100644 --- a/pkg/gui/types/context.go +++ b/pkg/gui/types/context.go @@ -75,6 +75,10 @@ type IBaseContext interface { // determined independently. HasControlledBounds() bool + // true if the context holds something for a selection to sit on. Contexts that + // don't show a selection at all say false, and so do lists with nothing in them. + HasSelectableContent() bool + // the total height of the content that the view is currently showing TotalContentHeight() int From dd01d678791e00f4678351cab8b3c4463f9d4548 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 21 Aug 2026 10:54:29 +0200 Subject: [PATCH 4/8] Demonstrate that an unfocused list's selection goes stale A refresh only re-derives the highlight of the view that has the focus, so a list whose contents change while the user is somewhere else keeps the selection it had: none for a list that just got its first item, and one over nothing for a list that just lost its last. Co-authored-by: Claude Opus 5 (1M context) --- pkg/integration/tests/test_list.go | 2 + ...cused_list_hides_selection_when_emptied.go | 39 +++++++++++++++++++ ...ocused_list_shows_selection_when_filled.go | 37 ++++++++++++++++++ 3 files changed, 78 insertions(+) create mode 100644 pkg/integration/tests/ui/unfocused_list_hides_selection_when_emptied.go create mode 100644 pkg/integration/tests/ui/unfocused_list_shows_selection_when_filled.go diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 434eba2b6..67a39e25c 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -530,6 +530,8 @@ var tests = []*components.IntegrationTest{ ui.SwitchRepoMovesTheSelection, ui.SwitchTabFromMenu, ui.SwitchTabWithPanelJumpKeys, + ui.UnfocusedListHidesSelectionWhenEmptied, + ui.UnfocusedListShowsSelectionWhenFilled, undo.UndoCheckoutAndDrop, undo.UndoCommit, undo.UndoDrop, diff --git a/pkg/integration/tests/ui/unfocused_list_hides_selection_when_emptied.go b/pkg/integration/tests/ui/unfocused_list_hides_selection_when_emptied.go new file mode 100644 index 000000000..f6522770d --- /dev/null +++ b/pkg/integration/tests/ui/unfocused_list_hides_selection_when_emptied.go @@ -0,0 +1,39 @@ +package ui + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var UnfocusedListHidesSelectionWhenEmptied = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "A list that loses its last item while the focus is elsewhere stops showing a selection", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("file1", "one\n") + shell.Commit("one") + shell.UpdateFile("file1", "two\n") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + Lines(Contains("file1")). + SelectionIsActive(). + Press(keys.Universal.FocusMainView) + + t.Views().Main().IsFocused() + + t.Views().Files(). + SelectionIsInactive(). + Tap(func() { + t.Shell().RunCommand([]string{"git", "checkout", "--", "file1"}) + t.RefreshInBackground() + }). + IsEmpty(). + /* EXPECTED: + SelectionIsHidden() + ACTUAL: */ + SelectionIsInactive() + }, +}) diff --git a/pkg/integration/tests/ui/unfocused_list_shows_selection_when_filled.go b/pkg/integration/tests/ui/unfocused_list_shows_selection_when_filled.go new file mode 100644 index 000000000..9ff5388a5 --- /dev/null +++ b/pkg/integration/tests/ui/unfocused_list_shows_selection_when_filled.go @@ -0,0 +1,37 @@ +package ui + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var UnfocusedListShowsSelectionWhenFilled = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "A list that gets its first item while the focus is elsewhere starts showing a selection", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("file1", "one\n") + shell.Commit("one") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + IsEmpty(). + SelectionIsHidden(). + Press(keys.Universal.FocusMainView) + + t.Views().Main().IsFocused() + + t.Views().Files(). + Tap(func() { + t.Shell().CreateFile("file2", "two\n") + t.RefreshInBackground() + }). + Lines(Contains("file2")). + /* EXPECTED: + SelectionIsInactive() + ACTUAL: */ + SelectionIsHidden() + }, +}) From e8009599e04cd38752909a5e14280ed468b6ac14 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 21 Aug 2026 10:55:38 +0200 Subject: [PATCH 5/8] Demonstrate that toggling whitespace undims the panel beneath the main view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Toggling whitespace re-focuses the side panel to re-render the diff, which also re-derives that panel's highlight — as though the panel had the focus, which it doesn't. Co-authored-by: Claude Opus 5 (1M context) --- pkg/integration/tests/test_list.go | 1 + ...espace_keeps_unfocused_selection_dimmed.go | 34 +++++++++++++++++++ 2 files changed, 35 insertions(+) create mode 100644 pkg/integration/tests/ui/toggle_whitespace_keeps_unfocused_selection_dimmed.go diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 67a39e25c..0d25ddba6 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -530,6 +530,7 @@ var tests = []*components.IntegrationTest{ ui.SwitchRepoMovesTheSelection, ui.SwitchTabFromMenu, ui.SwitchTabWithPanelJumpKeys, + ui.ToggleWhitespaceKeepsUnfocusedSelectionDimmed, ui.UnfocusedListHidesSelectionWhenEmptied, ui.UnfocusedListShowsSelectionWhenFilled, undo.UndoCheckoutAndDrop, diff --git a/pkg/integration/tests/ui/toggle_whitespace_keeps_unfocused_selection_dimmed.go b/pkg/integration/tests/ui/toggle_whitespace_keeps_unfocused_selection_dimmed.go new file mode 100644 index 000000000..30223c3b8 --- /dev/null +++ b/pkg/integration/tests/ui/toggle_whitespace_keeps_unfocused_selection_dimmed.go @@ -0,0 +1,34 @@ +package ui + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var ToggleWhitespaceKeepsUnfocusedSelectionDimmed = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Toggling whitespace from the main view leaves the panel beneath it showing a dimmed selection", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("file1", "one\n") + shell.Commit("one") + shell.UpdateFile("file1", " one\n") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + Lines(Contains("file1")). + Press(keys.Universal.FocusMainView) + + t.Views().Main(). + IsFocused(). + Press(keys.Universal.ToggleWhitespaceInDiffView) + + t.Views().Files(). + /* EXPECTED: + SelectionIsInactive() + ACTUAL: */ + SelectionIsActive() + }, +}) From d1707d5dd27dfab0e291eefb13905bea95fb086c Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 21 Aug 2026 13:21:10 +0200 Subject: [PATCH 6/8] Re-render the main view without re-focusing the panel beneath it Toggling whitespace needs the panel beneath to render its diff again, which is what HandleRenderToMain is for; HandleFocus does that and also everything else that belongs to a panel gaining the focus, which this panel already has or, when the focus is in the main view, does not want. Re-selecting its current item is harmless, but re-deriving its highlight as a focused panel's is not: the selection turns bright while the user is somewhere else. Changing the context size and switching diff renderers already ask for a re-render this way. Co-authored-by: Claude Opus 5 (1M context) --- pkg/gui/controllers/toggle_whitespace_action.go | 2 +- .../ui/toggle_whitespace_keeps_unfocused_selection_dimmed.go | 3 --- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/pkg/gui/controllers/toggle_whitespace_action.go b/pkg/gui/controllers/toggle_whitespace_action.go index a1ac0c8da..67bb59d86 100644 --- a/pkg/gui/controllers/toggle_whitespace_action.go +++ b/pkg/gui/controllers/toggle_whitespace_action.go @@ -27,6 +27,6 @@ func (self *ToggleWhitespaceAction) Call() error { self.c.UserConfig().Git.IgnoreWhitespaceInDiffView = !self.c.UserConfig().Git.IgnoreWhitespaceInDiffView - self.c.Context().CurrentSide().HandleFocus(types.OnFocusOpts{}) + self.c.Context().CurrentSide().HandleRenderToMain() return nil } diff --git a/pkg/integration/tests/ui/toggle_whitespace_keeps_unfocused_selection_dimmed.go b/pkg/integration/tests/ui/toggle_whitespace_keeps_unfocused_selection_dimmed.go index 30223c3b8..9ffbd2878 100644 --- a/pkg/integration/tests/ui/toggle_whitespace_keeps_unfocused_selection_dimmed.go +++ b/pkg/integration/tests/ui/toggle_whitespace_keeps_unfocused_selection_dimmed.go @@ -26,9 +26,6 @@ var ToggleWhitespaceKeepsUnfocusedSelectionDimmed = NewIntegrationTest(NewIntegr Press(keys.Universal.ToggleWhitespaceInDiffView) t.Views().Files(). - /* EXPECTED: SelectionIsInactive() - ACTUAL: */ - SelectionIsActive() }, }) From ec3f681ebf7409f9b64f113c4c757dc642147720 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 21 Aug 2026 13:21:35 +0200 Subject: [PATCH 7/8] Derive the selection highlight from the context stack A view drew a selection because something told it to, from four places on three different schedules: a context being focused, a context losing focus, a context being activated over another one, and a list being re-rendered. Whether the flags ended up describing the state of the app depended on which of those had run last, and the last one to run was often none of them: a refresh only re-focuses the view that has the focus, so a list whose contents changed underneath an unfocused panel kept whichever highlight it happened to have. Derive both flags instead, in one place, from the two things they mean: a view shows a selection while its context is on the stack and has something to select, and the context the user is in shows an active one where the ones behind it show inactive ones. Nothing else needs to say anything about highlighting, so nothing else can leave a view saying something untrue about where the focus is. Co-authored-by: Claude Opus 5 (1M context) --- pkg/gui/context.go | 33 ++++++++++++++++--- pkg/gui/context/list_context_trait.go | 2 -- pkg/gui/context/simple_context.go | 5 --- pkg/gui/context/view_trait.go | 5 --- pkg/gui/types/context.go | 1 - pkg/gui/view_helpers.go | 4 +++ ...cused_list_hides_selection_when_emptied.go | 3 -- ...ocused_list_shows_selection_when_filled.go | 3 -- 8 files changed, 33 insertions(+), 23 deletions(-) diff --git a/pkg/gui/context.go b/pkg/gui/context.go index cd959274c..d83b144f2 100644 --- a/pkg/gui/context.go +++ b/pkg/gui/context.go @@ -3,6 +3,7 @@ package gui import ( "sync" + "github.com/jesseduffield/generics/set" "github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/utils" @@ -180,10 +181,6 @@ func (self *ContextMgr) Activate(c types.Context, opts types.OnFocusOpts) { self.gui.helpers.Window.MoveToTopOfWindow(c) inputViewName := c.GetInputViewName() - oldView := self.gui.c.GocuiGui().CurrentView() - if oldView != nil && oldView.Name() != inputViewName { - oldView.HighlightInactive = true - } if _, err := self.gui.c.GocuiGui().SetCurrentView(inputViewName); err != nil { panic(err) } @@ -199,9 +196,37 @@ func (self *ContextMgr) Activate(c types.Context, opts types.OnFocusOpts) { self.gui.c.GocuiGui().Cursor = v.Editable && v.Mask == "" + self.updateSelectionHighlights() + c.HandleFocus(opts) } +// updateSelectionHighlights re-derives which views draw a selection, and which of +// them draw theirs as the active one: a view shows a selection while its context is +// on the stack and has something to select, and the context the user is in shows the +// active selection while the ones behind it show inactive ones. +// +// Both of those can change, so this is called wherever they do: from Activate, which +// every change to the stack goes through; after a refresh, since that is when the +// contents of a list change; and from whoever tells a context that its content has +// gained or lost something to select. +func (self *ContextMgr) updateSelectionHighlights() { + self.RLock() + defer self.RUnlock() + + onStack := set.NewFromSlice(lo.Map(self.ContextStack, + func(c types.Context, _ int) types.ContextKey { return c.GetKey() })) + currentKey := self.currentContextWithoutLock().GetKey() + + for _, c := range self.allContexts.Flatten() { + // The global context has no view of its own. + if view := c.GetView(); view != nil { + view.Highlight = onStack.Includes(c.GetKey()) && c.HasSelectableContent() + view.HighlightInactive = c.GetKey() != currentKey + } + } +} + func (self *ContextMgr) Current() types.Context { self.RLock() defer self.RUnlock() diff --git a/pkg/gui/context/list_context_trait.go b/pkg/gui/context/list_context_trait.go index 102684e58..2e4ae7267 100644 --- a/pkg/gui/context/list_context_trait.go +++ b/pkg/gui/context/list_context_trait.go @@ -106,8 +106,6 @@ func formatListFooter(selectedLineIdx int, length int) string { func (self *ListContextTrait) HandleFocus(opts types.OnFocusOpts) { self.FocusLine(!opts.KeepScrollPosition) - self.GetViewTrait().SetHighlight(self.HasSelectableContent()) - self.Context.HandleFocus(opts) } diff --git a/pkg/gui/context/simple_context.go b/pkg/gui/context/simple_context.go index 36cd90d96..2de4199e2 100644 --- a/pkg/gui/context/simple_context.go +++ b/pkg/gui/context/simple_context.go @@ -33,10 +33,6 @@ func NewDisplayContext(key types.ContextKey, view *gocui.View, windowName string } func (self *SimpleContext) HandleFocus(opts types.OnFocusOpts) { - if self.hasSelectableContent { - self.GetViewTrait().SetHighlight(true) - } - for _, fn := range self.onFocusFns { fn(opts) } @@ -47,7 +43,6 @@ func (self *SimpleContext) HandleFocus(opts types.OnFocusOpts) { } func (self *SimpleContext) HandleFocusLost(opts types.OnFocusLostOpts) { - self.GetViewTrait().SetHighlight(false) self.view.SetOriginX(0) for _, fn := range self.onFocusLostFns { fn(opts) diff --git a/pkg/gui/context/view_trait.go b/pkg/gui/context/view_trait.go index 8e12e083f..9fc078e61 100644 --- a/pkg/gui/context/view_trait.go +++ b/pkg/gui/context/view_trait.go @@ -43,11 +43,6 @@ func (self *ViewTrait) SetContent(content string) { self.view.SetContent(content) } -func (self *ViewTrait) SetHighlight(highlight bool) { - self.view.Highlight = highlight - self.view.HighlightInactive = false -} - func (self *ViewTrait) SetFooter(value string) { self.view.Footer = value } diff --git a/pkg/gui/types/context.go b/pkg/gui/types/context.go index ddf7b345c..93b92c70e 100644 --- a/pkg/gui/types/context.go +++ b/pkg/gui/types/context.go @@ -229,7 +229,6 @@ type IViewTrait interface { ScrollDown(value int) PageDelta() int SelectedLineIdx() int - SetHighlight(bool) } type OnFocusOpts struct { diff --git a/pkg/gui/view_helpers.go b/pkg/gui/view_helpers.go index 2151a5692..5f3e4c2ab 100644 --- a/pkg/gui/view_helpers.go +++ b/pkg/gui/view_helpers.go @@ -140,6 +140,10 @@ func (gui *Gui) postRefreshUpdate(c types.Context, opts types.OnFocusOpts) { c.HandleRender() + // The render may have given the context its first item, or taken its last one + // away, which decides whether its view draws a selection at all. + gui.State.ContextMgr.updateSelectionHighlights() + if gui.currentViewName() == c.GetInputViewName() { c.HandleFocus(opts) } else { diff --git a/pkg/integration/tests/ui/unfocused_list_hides_selection_when_emptied.go b/pkg/integration/tests/ui/unfocused_list_hides_selection_when_emptied.go index f6522770d..eef409cb2 100644 --- a/pkg/integration/tests/ui/unfocused_list_hides_selection_when_emptied.go +++ b/pkg/integration/tests/ui/unfocused_list_hides_selection_when_emptied.go @@ -31,9 +31,6 @@ var UnfocusedListHidesSelectionWhenEmptied = NewIntegrationTest(NewIntegrationTe t.RefreshInBackground() }). IsEmpty(). - /* EXPECTED: SelectionIsHidden() - ACTUAL: */ - SelectionIsInactive() }, }) diff --git a/pkg/integration/tests/ui/unfocused_list_shows_selection_when_filled.go b/pkg/integration/tests/ui/unfocused_list_shows_selection_when_filled.go index 9ff5388a5..78a8f210d 100644 --- a/pkg/integration/tests/ui/unfocused_list_shows_selection_when_filled.go +++ b/pkg/integration/tests/ui/unfocused_list_shows_selection_when_filled.go @@ -29,9 +29,6 @@ var UnfocusedListShowsSelectionWhenFilled = NewIntegrationTest(NewIntegrationTes t.RefreshInBackground() }). Lines(Contains("file2")). - /* EXPECTED: SelectionIsInactive() - ACTUAL: */ - SelectionIsHidden() }, }) From d2dc38ee87db564a1cac21d266c0c61d956853e0 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 21 Aug 2026 13:21:46 +0200 Subject: [PATCH 8/8] Drop the highlight fixups the context stack now makes unnecessary Two places nudged the flags because nothing else would: switching repos, where the view focused in the repo being left is not the one focused in the repo being entered, and tabbing from the suggestions list back to the prompt, which replaces the top of the stack rather than popping it, so the suggestions context never hears that it lost the focus. Both are just a context leaving the stack now. Co-authored-by: Claude Opus 5 (1M context) --- pkg/gui/controllers/suggestions_controller.go | 1 - pkg/gui/gui.go | 7 ------- 2 files changed, 8 deletions(-) diff --git a/pkg/gui/controllers/suggestions_controller.go b/pkg/gui/controllers/suggestions_controller.go index 0553050e5..18ee594b2 100644 --- a/pkg/gui/controllers/suggestions_controller.go +++ b/pkg/gui/controllers/suggestions_controller.go @@ -85,7 +85,6 @@ func (self *SuggestionsController) GetMouseKeybindings(opts types.KeybindingsOpt func (self *SuggestionsController) switchToPrompt() error { self.c.Views().Suggestions.Subtitle = "" - self.c.Views().Suggestions.Highlight = false self.c.Context().Replace(self.c.Contexts().Prompt) return nil } diff --git a/pkg/gui/gui.go b/pkg/gui/gui.go index 4de37fec2..801fe14d3 100644 --- a/pkg/gui/gui.go +++ b/pkg/gui/gui.go @@ -598,13 +598,6 @@ func (gui *Gui) resetState(startArgs appTypes.StartArgs) types.Context { // RefreshHelper.onUIThreadUnlessRepoChanged). gui.repoGeneration.Add(1) - // Un-highlight the current view if there is one. The reason we do this is - // that the repo we are switching to might have a different view focused, - // and would then show an inactive highlight for the previous view. - if oldCurrentView := gui.g.CurrentView(); oldCurrentView != nil { - oldCurrentView.Highlight = false - } - worktreePath := gui.git.RepoPaths.WorktreePath() if state := gui.RepoStateMap[Repo(worktreePath)]; state != nil {