From a881fb7ee0f5c0e231376b2de1ea2198dfc3d3b7 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 8 Aug 2026 15:09:21 +0200 Subject: [PATCH 1/6] Add a test for paging up and down in a list We are about to make list panels scroll their selection into view automatically. Page up and down are one of the few places that manage the scroll position themselves, keeping the selection at the edge of the viewport rather than in its middle, and nothing covers that today. Asserting on it needs an exact scroll position assertion; only OriginYAtLeast existed so far. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/integration/components/view_driver.go | 11 +++++ pkg/integration/tests/test_list.go | 1 + pkg/integration/tests/ui/page_up_and_down.go | 47 ++++++++++++++++++++ 3 files changed, 59 insertions(+) create mode 100644 pkg/integration/tests/ui/page_up_and_down.go diff --git a/pkg/integration/components/view_driver.go b/pkg/integration/components/view_driver.go index 937c19b7bbe..bc0969f247c 100644 --- a/pkg/integration/components/view_driver.go +++ b/pkg/integration/components/view_driver.go @@ -355,6 +355,17 @@ func (self *ViewDriver) SelectedLineIdxAtLeast(expected int) *ViewDriver { return self } +// asserts on the scroll position of the view, i.e. the index of the line that +// is shown at the top of the view. +func (self *ViewDriver) OriginY(expected int) *ViewDriver { + self.t.assertWithRetries(func() (bool, string) { + actual := self.getView().OriginY() + return expected == actual, fmt.Sprintf("%s: Expected origin Y to be %d, got %d", self.context, expected, actual) + }) + + return self +} + func (self *ViewDriver) OriginYAtLeast(expected int) *ViewDriver { self.t.assertEventually(func() (bool, string) { var actual int diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 15ef6f8c75d..2d88de8e9b2 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -505,6 +505,7 @@ var tests = []*components.IntegrationTest{ ui.KeybindingSuggestionsWhenSwitchingRepos, ui.ModeSpecificKeybindingSuggestions, ui.OpenLinkFailure, + ui.PageUpAndDown, ui.PromoteTabToSidePanel, ui.RangeSelect, ui.RangeSelectWithAutoscroll, diff --git a/pkg/integration/tests/ui/page_up_and_down.go b/pkg/integration/tests/ui/page_up_and_down.go new file mode 100644 index 00000000000..603edfd923d --- /dev/null +++ b/pkg/integration/tests/ui/page_up_and_down.go @@ -0,0 +1,47 @@ +package ui + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +const ( + // The height of the commits panel in this test's window, in lines. + commitsPanelHeight = 5 + // Paging keeps one line of overlap between the old and the new page. + pageDelta = commitsPanelHeight - 1 +) + +var PageUpAndDown = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Paging down and up keeps the selection at the edge of the viewport", + ExtraCmdArgs: []string{}, + Skip: false, + Width: 120, + Height: 30, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.CreateNCommits(40) + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Commits(). + Focus(). + SelectedLineIdx(0). + OriginY(0). + Press(keys.Universal.NextPage). + // The selection moves to the bottom of the viewport; nothing scrolls yet + SelectedLineIdx(commitsPanelHeight - 1). + OriginY(0). + Press(keys.Universal.NextPage). + // Now the view scrolls by a page, and the selection stays at the bottom + SelectedLineIdx(commitsPanelHeight - 1 + pageDelta). + OriginY(pageDelta). + Press(keys.Universal.PrevPage). + // The selection moves to the top of the viewport; nothing scrolls + SelectedLineIdx(pageDelta). + OriginY(pageDelta). + Press(keys.Universal.PrevPage). + // And back a page, with the selection staying at the top + SelectedLineIdx(0). + OriginY(0) + }, +}) From e9faf0325d400e7b1e13367c37088b5eff3a100c Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 8 Aug 2026 15:20:51 +0200 Subject: [PATCH 2/6] 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 673d9d726c7..096534e9664 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 d65caee5d6b..afd55a845a6 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 cf0338decb1..bda63e3fb51 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 bc0969f247c..9b895e4625d 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 2d88de8e9b2..c9f4f975d77 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 00000000000..e2cf6ee1eda --- /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 4d2da760245..db9068ad920 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()) From a85d6e03499e650f5ef84c80062a76ce375076ca Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 8 Aug 2026 15:32:08 +0200 Subject: [PATCH 3/6] Add a test for dragging a range selection past the bottom of a panel This is the other place that manages its own scroll position: while a drag extends the selection to a line below the viewport, the view stays put, and the drag autoscroller scrolls it one line at a time for as long as the pointer stays there. Making the scroll automatic would centre the selection instead, i.e. jump the view rather than scroll it. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/integration/tests/test_list.go | 1 + .../tests/ui/drag_beyond_viewport.go | 37 +++++++++++++++++++ 2 files changed, 38 insertions(+) create mode 100644 pkg/integration/tests/ui/drag_beyond_viewport.go diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index c9f4f975d77..ab7bf196c62 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -500,6 +500,7 @@ var tests = []*components.IntegrationTest{ ui.BranchesNotFirstTab, ui.CommitsNotFirstTab, ui.DisableSwitchTabWithPanelJumpKeys, + ui.DragBeyondViewport, ui.EmptyMenu, ui.HideSidePanel, ui.KeybindingSuggestionsDontCrashOnDisabledBindings, diff --git a/pkg/integration/tests/ui/drag_beyond_viewport.go b/pkg/integration/tests/ui/drag_beyond_viewport.go new file mode 100644 index 00000000000..f756a783c79 --- /dev/null +++ b/pkg/integration/tests/ui/drag_beyond_viewport.go @@ -0,0 +1,37 @@ +package ui + +import ( + "fmt" + + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var DragBeyondViewport = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Dragging a range selection beyond the bottom of the panel doesn't scroll the 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(). + OriginY(0). + // The pointer ends up below the panel, so the range extends to a line + // that isn't visible. Scrolling there is the drag autoscroller's job, + // which scrolls line by line for as long as the pointer stays there; + // the drag itself must leave the scroll position alone. + ClickAndHold(1, 1). + MouseMove(1, 8). + MouseRelease(). + SelectedLineIdx(8). + OriginY(0) + }, +}) From 29d05e23f98a5d4d429dff51a1210fbea9e018aa Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 8 Aug 2026 15:52:43 +0200 Subject: [PATCH 4/6] Add tests for the scroll-into-view regressions we fixed by hand Since scrolling the selection into view became opt-in, five places have had to be fixed by hand after the fact, none of them with a test. Cover them now: making the scrolling automatic has to keep all five working, and once it does, the hand-added scroll calls can go. Two of them assert that the selection is visible rather than on an exact scroll position, because the panel they look at changes height along the way (filtering mode switches to half screen), or because what matters is only that the commit we jumped to can be seen. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/integration/components/view_driver.go | 14 +++++ pkg/integration/tests/test_list.go | 5 ++ .../filtering_scrolls_selection_into_view.go | 62 +++++++++++++++++++ ...base_commit_for_fixup_scrolls_into_view.go | 35 +++++++++++ .../tests/ui/menu_scroll_position_is_reset.go | 39 ++++++++++++ ...move_commit_scrolls_selection_into_view.go | 31 ++++++++++ .../sub_commits_scroll_position_is_reset.go | 39 ++++++++++++ 7 files changed, 225 insertions(+) create mode 100644 pkg/integration/tests/ui/filtering_scrolls_selection_into_view.go create mode 100644 pkg/integration/tests/ui/find_base_commit_for_fixup_scrolls_into_view.go create mode 100644 pkg/integration/tests/ui/menu_scroll_position_is_reset.go create mode 100644 pkg/integration/tests/ui/move_commit_scrolls_selection_into_view.go create mode 100644 pkg/integration/tests/ui/sub_commits_scroll_position_is_reset.go diff --git a/pkg/integration/components/view_driver.go b/pkg/integration/components/view_driver.go index 9b895e4625d..23e3502a189 100644 --- a/pkg/integration/components/view_driver.go +++ b/pkg/integration/components/view_driver.go @@ -366,6 +366,20 @@ func (self *ViewDriver) OriginY(expected int) *ViewDriver { return self } +// asserts that the selected line is inside the visible area of the view +func (self *ViewDriver) SelectedLineIsVisible() *ViewDriver { + self.t.assertWithRetries(func() (bool, string) { + view := self.getView() + firstVisible, lastVisible := view.OriginY(), view.OriginY()+view.InnerHeight()-1 + actual := view.SelectedLineIdx() + return actual >= firstVisible && actual <= lastVisible, + fmt.Sprintf("%s: Expected the selected line (%d) to be visible, but only lines %d to %d are", + self.context, actual, firstVisible, lastVisible) + }) + + return self +} + func (self *ViewDriver) OriginYAtLeast(expected int) *ViewDriver { self.t.assertEventually(func() (bool, string) { var actual int diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index ab7bf196c62..a945e324d54 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -502,10 +502,14 @@ var tests = []*components.IntegrationTest{ ui.DisableSwitchTabWithPanelJumpKeys, ui.DragBeyondViewport, ui.EmptyMenu, + ui.FilteringScrollsSelectionIntoView, + ui.FindBaseCommitForFixupScrollsIntoView, ui.HideSidePanel, ui.KeybindingSuggestionsDontCrashOnDisabledBindings, ui.KeybindingSuggestionsWhenSwitchingRepos, + ui.MenuScrollPositionIsReset, ui.ModeSpecificKeybindingSuggestions, + ui.MoveCommitScrollsSelectionIntoView, ui.OpenLinkFailure, ui.PageUpAndDown, ui.PromoteTabToSidePanel, @@ -513,6 +517,7 @@ var tests = []*components.IntegrationTest{ ui.RangeSelectWithAutoscroll, ui.ReloadSidePanels, ui.ReorderSidePanels, + ui.SubCommitsScrollPositionIsReset, ui.SwitchTabFromMenu, ui.SwitchTabWithPanelJumpKeys, undo.UndoCheckoutAndDrop, diff --git a/pkg/integration/tests/ui/filtering_scrolls_selection_into_view.go b/pkg/integration/tests/ui/filtering_scrolls_selection_into_view.go new file mode 100644 index 00000000000..3f1ab4d8497 --- /dev/null +++ b/pkg/integration/tests/ui/filtering_scrolls_selection_into_view.go @@ -0,0 +1,62 @@ +package ui + +import ( + "fmt" + + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var FilteringScrollsSelectionIntoView = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Entering and leaving filtering mode scrolls the selected commit into view", + ExtraCmdArgs: []string{}, + Skip: false, + Width: 120, + Height: 30, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + for i := range 40 { + file := "otherFile" + if i%2 == 0 { + file = "filterFile" + } + shell.UpdateFileAndAdd(file, fmt.Sprintf("content %02d", i)) + shell.Commit(fmt.Sprintf("commit %02d", i)) + } + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Commits(). + Focus(). + Press(keys.Universal.GotoBottom). + SelectedLine(Contains("commit 00")). + OriginYAtLeast(1). + Press(keys.Universal.FilteringMenu) + + t.ExpectPopup().Menu(). + Title(Equals("Filtering")). + Select(Contains("Enter path to filter by")). + Confirm() + t.ExpectPopup().Prompt(). + Title(Equals("Enter path:")). + Type("filterFile"). + Confirm() + + // The filtered list has nothing to do with the one that was showing, so + // its scroll position doesn't either: we start at the top again + t.Views().Commits(). + IsFocused(). + SelectedLine(Contains("commit 38")). + SelectedLineIdx(0). + OriginY(0). + Press(keys.Universal.GotoBottom). + SelectedLine(Contains("commit 00")). + PressEscape() + + // Leaving filtering mode keeps the commit selected, at its position in + // the full list, which needs scrolling to again + t.Views().Commits(). + IsFocused(). + SelectedLine(Contains("commit 00")). + SelectedLineIsVisible() + }, +}) diff --git a/pkg/integration/tests/ui/find_base_commit_for_fixup_scrolls_into_view.go b/pkg/integration/tests/ui/find_base_commit_for_fixup_scrolls_into_view.go new file mode 100644 index 00000000000..b0c63dcf5a9 --- /dev/null +++ b/pkg/integration/tests/ui/find_base_commit_for_fixup_scrolls_into_view.go @@ -0,0 +1,35 @@ +package ui + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var FindBaseCommitForFixupScrollsIntoView = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Finding the base commit for a fixup scrolls it into view", + ExtraCmdArgs: []string{}, + Skip: false, + Width: 120, + Height: 30, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.NewBranch("mybranch"). + EmptyCommit("1st commit"). + CreateFileAndAdd("file1", "line 1\nline 2\nline 3\n"). + Commit("base commit"). + CreateNCommits(40). + UpdateFile("file1", "line 1\nline 2 changed\nline 3\n") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + Focus(). + Press(keys.Files.FindBaseCommitForFixup) + + // The base commit is at the very bottom of the list, far below the + // visible area + t.Views().Commits(). + IsFocused(). + SelectedLine(Contains("base commit")). + SelectedLineIsVisible() + }, +}) diff --git a/pkg/integration/tests/ui/menu_scroll_position_is_reset.go b/pkg/integration/tests/ui/menu_scroll_position_is_reset.go new file mode 100644 index 00000000000..448e0b99542 --- /dev/null +++ b/pkg/integration/tests/ui/menu_scroll_position_is_reset.go @@ -0,0 +1,39 @@ +package ui + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var MenuScrollPositionIsReset = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "A menu that is opened after a scrolled down one starts at the top again", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.CreateFile("myfile", "myfile") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + Press(keys.Universal.OptionMenu) + + t.Views().Menu(). + IsFocused(). + // The first line is a section header, so the first item is at index 1 + SelectedLineIdx(1). + OriginY(0). + Press(keys.Universal.GotoBottom). + OriginYAtLeast(1). + PressEscape() + + t.Views().Files(). + IsFocused(). + Press(keys.Universal.OptionMenu) + + t.Views().Menu(). + IsFocused(). + SelectedLineIdx(1). + OriginY(0) + }, +}) diff --git a/pkg/integration/tests/ui/move_commit_scrolls_selection_into_view.go b/pkg/integration/tests/ui/move_commit_scrolls_selection_into_view.go new file mode 100644 index 00000000000..a665b548d36 --- /dev/null +++ b/pkg/integration/tests/ui/move_commit_scrolls_selection_into_view.go @@ -0,0 +1,31 @@ +package ui + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var MoveCommitScrollsSelectionIntoView = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Moving a commit down scrolls it into view if it isn't visible", + ExtraCmdArgs: []string{}, + Skip: false, + Width: 120, + Height: 30, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.CreateNCommits(40) + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Commits(). + Focus(). + SelectedLine(Contains("commit-40")). + // Scroll the selected commit out of view with the mouse wheel + ScrollWheelDown(). + ScrollWheelDown(). + OriginY(4). + Press(keys.Commits.MoveDownCommit). + SelectedLine(Contains("commit-40")). + SelectedLineIdx(1). + SelectedLineIsVisible() + }, +}) diff --git a/pkg/integration/tests/ui/sub_commits_scroll_position_is_reset.go b/pkg/integration/tests/ui/sub_commits_scroll_position_is_reset.go new file mode 100644 index 00000000000..1de8acb7043 --- /dev/null +++ b/pkg/integration/tests/ui/sub_commits_scroll_position_is_reset.go @@ -0,0 +1,39 @@ +package ui + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var SubCommitsScrollPositionIsReset = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Viewing the commits of a branch again after scrolling down starts at the top again", + ExtraCmdArgs: []string{}, + Skip: false, + Width: 120, + Height: 30, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.CreateNCommits(40) + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Branches(). + Focus(). + PressEnter() + + t.Views().SubCommits(). + IsFocused(). + OriginY(0). + Press(keys.Universal.GotoBottom). + OriginYAtLeast(1). + PressEscape() + + t.Views().Branches(). + IsFocused(). + PressEnter() + + t.Views().SubCommits(). + IsFocused(). + SelectedLineIdx(0). + OriginY(0) + }, +}) From aebf495dce7fa1da4fd30e41ad032b23fb50377a Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 8 Aug 2026 16:01:43 +0200 Subject: [PATCH 5/6] Scroll the selection into view by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ever since scrolling the selection into view became opt-in, we have been fixing the same class of regression by hand, five times so far: a controller moves the selection somewhere new, doesn't say that it wants the view to follow, and the selection ends up off screen. The decision needs facts from two places — whether the selection went somewhere new is known to the list, whether the scroll position is the caller's to manage is known to the caller — and asking every caller for both is what keeps going wrong. The callers that get it wrong are usually not even the ones that moved the selection: they are pass-throughs like postRefreshUpdate, which can't know what a refresh did to the selection. So default to scrolling, and let the two callers that maintain the scroll position themselves say so. The one case where scrolling is always wrong is a refresh that no user action is behind: a background poll, or a reload of state on window focus, after a subprocess, or after a repo switch. Those must leave the viewport wherever the user last scrolled it to — that is what made the scrolling opt-in in the first place. Both are already marked in RefreshOptions, so the refresh can decide it once, centrally, instead of each caller judging it. A user action that ends in a foreground refresh does now yank the view back to the selection if the user had scrolled away from it. That's a behaviour change, and there may be actions where it turns out to be unwelcome; those we can fix individually, and it beats the ones that don't scroll today. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/gui/context/list_context_trait.go | 2 +- pkg/gui/controllers/helpers/refresh_helper.go | 24 +++++++++++++++---- pkg/gui/controllers/list_controller.go | 14 +++++++---- .../controllers/local_commits_controller.go | 4 ++-- pkg/gui/gui_common.go | 6 ++++- pkg/gui/types/common.go | 7 +++++- pkg/gui/types/context.go | 10 +++++--- pkg/gui/view_helpers.go | 6 ++--- 8 files changed, 53 insertions(+), 20 deletions(-) diff --git a/pkg/gui/context/list_context_trait.go b/pkg/gui/context/list_context_trait.go index 597fc99df29..9ab24cf9b5e 100644 --- a/pkg/gui/context/list_context_trait.go +++ b/pkg/gui/context/list_context_trait.go @@ -89,7 +89,7 @@ func formatListFooter(selectedLineIdx int, length int) string { } func (self *ListContextTrait) HandleFocus(opts types.OnFocusOpts) { - self.FocusLine(opts.ScrollSelectionIntoView) + self.FocusLine(!opts.KeepScrollPosition) self.GetViewTrait().SetHighlight(self.list.Len() > 0) diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 6672fab2656..81eeec4d1e5 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -111,6 +111,14 @@ type refreshEnv struct { // persist its refreshed stat cache. backgroundRoutine bool + // Whether the views this refresh updates must keep the scroll position they + // have. Focusing a list scrolls its selection into view, which is what a + // user action should do — but a refresh that no user action is behind must + // leave the viewport wherever the user last scrolled it to. That's the case + // for the unattended background routines, and for the refreshes that merely + // reload state (see RefreshOptions.DontBlockRepoSwitch). + keepScrollPosition bool + // the repo generation captured when the refresh started generation int @@ -220,8 +228,9 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr // against the repo it started in, and the generation guard drops its // writes. env := refreshEnv{ - background: options.Background || options.DontBlockRepoSwitch, - backgroundRoutine: options.Background, + background: options.Background || options.DontBlockRepoSwitch, + backgroundRoutine: options.Background, + keepScrollPosition: options.Background || options.DontBlockRepoSwitch, } if !self.captureOnUIThread(calledFromWorker, env.background, func() { env.generation = self.c.State().GetRepoGeneration() @@ -1595,7 +1604,11 @@ func (self *RefreshHelper) refreshView(context types.Context, env refreshEnv) { // the filtered list model is up to date for rendering. self.searchHelper.ReApplyFilter(context) - self.c.PostRefreshUpdate(context) + if env.keepScrollPosition { + self.c.PostRefreshUpdateKeepingScrollPosition(context) + } else { + self.c.PostRefreshUpdate(context) + } self.c.AfterLayout(func() error { // Re-applying the search must be done after re-rendering the view though, @@ -1771,7 +1784,10 @@ func (self *RefreshHelper) setGithubPullRequests(baseInfo *githubRemoteInfo, bra // the branches and remotes as they are on the UI thread, after their // own refreshes' bounces have applied. self.rebuildPullRequestsMap() - self.c.PostRefreshUpdate(self.c.Contexts().Branches) + // This lands whenever the network call happens to return, and only + // changes how the branches are rendered, not which one is selected, so + // it has no business moving the viewport. + self.c.PostRefreshUpdateKeepingScrollPosition(self.c.Contexts().Branches) }) } diff --git a/pkg/gui/controllers/list_controller.go b/pkg/gui/controllers/list_controller.go index 8136c6aa9ae..c073e51413d 100644 --- a/pkg/gui/controllers/list_controller.go +++ b/pkg/gui/controllers/list_controller.go @@ -136,7 +136,7 @@ func (self *ListController) handleLineChangeAux(f func(int), change int) error { self.context.SetNeedRerenderVisibleLines() } - self.context.HandleFocus(types.OnFocusOpts{ScrollSelectionIntoView: true}) + self.context.HandleFocus(types.OnFocusOpts{}) } else { // If the selection did not change (because, for example, we are at the top of the list and // press up), we still want to ensure that the selection is visible. This is useful after @@ -205,9 +205,10 @@ func (self *ListController) handlePageChange(delta int) error { // must tell it explicitly to rerender. self.context.SetNeedRerenderVisibleLines() - // Since we are maintaining the scroll position ourselves above, there's no point in passing - // ScrollSelectionIntoView=true here. - self.context.HandleFocus(types.OnFocusOpts{}) + // This function scrolls the view itself, keeping the selection at the edge of + // the viewport rather than in its middle, so the scroll position is ours to + // maintain, not the focus mechanism's. + self.context.HandleFocus(types.OnFocusOpts{KeepScrollPosition: true}) return nil } @@ -280,7 +281,10 @@ func (self *ListController) selectRangeThroughViewIndex(viewIndex int) { newSelectedLineIdx := self.context.ViewIndexToModelIndex(viewIndex) list.ExpandNonStickyRange(newSelectedLineIdx - list.GetSelectedLineIdx()) - self.context.HandleFocus(types.OnFocusOpts{}) + // The pointer can be outside the viewport, in which case so is the end of + // the range; the drag autoscroller takes care of following it, one line at a + // time, for as long as the pointer stays there. + self.context.HandleFocus(types.OnFocusOpts{KeepScrollPosition: true}) } func (self *ListController) handleDragAutoscroll(viewIndex int) bool { diff --git a/pkg/gui/controllers/local_commits_controller.go b/pkg/gui/controllers/local_commits_controller.go index 642263d129c..0a02e73985d 100644 --- a/pkg/gui/controllers/local_commits_controller.go +++ b/pkg/gui/controllers/local_commits_controller.go @@ -1171,7 +1171,7 @@ func (self *LocalCommitsController) move( return err } self.context().MoveSelection(offset) - self.context().HandleFocus(types.OnFocusOpts{ScrollSelectionIntoView: true}) + self.context().HandleFocus(types.OnFocusOpts{}) // Block input until the refresh has landed: a quick second press must // read the moved todo from the refreshed model, not grab whatever the @@ -1204,7 +1204,7 @@ func (self *LocalCommitsController) move( Then: func() error { if err == nil { self.context().MoveSelection(offset) - self.context().HandleFocus(types.OnFocusOpts{ScrollSelectionIntoView: true}) + self.context().HandleFocus(types.OnFocusOpts{}) } if onComplete != nil { return onComplete() diff --git a/pkg/gui/gui_common.go b/pkg/gui/gui_common.go index e7b14ba048c..d92284ea563 100644 --- a/pkg/gui/gui_common.go +++ b/pkg/gui/gui_common.go @@ -39,7 +39,11 @@ func (self *guiCommon) RefreshFromWorker(opts types.RefreshOptions) { } func (self *guiCommon) PostRefreshUpdate(context types.Context) { - self.gui.postRefreshUpdate(context) + self.gui.postRefreshUpdate(context, false) +} + +func (self *guiCommon) PostRefreshUpdateKeepingScrollPosition(context types.Context) { + self.gui.postRefreshUpdate(context, true) } func (self *guiCommon) RunSubprocessAndRefresh(cmdObj *oscommands.CmdObj) error { diff --git a/pkg/gui/types/common.go b/pkg/gui/types/common.go index 75cb530186e..ff73b91f643 100644 --- a/pkg/gui/types/common.go +++ b/pkg/gui/types/common.go @@ -48,8 +48,13 @@ type IGuiCommon interface { RefreshFromWorker(RefreshOptions) // we call this when we've changed something in the view model but not the actual model, // e.g. expanding or collapsing a folder in a file view. Calling 'Refresh' in this - // case would be overkill, although refresh will internally call 'PostRefreshUpdate' + // case would be overkill, although refresh will internally call 'PostRefreshUpdate'. + // It re-focuses the context's selection, which scrolls it into view. PostRefreshUpdate(Context) + // Like PostRefreshUpdate, but leaves the view scrolled where it is. For + // refreshes that no user action is behind: those must not move the viewport + // away from wherever the user last put it. + PostRefreshUpdateKeepingScrollPosition(Context) // renders string to a view without resetting its origin SetViewContent(view *gocui.View, content string) diff --git a/pkg/gui/types/context.go b/pkg/gui/types/context.go index 416b39b957f..35662d86c5d 100644 --- a/pkg/gui/types/context.go +++ b/pkg/gui/types/context.go @@ -227,9 +227,13 @@ type IViewTrait interface { } type OnFocusOpts struct { - ClickedWindowName string - ClickedViewLineIdx int - ScrollSelectionIntoView bool + ClickedWindowName string + ClickedViewLineIdx int + + // Focusing a list context scrolls its selection into view. Set this to leave + // the view's scroll position alone instead; only for callers that maintain + // it themselves, e.g. by keeping the selection at the edge of the viewport. + KeepScrollPosition bool } type OnFocusLostOpts struct { diff --git a/pkg/gui/view_helpers.go b/pkg/gui/view_helpers.go index d139984fa01..e9ad48aabf5 100644 --- a/pkg/gui/view_helpers.go +++ b/pkg/gui/view_helpers.go @@ -132,7 +132,7 @@ func (gui *Gui) renderContentOnly() { // postRefreshUpdate is to be called on a context after the state that it depends on has been refreshed // if the context's view is set to another context we do nothing. // if the context's view is the current view we trigger a focus; re-selecting the current item. -func (gui *Gui) postRefreshUpdate(c types.Context) { +func (gui *Gui) postRefreshUpdate(c types.Context, keepScrollPosition bool) { t := time.Now() defer func() { gui.Log.Infof("postRefreshUpdate for %s took %s", c.GetKey(), time.Since(t)) @@ -141,14 +141,14 @@ func (gui *Gui) postRefreshUpdate(c types.Context) { c.HandleRender() if gui.currentViewName() == c.GetViewName() { - c.HandleFocus(types.OnFocusOpts{}) + c.HandleFocus(types.OnFocusOpts{KeepScrollPosition: keepScrollPosition}) } else { // The FocusLine call is included in the HandleFocus method which we // call for focused views above; but we need to call it here for // non-focused views to ensure that an inactive selection is painted // correctly, and that integration tests see the up to date selection // state. - c.FocusLine(false) + c.FocusLine(!keepScrollPosition) currentCtx := gui.State.ContextMgr.Current() if currentCtx.GetKey() == context.NORMAL_MAIN_CONTEXT_KEY || currentCtx.GetKey() == context.NORMAL_SECONDARY_CONTEXT_KEY { From 5c4c7139d7f467ce01834927576377cf0ecbc4a0 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 8 Aug 2026 16:14:40 +0200 Subject: [PATCH 6/6] Remove the scroll calls that are now redundant Every one of these did by hand what focusing the list now does on its own: five hand-added scroll requests, and four origin resets that paired a "select the first item" with a "and show the top of the list". The scroll that the commits refresh performed when it found the selected commit at a new index goes too. It is now unconditional for a foreground refresh, and deliberately absent for a background one: when an agent commits in another window, we would rather see the new commits arrive than have the view yank itself back to the commit we had selected. The one origin reset that stays is the one in ReApplyFilter, which runs as part of a refresh and so can't rely on the refresh scrolling. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/gui/controllers/helpers/fixup_helper.go | 1 - pkg/gui/controllers/helpers/mode_helper.go | 5 --- pkg/gui/controllers/helpers/refresh_helper.go | 33 ++++--------------- .../helpers/refresh_helper_test.go | 11 +------ pkg/gui/controllers/helpers/search_helper.go | 4 ++- .../controllers/helpers/sub_commits_helper.go | 1 - pkg/gui/controllers/stash_controller.go | 1 - pkg/gui/menu_panel.go | 2 -- 8 files changed, 10 insertions(+), 48 deletions(-) diff --git a/pkg/gui/controllers/helpers/fixup_helper.go b/pkg/gui/controllers/helpers/fixup_helper.go index e8fa43f2dcd..e998c2ad10e 100644 --- a/pkg/gui/controllers/helpers/fixup_helper.go +++ b/pkg/gui/controllers/helpers/fixup_helper.go @@ -141,7 +141,6 @@ func (self *FixupHelper) HandleFindBaseCommitForFixupPress() error { } self.c.Contexts().LocalCommits.SetSelection(index) - self.c.Contexts().LocalCommits.FocusLine(true) self.c.Context().Push(self.c.Contexts().LocalCommits, types.OnFocusOpts{}) return nil }, diff --git a/pkg/gui/controllers/helpers/mode_helper.go b/pkg/gui/controllers/helpers/mode_helper.go index 6e5139744b6..68f7ea14913 100644 --- a/pkg/gui/controllers/helpers/mode_helper.go +++ b/pkg/gui/controllers/helpers/mode_helper.go @@ -270,11 +270,6 @@ func (self *ModeHelper) changeFiltering(setFilter func(), selectCommit func()) e selectCommit() self.c.PostRefreshUpdate(self.c.Contexts().LocalCommits) - // The list we just selected in has nothing to do with the one - // that was showing, so wherever it was scrolled to says nothing - // about where the selection now is. PostRefreshUpdate leaves the - // scroll position alone, so ask for it separately. - self.c.Contexts().LocalCommits.FocusLine(true) return nil }, }) diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 81eeec4d1e5..d076ced0a5d 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -851,33 +851,21 @@ func (self *RefreshHelper) refreshCommitsWithLimit(captured capturedCommitState, self.c.Model().CheckedOutBranch = "" } - scrollSelectionIntoView := false switch commitSelection { case types.SelectHeadCommit: if headCommitIdx := models.HeadCommitIdx(commits); headCommitIdx >= 0 { self.c.Contexts().LocalCommits.SetSelection(headCommitIdx) - scrollSelectionIntoView = true } case types.KeepCommitSelectionByHash: if selectionRange != nil { - selectedIdx, rangeStartIdx, didMove, found := findLocalCommitSelectionRange(commits, selectionRange) + selectedIdx, rangeStartIdx, found := findLocalCommitSelectionRange(commits, selectionRange) if found { self.c.Contexts().LocalCommits.SetSelectionRangeAndMode(selectedIdx, rangeStartIdx, selectionRange.mode) - scrollSelectionIntoView = didMove } } case types.KeepCommitSelectionIndex: // The caller set the selection index deliberately; leave it untouched. } - - if scrollSelectionIntoView { - // Enqueued from within this bounce so it runs after refreshView's - // render below (which was enqueued first), matching the previous - // ordering where FocusLine ran after the view was re-rendered. - self.onUIThreadUnlessRepoChanged(env, func() { - self.c.Contexts().LocalCommits.FocusLine(true) - }) - } }) self.refreshView(self.c.Contexts().LocalCommits, env) @@ -889,8 +877,6 @@ type localCommitSelectionRange struct { selectedIsTODO bool rangeStartHash string rangeStartIsTODO bool - selectedIdx int - rangeStartIdx int mode traits.RangeSelectMode } @@ -909,8 +895,6 @@ func captureLocalCommitSelectionRange( selectedIsTODO: commits[selectedIdx].IsTODO(), rangeStartHash: commits[rangeStartIdx].Hash(), rangeStartIsTODO: commits[rangeStartIdx].IsTODO(), - selectedIdx: selectedIdx, - rangeStartIdx: rangeStartIdx, mode: mode, } } @@ -918,17 +902,16 @@ func captureLocalCommitSelectionRange( func findLocalCommitSelectionRange( commits []*models.Commit, selectionRange *localCommitSelectionRange, -) (int, int, bool, bool) { +) (int, int, bool) { selectedIdx, foundSelected := findCommitByHashPreferringTODOStatus( commits, selectionRange.selectedHash, selectionRange.selectedIsTODO) rangeStartIdx, foundRangeStart := findCommitByHashPreferringTODOStatus( commits, selectionRange.rangeStartHash, selectionRange.rangeStartIsTODO) if !foundSelected || !foundRangeStart { - return 0, 0, false, false + return 0, 0, false } - didMove := selectedIdx != selectionRange.selectedIdx || rangeStartIdx != selectionRange.rangeStartIdx - return selectedIdx, rangeStartIdx, didMove, true + return selectedIdx, rangeStartIdx, true } // findCommitByHashPreferringTODOStatus finds the commit with the given hash. @@ -1179,10 +1162,8 @@ func (self *RefreshHelper) refreshBranches(captured capturedBranchState, refresh } } case types.SelectCheckedOutBranch: - // The checked-out branch is always at the top of the list. Setting - // the selection doesn't scroll the view, so also reset the origin. + // The checked-out branch is always at the top of the list. self.c.Contexts().Branches.SetSelectedLineIdx(0) - self.c.Contexts().Branches.GetView().SetOriginY(0) } // Need to re-render the commits view because the visualization of local @@ -1473,11 +1454,9 @@ func (self *RefreshHelper) refreshReflogCommits(captured capturedReflogState, en self.c.Model().ReflogCommits = reflogCommits self.c.Model().FilteredReflogCommits = filteredReflogCommits // Setting the selection here, in the same bounce that writes the list, - // keeps it on the UI thread and atomic with the list update. Setting the - // selection doesn't scroll the view, so also reset the origin. + // keeps it on the UI thread and atomic with the list update. if selectTopEntry { self.c.Contexts().ReflogCommits.SetSelectedLineIdx(0) - self.c.Contexts().ReflogCommits.GetView().SetOriginY(0) } }) diff --git a/pkg/gui/controllers/helpers/refresh_helper_test.go b/pkg/gui/controllers/helpers/refresh_helper_test.go index 3a5f6ea829c..d3829127a4c 100644 --- a/pkg/gui/controllers/helpers/refresh_helper_test.go +++ b/pkg/gui/controllers/helpers/refresh_helper_test.go @@ -28,8 +28,6 @@ func TestCaptureLocalCommitSelectionRange(t *testing.T) { expected: &localCommitSelectionRange{ selectedHash: "b", rangeStartHash: "a", - selectedIdx: 1, - rangeStartIdx: 0, mode: traits.RangeSelectModeSticky, }, }, @@ -74,15 +72,12 @@ func TestFindLocalCommitSelectionRange(t *testing.T) { type expectation struct { selectedIdx int rangeStartIdx int - moved bool found bool } selectionRange := localCommitSelectionRange{ selectedHash: "b", rangeStartHash: "c", - selectedIdx: 1, - rangeStartIdx: 2, mode: traits.RangeSelectModeSticky, } @@ -97,7 +92,6 @@ func TestFindLocalCommitSelectionRange(t *testing.T) { expected: expectation{ selectedIdx: 2, rangeStartIdx: 3, - moved: true, found: true, }, }, @@ -126,7 +120,6 @@ func TestFindLocalCommitSelectionRange(t *testing.T) { expected: expectation{ selectedIdx: 2, rangeStartIdx: 3, - moved: true, found: true, }, }, @@ -139,7 +132,6 @@ func TestFindLocalCommitSelectionRange(t *testing.T) { expected: expectation{ selectedIdx: 0, rangeStartIdx: 1, - moved: true, found: true, }, }, @@ -147,11 +139,10 @@ func TestFindLocalCommitSelectionRange(t *testing.T) { for _, testCase := range testCases { t.Run(testCase.name, func(t *testing.T) { - selectedIdx, rangeStartIdx, moved, found := findLocalCommitSelectionRange(testCase.commits, &selectionRange) + selectedIdx, rangeStartIdx, found := findLocalCommitSelectionRange(testCase.commits, &selectionRange) actual := expectation{ selectedIdx: selectedIdx, rangeStartIdx: rangeStartIdx, - moved: moved, found: found, } diff --git a/pkg/gui/controllers/helpers/search_helper.go b/pkg/gui/controllers/helpers/search_helper.go index bc0c938f93a..51f51079263 100644 --- a/pkg/gui/controllers/helpers/search_helper.go +++ b/pkg/gui/controllers/helpers/search_helper.go @@ -225,7 +225,6 @@ func (self *SearchHelper) OnPromptContentChanged(searchString string) { switch context := state.Context.(type) { case types.IFilterableContext: context.SetSelection(0) - context.GetView().SetOriginY(0) context.SetFilter(searchString, self.c.UserConfig().Gui.UseFuzzySearch()) self.c.PostRefreshUpdate(context) case types.ISearchableContext: @@ -241,6 +240,9 @@ func (self *SearchHelper) ReApplyFilter(context types.Context) { state := self.searchState() if context == state.Context && self.c.Context().Current().GetKey() == self.c.Contexts().Search.GetKey() { filterableContext.SetSelection(0) + // This runs as part of a refresh, and a refresh that no user action + // is behind keeps the scroll position, which would leave the view + // scrolled somewhere the filtered list no longer has anything at. filterableContext.GetView().SetOriginY(0) } filterableContext.ReApplyFilter(self.c.UserConfig().Gui.UseFuzzySearch()) diff --git a/pkg/gui/controllers/helpers/sub_commits_helper.go b/pkg/gui/controllers/helpers/sub_commits_helper.go index 7bd92882693..09f32d1a99c 100644 --- a/pkg/gui/controllers/helpers/sub_commits_helper.go +++ b/pkg/gui/controllers/helpers/sub_commits_helper.go @@ -66,7 +66,6 @@ func (self *SubCommitsHelper) ViewSubCommits(opts ViewSubCommitsOpts) error { subCommitsContext.GetView().TitlePrefix = opts.Context.GetView().TitlePrefix self.c.PostRefreshUpdate(self.c.Contexts().SubCommits) - subCommitsContext.FocusLine(true) self.c.Context().Push(self.c.Contexts().SubCommits, types.OnFocusOpts{}) return nil diff --git a/pkg/gui/controllers/stash_controller.go b/pkg/gui/controllers/stash_controller.go index 03011e421c2..7730587f488 100644 --- a/pkg/gui/controllers/stash_controller.go +++ b/pkg/gui/controllers/stash_controller.go @@ -251,7 +251,6 @@ func (self *StashController) handleRenameStashEntry(stashEntry *models.StashEntr return err } self.context().SetSelection(0) // Select the renamed stash - self.context().FocusLine(true) // Renaming re-creates the stash at the top, shifting the other // entries' indices; block input so that a quick next action sees // the refreshed list rather than the stale indices. diff --git a/pkg/gui/menu_panel.go b/pkg/gui/menu_panel.go index 23016b9a504..0ddefdbeefc 100644 --- a/pkg/gui/menu_panel.go +++ b/pkg/gui/menu_panel.go @@ -72,8 +72,6 @@ func (gui *Gui) createMenu(opts types.CreateMenuOptions) error { gui.State.Contexts.Menu.SetOnCancel(opts.OnCancel) gui.State.Contexts.Menu.SetSelection(0) - gui.Views.Menu.SetOriginY(0) - gui.Views.Menu.Title = opts.Title gui.Views.Menu.FgColor = theme.GocuiDefaultTextColor