From 5aa003612c9c1dba1f73173a175194b44e4dc938 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 24 Jul 2026 08:45:45 +0200 Subject: [PATCH 1/2] Demonstrate stale focus refresh overwriting a click When clicking in the commits view of lazygit running in an unfocused VS Code window, VS Code first sends us the focus-in event and then the mouse-click. The focus-in refresh captures the selection when it starts, then we handle the mouse click and you briefly see the clicked row getting selected, but then the selection flashes back to the original row as the refresh restores it when done. --- pkg/gui/gui_driver.go | 19 ++++++++++++ pkg/integration/components/test_driver.go | 6 ++++ pkg/integration/components/test_test.go | 4 +++ pkg/integration/components/view_driver.go | 8 +++++ ..._clicked_commit_selected_after_focus_in.go | 29 +++++++++++++++++++ pkg/integration/tests/test_list.go | 1 + pkg/integration/types/types.go | 3 ++ 7 files changed, 70 insertions(+) create mode 100644 pkg/integration/tests/commit/keep_clicked_commit_selected_after_focus_in.go diff --git a/pkg/gui/gui_driver.go b/pkg/gui/gui_driver.go index 74a8109a7..9e06f483b 100644 --- a/pkg/gui/gui_driver.go +++ b/pkg/gui/gui_driver.go @@ -77,6 +77,25 @@ func (self *GuiDriver) FocusIn() { self.waitTillIdle() } +func (self *GuiDriver) FocusInAndClick(x, y int) { + self.CheckAllToastsAcknowledged() + + self.gui.g.ReplayFocusEvent(gocui.NewTcellFocusEventWrapper( + tcell.NewEventFocus(true), + 0, + )) + self.gui.g.ReplayMouseEvent(gocui.NewTcellMouseEventWrapper( + tcell.NewEventMouse(x, y, tcell.ButtonPrimary, 0), + 0, + )) + self.waitTillIdle() + self.gui.g.ReplayMouseEvent(gocui.NewTcellMouseEventWrapper( + tcell.NewEventMouse(x, y, tcell.ButtonNone, 0), + 0, + )) + self.waitTillIdle() +} + func (self *GuiDriver) PretendMergeOrRebaseStartedInLazygit() { self.gui.onUIThread(func() error { self.gui.State.SetMergeOrRebaseStartedInLazygit(true) diff --git a/pkg/integration/components/test_driver.go b/pkg/integration/components/test_driver.go index 42ce8ac35..19219707a 100644 --- a/pkg/integration/components/test_driver.go +++ b/pkg/integration/components/test_driver.go @@ -73,6 +73,12 @@ func (self *TestDriver) FocusIn() { self.Wait(self.inputDelay) } +func (self *TestDriver) focusInAndClick(x, y int) { + self.SetCaption(fmt.Sprintf("Focusing window and clicking %d, %d", x, y)) + self.gui.FocusInAndClick(x, y) + self.Wait(self.inputDelay) +} + func (self *TestDriver) typeContent(content string) { for _, char := range content { self.pressFast(string(char)) diff --git a/pkg/integration/components/test_test.go b/pkg/integration/components/test_test.go index 8fd4417ea..7196779eb 100644 --- a/pkg/integration/components/test_test.go +++ b/pkg/integration/components/test_test.go @@ -41,6 +41,10 @@ func (self *fakeGuiDriver) Click(x, y int) { func (self *fakeGuiDriver) FocusIn() { } +func (self *fakeGuiDriver) FocusInAndClick(x, y int) { + self.clickedCoordinates = append(self.clickedCoordinates, coordinate{x: x, y: y}) +} + func (self *fakeGuiDriver) Keys() config.KeybindingConfig { return config.KeybindingConfig{} } diff --git a/pkg/integration/components/view_driver.go b/pkg/integration/components/view_driver.go index 920c610be..2cfaba338 100644 --- a/pkg/integration/components/view_driver.go +++ b/pkg/integration/components/view_driver.go @@ -475,6 +475,14 @@ func (self *ViewDriver) Click(x, y int) *ViewDriver { return self } +func (self *ViewDriver) FocusInAndClick(x, y int) *ViewDriver { + offsetX, offsetY, _, _ := self.getView().Dimensions() + + self.t.focusInAndClick(offsetX+1+x, offsetY+1+y) + + return self +} + // i.e. pressing down arrow func (self *ViewDriver) SelectNextItem() *ViewDriver { return self.PressFast(self.t.keys.Universal.NextItem) diff --git a/pkg/integration/tests/commit/keep_clicked_commit_selected_after_focus_in.go b/pkg/integration/tests/commit/keep_clicked_commit_selected_after_focus_in.go new file mode 100644 index 000000000..c951ea125 --- /dev/null +++ b/pkg/integration/tests/commit/keep_clicked_commit_selected_after_focus_in.go @@ -0,0 +1,29 @@ +package commit + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var KeepClickedCommitSelectedAfterFocusIn = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Keep a clicked commit selected when focus-in immediately precedes the click", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.CreateNCommits(2) + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Commits(). + Focus(). + Lines( + Contains("commit-02").IsSelected(), + Contains("commit-01"), + ). + FocusInAndClick(1, 1). + /* EXPECTED: + SelectedLine(Contains("commit-01")) + ACTUAL: */ + SelectedLine(Contains("commit-02")) + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 1bb06741f..07a12e2be 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -140,6 +140,7 @@ var tests = []*components.IntegrationTest{ commit.Highlight, commit.History, commit.HistoryComplex, + commit.KeepClickedCommitSelectedAfterFocusIn, commit.KeepSelectedCommitAfterExternalCommit, commit.NewBranch, commit.PasteCommitMessage, diff --git a/pkg/integration/types/types.go b/pkg/integration/types/types.go index 12009315a..ea76c45be 100644 --- a/pkg/integration/types/types.go +++ b/pkg/integration/types/types.go @@ -31,6 +31,9 @@ type GuiDriver interface { // Simulate the terminal window regaining focus (which triggers a reload of // changed config files) FocusIn() + // Simulate a terminal dispatching focus-in immediately followed by a click, + // without waiting for the focus refresh to finish in between. + FocusInAndClick(int, int) Keys() config.KeybindingConfig CurrentContext() types.Context ContextForView(viewName string) types.Context From 3d9318e2a777cadf1c1e7718682ace1e8f69a606 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 24 Jul 2026 08:48:06 +0200 Subject: [PATCH 2/2] Preserve commit clicks during focus refreshes This fixes the problem described in the previous commit; we no longer capture the selection at the start of the refresh. There's no reason to do that (we don't do it for branches either). It is enough to capture the selection in the final bounce, before we assign the new model slice. --- pkg/gui/controllers/helpers/refresh_helper.go | 26 +++++++++---------- ..._clicked_commit_selected_after_focus_in.go | 3 --- 2 files changed, 13 insertions(+), 16 deletions(-) diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index cf097b67e..29784b5ff 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -322,7 +322,7 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr var capturedReflog capturedReflogState var capturedBranches capturedBranchState self.captureOnUIThread(calledFromWorker, env.background, func() { - capturedCommits = self.captureCommitsState(options.CommitSelection) + capturedCommits = self.captureCommitsState() capturedReflog = self.captureReflogState() capturedBranches = self.captureBranchState() }) @@ -704,7 +704,6 @@ func (self *RefreshHelper) refreshReflogAndBranches(capturedReflog capturedReflo // worker computes from an immutable snapshot rather than reading state the UI // thread concurrently mutates. type capturedCommitState struct { - selectionRange *localCommitSelectionRange limitCommits bool showWholeGitGraph bool filterPath string @@ -716,17 +715,12 @@ type capturedCommitState struct { // captureCommitsState reads the commits refresh's model/context/mode inputs // into an immutable snapshot. It must run on the UI thread. -func (self *RefreshHelper) captureCommitsState(commitSelection types.CommitSelectionBehavior) capturedCommitState { - var selectionRange *localCommitSelectionRange - if commitSelection == types.KeepCommitSelectionByHash { - selectedIdx, rangeStartIdx, rangeSelectMode := self.c.Contexts().LocalCommits.GetSelectionRangeAndMode() - selectionRange = captureLocalCommitSelectionRange(self.c.Model().Commits, selectedIdx, rangeStartIdx, rangeSelectMode) - } - +// The selection is captured later, when applying the refresh, so user input +// received while the git work is in flight is not overwritten. +func (self *RefreshHelper) captureCommitsState() capturedCommitState { parentCtx := self.c.Contexts().CommitFiles.GetParentContext() return capturedCommitState{ - selectionRange: selectionRange, limitCommits: self.c.Contexts().LocalCommits.GetLimitCommits(), showWholeGitGraph: self.c.Contexts().LocalCommits.GetShowWholeGitGraph(), filterPath: self.c.Modes().Filtering.GetPath(), @@ -815,6 +809,12 @@ func (self *RefreshHelper) refreshCommitsWithLimit(captured capturedCommitState, workingTreeState := env.git.Status.WorkingTreeState() self.onUIThreadUnlessRepoChanged(env, func() { + var selectionRange *localCommitSelectionRange + if commitSelection == types.KeepCommitSelectionByHash { + selectedIdx, rangeStartIdx, rangeSelectMode := self.c.Contexts().LocalCommits.GetSelectionRangeAndMode() + selectionRange = captureLocalCommitSelectionRange(self.c.Model().Commits, selectedIdx, rangeStartIdx, rangeSelectMode) + } + self.c.Model().BisectInfo = bisectInfo self.c.Model().Commits = commits self.RefreshAuthors(commits) @@ -833,10 +833,10 @@ func (self *RefreshHelper) refreshCommitsWithLimit(captured capturedCommitState, scrollSelectionIntoView = true } case types.KeepCommitSelectionByHash: - if captured.selectionRange != nil { - selectedIdx, rangeStartIdx, didMove, found := findLocalCommitSelectionRange(commits, captured.selectionRange) + if selectionRange != nil { + selectedIdx, rangeStartIdx, didMove, found := findLocalCommitSelectionRange(commits, selectionRange) if found { - self.c.Contexts().LocalCommits.SetSelectionRangeAndMode(selectedIdx, rangeStartIdx, captured.selectionRange.mode) + self.c.Contexts().LocalCommits.SetSelectionRangeAndMode(selectedIdx, rangeStartIdx, selectionRange.mode) scrollSelectionIntoView = didMove } } diff --git a/pkg/integration/tests/commit/keep_clicked_commit_selected_after_focus_in.go b/pkg/integration/tests/commit/keep_clicked_commit_selected_after_focus_in.go index c951ea125..cfd4e23c5 100644 --- a/pkg/integration/tests/commit/keep_clicked_commit_selected_after_focus_in.go +++ b/pkg/integration/tests/commit/keep_clicked_commit_selected_after_focus_in.go @@ -21,9 +21,6 @@ var KeepClickedCommitSelectedAfterFocusIn = NewIntegrationTest(NewIntegrationTes Contains("commit-01"), ). FocusInAndClick(1, 1). - /* EXPECTED: SelectedLine(Contains("commit-01")) - ACTUAL: */ - SelectedLine(Contains("commit-02")) }, })