From e9faf0325d400e7b1e13367c37088b5eff3a100c Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 8 Aug 2026 15:20:51 +0200 Subject: [PATCH] Add a test that a background refresh keeps the scroll position MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The one behaviour that made scrolling the selection into view opt-in in the first place — a background refresh must not yank the view back to a selection the user scrolled away from — has never been covered by a test. It's about to become the one case that the automatic scrolling has to suppress, so cover it first. Getting there needs two things from the test harness: mouse wheel events, which are the only way to scroll a list panel without moving the selection, and a way to trigger a background refresh. The periodic routine that issues it is turned off in tests, and turning it on would mean waiting for its timer and hoping it fires while we're looking, so drive the refresh directly instead. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/gui/gui_driver.go | 14 +++++++ pkg/integration/components/test_driver.go | 15 +++++++ pkg/integration/components/test_test.go | 6 +++ pkg/integration/components/view_driver.go | 9 ++++ pkg/integration/tests/test_list.go | 1 + ...ackground_refresh_keeps_scroll_position.go | 41 +++++++++++++++++++ pkg/integration/types/types.go | 3 ++ 7 files changed, 89 insertions(+) create mode 100644 pkg/integration/tests/ui/background_refresh_keeps_scroll_position.go diff --git a/pkg/gui/gui_driver.go b/pkg/gui/gui_driver.go index 673d9d726..096534e96 100644 --- a/pkg/gui/gui_driver.go +++ b/pkg/gui/gui_driver.go @@ -69,6 +69,10 @@ func (self *GuiDriver) MouseMove(x, y int) { self.replayMouseEvent(x, y, tcell.ButtonPrimary) } +func (self *GuiDriver) ScrollWheelDown(x, y int) { + self.replayMouseEvent(x, y, tcell.WheelDown) +} + func (self *GuiDriver) MouseRelease(x, y int) { self.replayMouseEvent(x, y, tcell.ButtonNone) } @@ -128,6 +132,16 @@ func (self *GuiDriver) FocusInAndClick(x, y int) { self.waitTillIdle() } +// RefreshInBackground performs the refresh that the background routines perform +// on a timer (see BackgroundRoutineMgr). Tests drive it directly rather than +// turning those routines on, so that they neither wait for a timer nor depend on +// one firing at a particular moment. +func (self *GuiDriver) RefreshInBackground() { + self.gui.c.RefreshFromWorker(types.RefreshOptions{Background: true}) + + 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 d65caee5d..afd55a845 100644 --- a/pkg/integration/components/test_driver.go +++ b/pkg/integration/components/test_driver.go @@ -78,6 +78,12 @@ func (self *TestDriver) repeatMouseMove() { self.mouseMove(self.mouseX, self.mouseY) } +func (self *TestDriver) scrollWheelDown(x, y int) { + self.SetCaption(fmt.Sprintf("Scrolling down at %d, %d", x, y)) + self.gui.ScrollWheelDown(x, y) + self.Wait(self.inputDelay) +} + func (self *TestDriver) mouseRelease() { self.SetCaption(fmt.Sprintf("Releasing mouse at %d, %d", self.mouseX, self.mouseY)) self.gui.MouseRelease(self.mouseX, self.mouseY) @@ -136,6 +142,15 @@ func (self *TestDriver) Log(message string) { self.gui.LogUI(message) } +// RefreshInBackground performs the refresh that lazygit's background routines +// perform on a timer, e.g. to pick up changes made by RunCommand. Tests use this +// rather than turning those routines on and waiting for them. +func (self *TestDriver) RefreshInBackground() { + self.SetCaption("Refreshing in the background") + self.gui.RefreshInBackground() + self.Wait(self.inputDelay) +} + // allows the user to run shell commands during the test to emulate background activity func (self *TestDriver) Shell() *Shell { return self.shell diff --git a/pkg/integration/components/test_test.go b/pkg/integration/components/test_test.go index cf0338dec..bda63e3fb 100644 --- a/pkg/integration/components/test_test.go +++ b/pkg/integration/components/test_test.go @@ -56,6 +56,12 @@ func (self *fakeGuiDriver) MouseRelease(x, y int) { self.releasedCoordinates = append(self.releasedCoordinates, coordinate{x: x, y: y}) } +func (self *fakeGuiDriver) ScrollWheelDown(x, y int) { +} + +func (self *fakeGuiDriver) RefreshInBackground() { +} + func (self *fakeGuiDriver) OnUIThreadAndWait(f func()) { f() } diff --git a/pkg/integration/components/view_driver.go b/pkg/integration/components/view_driver.go index bc0969f24..9b895e462 100644 --- a/pkg/integration/components/view_driver.go +++ b/pkg/integration/components/view_driver.go @@ -544,6 +544,15 @@ func (self *ViewDriver) MouseMoveToBottom(x int) *ViewDriver { return self.MouseMove(x, self.getView().InnerHeight()-1) } +// scrolls the view down by one notch of the mouse wheel, i.e. by +// gui.scrollHeight lines. This moves the scroll position without moving the +// selection. +func (self *ViewDriver) ScrollWheelDown() *ViewDriver { + offsetX, offsetY, _, _ := self.getView().Dimensions() + self.t.scrollWheelDown(offsetX+1, offsetY+1) + return self +} + func (self *ViewDriver) RepeatMouseMove() *ViewDriver { self.t.repeatMouseMove() return self diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 2d88de8e9..c9f4f975d 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -496,6 +496,7 @@ var tests = []*components.IntegrationTest{ tag.Reset, tag.ResetToDuplicateNamedBranch, ui.Accordion, + ui.BackgroundRefreshKeepsScrollPosition, ui.BranchesNotFirstTab, ui.CommitsNotFirstTab, ui.DisableSwitchTabWithPanelJumpKeys, diff --git a/pkg/integration/tests/ui/background_refresh_keeps_scroll_position.go b/pkg/integration/tests/ui/background_refresh_keeps_scroll_position.go new file mode 100644 index 000000000..e2cf6ee1e --- /dev/null +++ b/pkg/integration/tests/ui/background_refresh_keeps_scroll_position.go @@ -0,0 +1,41 @@ +package ui + +import ( + "fmt" + + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var BackgroundRefreshKeepsScrollPosition = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "A background refresh doesn't scroll the selection back into view", + ExtraCmdArgs: []string{}, + Skip: false, + Width: 120, + Height: 30, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.EmptyCommit("initial commit") + for i := range 20 { + shell.CreateFile(fmt.Sprintf("file%02d", i), "") + } + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + Focus(). + SelectNextItem(). + SelectedLine(Contains("file00")). + // Scroll the selection out of view with the mouse wheel + ScrollWheelDown(). + ScrollWheelDown(). + OriginY(4). + Tap(func() { + t.Shell().CreateFile("aaa", "") + t.RefreshInBackground() + }). + // The new file sorts before the selected one, so the selection has + // moved down a line; the view must stay where the user left it though + SelectedLineIdx(2). + OriginY(4) + }, +}) diff --git a/pkg/integration/types/types.go b/pkg/integration/types/types.go index 4d2da7602..db9068ad9 100644 --- a/pkg/integration/types/types.go +++ b/pkg/integration/types/types.go @@ -31,6 +31,9 @@ type GuiDriver interface { ClickAndHold(int, int) MouseMove(int, int) MouseRelease(int, int) + ScrollWheelDown(int, int) + // Perform the refresh that a background routine would perform on a timer + RefreshInBackground() // Can be used to avoid data races with the UI thread in the uncommon cases that // the test driver needs to assert state while the gui is not idle. OnUIThreadAndWait(func())