From 37bc0dfc4479497edebe25729a744337633aa30c Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 23 Dec 2025 11:13:55 +0100 Subject: [PATCH 1/3] Extract a method for selecting the first branch (and first commit) We want to do this whenever we switch branches; it wasn't done consistently though. There are many different ways to switch branches, and only some of these would reset the selection of all three panels (branches, commits, and reflog). --- pkg/gui/controllers/branches_controller.go | 2 +- pkg/gui/controllers/helpers/refs_helper.go | 20 +++++++++++--------- pkg/gui/controllers/remotes_controller.go | 2 +- 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/pkg/gui/controllers/branches_controller.go b/pkg/gui/controllers/branches_controller.go index f430f7b0ef8..c796e19ab9e 100644 --- a/pkg/gui/controllers/branches_controller.go +++ b/pkg/gui/controllers/branches_controller.go @@ -531,7 +531,7 @@ func (self *BranchesController) createNewBranchWithName(newBranchName string) er return err } - self.context().SetSelection(0) + self.c.Helpers().Refs.SelectFirstBranchAndFirstCommit() self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, KeepBranchSelectionIndex: true}) return nil } diff --git a/pkg/gui/controllers/helpers/refs_helper.go b/pkg/gui/controllers/helpers/refs_helper.go index 1976bf3867d..ba8807f5c0a 100644 --- a/pkg/gui/controllers/helpers/refs_helper.go +++ b/pkg/gui/controllers/helpers/refs_helper.go @@ -31,6 +31,12 @@ func NewRefsHelper( } } +func (self *RefsHelper) SelectFirstBranchAndFirstCommit() { + self.c.Contexts().Branches.SetSelection(0) + self.c.Contexts().ReflogCommits.SetSelection(0) + self.c.Contexts().LocalCommits.SetSelection(0) +} + func (self *RefsHelper) CheckoutRef(ref string, options types.CheckoutRefOptions) error { waitingStatus := options.WaitingStatus if waitingStatus == "" { @@ -40,9 +46,8 @@ func (self *RefsHelper) CheckoutRef(ref string, options types.CheckoutRefOptions cmdOptions := git_commands.CheckoutOptions{Force: false, EnvVars: options.EnvVars} refresh := func() { - self.c.Contexts().Branches.SetSelection(0) - self.c.Contexts().ReflogCommits.SetSelection(0) - self.c.Contexts().LocalCommits.SetSelection(0) + self.SelectFirstBranchAndFirstCommit() + // loading a heap of commits is slow so we limit them whenever doing a reset self.c.Contexts().LocalCommits.SetLimitCommits(true) @@ -348,8 +353,7 @@ func (self *RefsHelper) NewBranch(from string, fromFormattedName string, suggest self.c.Context().Push(self.c.Contexts().Branches, types.OnFocusOpts{}) } - self.c.Contexts().LocalCommits.SetSelection(0) - self.c.Contexts().Branches.SetSelection(0) + self.SelectFirstBranchAndFirstCommit() self.c.Refresh(types.RefreshOptions{Mode: types.BLOCK_UI, KeepBranchSelectionIndex: true}) } @@ -504,8 +508,7 @@ func (self *RefsHelper) moveCommitsToNewBranchStackedOnCurrentBranch(newBranchNa } } - self.c.Contexts().LocalCommits.SetSelection(0) - self.c.Contexts().Branches.SetSelection(0) + self.SelectFirstBranchAndFirstCommit() self.c.Refresh(types.RefreshOptions{Mode: types.BLOCK_UI, KeepBranchSelectionIndex: true}) return nil @@ -543,8 +546,7 @@ func (self *RefsHelper) moveCommitsToNewBranchOffOfMainBranch(newBranchName stri } } - self.c.Contexts().LocalCommits.SetSelection(0) - self.c.Contexts().Branches.SetSelection(0) + self.SelectFirstBranchAndFirstCommit() self.c.Refresh(types.RefreshOptions{Mode: types.BLOCK_UI, KeepBranchSelectionIndex: true}) return nil diff --git a/pkg/gui/controllers/remotes_controller.go b/pkg/gui/controllers/remotes_controller.go index bd88b1d583f..e1f08bc7d0b 100644 --- a/pkg/gui/controllers/remotes_controller.go +++ b/pkg/gui/controllers/remotes_controller.go @@ -368,7 +368,7 @@ func (self *RemotesController) fetchAndCheckout(remote *models.Remote, branchNam err = self.c.Git().Branch.New(branchName, remote.Name+"/"+branchName) if err == nil { self.c.Context().Push(self.c.Contexts().Branches, types.OnFocusOpts{}) - self.c.Contexts().Branches.SetSelection(0) + self.c.Helpers().Refs.SelectFirstBranchAndFirstCommit() refreshOptions.KeepBranchSelectionIndex = true } } From efd4298b5e91c0769a7cc413de4590c6656c7aed Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 22 Dec 2025 16:08:29 +0100 Subject: [PATCH 2/3] Avoid scrolling the selection into view on refresh It is possible to scroll the selection out of view using the mouse wheel; after doing this, it would sometimes scroll into view by itself again, for example when a background fetch occurred. In the files panel this would even happen every 10s with every regular files refresh. Fix this by adding a scrollIntoView parameter to HandleFocus, which is false by default, and is only set to true from controllers that change the selection. --- go.mod | 2 +- go.sum | 4 ++-- pkg/gui/context/list_context_trait.go | 8 ++++---- pkg/gui/context/simple_context.go | 2 +- pkg/gui/context/view_trait.go | 4 ++-- pkg/gui/controllers/helpers/cherry_pick_helper.go | 2 +- pkg/gui/controllers/helpers/refs_helper.go | 3 +++ pkg/gui/controllers/list_controller.go | 4 +++- pkg/gui/controllers/local_commits_controller.go | 2 +- pkg/gui/controllers/stash_controller.go | 2 +- pkg/gui/types/context.go | 9 +++++---- pkg/gui/view_helpers.go | 2 +- pkg/integration/clients/tui.go | 4 ++-- vendor/github.com/jesseduffield/gocui/view.go | 11 +++++++---- vendor/modules.txt | 2 +- 15 files changed, 35 insertions(+), 26 deletions(-) diff --git a/go.mod b/go.mod index 4cd96901ad5..278aebf6b58 100644 --- a/go.mod +++ b/go.mod @@ -18,7 +18,7 @@ require ( github.com/integrii/flaggy v1.4.0 github.com/jesseduffield/generics v0.0.0-20250517122708-b0b4a53a6f5c github.com/jesseduffield/go-git/v5 v5.14.1-0.20250407170251-e1a013310ccd - github.com/jesseduffield/gocui v0.3.1-0.20251214132308-02ab34c1c624 + github.com/jesseduffield/gocui v0.3.1-0.20251223143206-950739ccd44a github.com/jesseduffield/lazycore v0.0.0-20221012050358-03d2e40243c5 github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0 github.com/karimkhaleel/jsonschema v0.0.0-20231001195015-d933f0d94ea3 diff --git a/go.sum b/go.sum index 70fbbbe6ffd..5ef0c6593c0 100644 --- a/go.sum +++ b/go.sum @@ -194,8 +194,8 @@ github.com/jesseduffield/generics v0.0.0-20250517122708-b0b4a53a6f5c h1:tC2Paiis github.com/jesseduffield/generics v0.0.0-20250517122708-b0b4a53a6f5c/go.mod h1:F2fEBk0ddf6ixrBrJjY7phfQ3hL9rXG0uSjvwYe50bE= github.com/jesseduffield/go-git/v5 v5.14.1-0.20250407170251-e1a013310ccd h1:ViKj6qth8FgcIWizn9KiACWwPemWSymx62OPN0tHT+Q= github.com/jesseduffield/go-git/v5 v5.14.1-0.20250407170251-e1a013310ccd/go.mod h1:lRhCiBr6XjQrvcQVa+UYsy/99d3wMXn/a0nSQlhnhlA= -github.com/jesseduffield/gocui v0.3.1-0.20251214132308-02ab34c1c624 h1:30mIX4f52zrO4fWfZQKHJG29t2apcSOtR/sbd3BNsVE= -github.com/jesseduffield/gocui v0.3.1-0.20251214132308-02ab34c1c624/go.mod h1:sLIyZ2J42R6idGdtemZzsiR3xY5EF0KsvYEGh3dQv3s= +github.com/jesseduffield/gocui v0.3.1-0.20251223143206-950739ccd44a h1:XRsyqrSljes4TlaPczQViIAA4xqdnB0fKEEpZdqWWTA= +github.com/jesseduffield/gocui v0.3.1-0.20251223143206-950739ccd44a/go.mod h1:sLIyZ2J42R6idGdtemZzsiR3xY5EF0KsvYEGh3dQv3s= github.com/jesseduffield/lazycore v0.0.0-20221012050358-03d2e40243c5 h1:CDuQmfOjAtb1Gms6a1p5L2P8RhbLUq5t8aL7PiQd2uY= github.com/jesseduffield/lazycore v0.0.0-20221012050358-03d2e40243c5/go.mod h1:qxN4mHOAyeIDLP7IK7defgPClM/z1Kze8VVQiaEjzsQ= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= diff --git a/pkg/gui/context/list_context_trait.go b/pkg/gui/context/list_context_trait.go index cc1d402c067..2ea58360bfd 100644 --- a/pkg/gui/context/list_context_trait.go +++ b/pkg/gui/context/list_context_trait.go @@ -25,8 +25,8 @@ type ListContextTrait struct { func (self *ListContextTrait) IsListContext() {} -func (self *ListContextTrait) FocusLine() { - self.Context.FocusLine() +func (self *ListContextTrait) FocusLine(scrollIntoView bool) { + self.Context.FocusLine(scrollIntoView) // Doing this at the end of the layout function because we need the view to be // resized before we focus the line, otherwise if we're in accordion mode @@ -36,7 +36,7 @@ func (self *ListContextTrait) FocusLine() { oldOrigin, _ := self.GetViewTrait().ViewPortYBounds() self.GetViewTrait().FocusPoint( - self.ModelIndexToViewIndex(self.list.GetSelectedLineIdx())) + self.ModelIndexToViewIndex(self.list.GetSelectedLineIdx()), scrollIntoView) selectRangeIndex, isSelectingRange := self.list.GetRangeStartIdx() if isSelectingRange { @@ -75,7 +75,7 @@ func formatListFooter(selectedLineIdx int, length int) string { } func (self *ListContextTrait) HandleFocus(opts types.OnFocusOpts) { - self.FocusLine() + self.FocusLine(opts.ScrollSelectionIntoView) self.GetViewTrait().SetHighlight(self.list.Len() > 0) diff --git a/pkg/gui/context/simple_context.go b/pkg/gui/context/simple_context.go index a5b051b9349..f51d3dc5c99 100644 --- a/pkg/gui/context/simple_context.go +++ b/pkg/gui/context/simple_context.go @@ -54,7 +54,7 @@ func (self *SimpleContext) HandleFocusLost(opts types.OnFocusLostOpts) { } } -func (self *SimpleContext) FocusLine() { +func (self *SimpleContext) FocusLine(scrollIntoView bool) { } func (self *SimpleContext) HandleRender() { diff --git a/pkg/gui/context/view_trait.go b/pkg/gui/context/view_trait.go index ccf7d3e9623..9a30bde6f77 100644 --- a/pkg/gui/context/view_trait.go +++ b/pkg/gui/context/view_trait.go @@ -17,8 +17,8 @@ func NewViewTrait(view *gocui.View) *ViewTrait { return &ViewTrait{view: view} } -func (self *ViewTrait) FocusPoint(yIdx int) { - self.view.FocusPoint(self.view.OriginX(), yIdx) +func (self *ViewTrait) FocusPoint(yIdx int, scrollIntoView bool) { + self.view.FocusPoint(self.view.OriginX(), yIdx, scrollIntoView) } func (self *ViewTrait) SetRangeSelectStart(yIdx int) { diff --git a/pkg/gui/controllers/helpers/cherry_pick_helper.go b/pkg/gui/controllers/helpers/cherry_pick_helper.go index c4be07ceb28..359d1cbc299 100644 --- a/pkg/gui/controllers/helpers/cherry_pick_helper.go +++ b/pkg/gui/controllers/helpers/cherry_pick_helper.go @@ -101,7 +101,7 @@ func (self *CherryPickHelper) Paste() error { // below the selection. if commit := self.c.Contexts().LocalCommits.GetSelected(); commit != nil && !commit.IsTODO() { self.c.Contexts().LocalCommits.MoveSelection(len(cherryPickedCommits)) - self.c.Contexts().LocalCommits.FocusLine() + self.c.Contexts().LocalCommits.FocusLine(true) } // If we're in the cherry-picking state at this point, it must diff --git a/pkg/gui/controllers/helpers/refs_helper.go b/pkg/gui/controllers/helpers/refs_helper.go index ba8807f5c0a..d52d9bbad10 100644 --- a/pkg/gui/controllers/helpers/refs_helper.go +++ b/pkg/gui/controllers/helpers/refs_helper.go @@ -35,6 +35,9 @@ func (self *RefsHelper) SelectFirstBranchAndFirstCommit() { self.c.Contexts().Branches.SetSelection(0) self.c.Contexts().ReflogCommits.SetSelection(0) self.c.Contexts().LocalCommits.SetSelection(0) + self.c.Contexts().Branches.GetView().SetOriginY(0) + self.c.Contexts().ReflogCommits.GetView().SetOriginY(0) + self.c.Contexts().LocalCommits.GetView().SetOriginY(0) } func (self *RefsHelper) CheckoutRef(ref string, options types.CheckoutRefOptions) error { diff --git a/pkg/gui/controllers/list_controller.go b/pkg/gui/controllers/list_controller.go index f772fb3d8c5..27c3f402eff 100644 --- a/pkg/gui/controllers/list_controller.go +++ b/pkg/gui/controllers/list_controller.go @@ -116,7 +116,7 @@ func (self *ListController) handleLineChangeAux(f func(int), change int) error { } if cursorMoved || rangeBefore != rangeAfter { - self.context.HandleFocus(types.OnFocusOpts{}) + self.context.HandleFocus(types.OnFocusOpts{ScrollSelectionIntoView: true}) } return nil @@ -173,6 +173,8 @@ func (self *ListController) handlePageChange(delta int) error { } } + // Since we are maintaining the scroll position ourselves above, there's no point in passing + // ScrollSelectionIntoView=true here. self.context.HandleFocus(types.OnFocusOpts{}) return nil diff --git a/pkg/gui/controllers/local_commits_controller.go b/pkg/gui/controllers/local_commits_controller.go index 5b76fd700e7..1091b91782f 100644 --- a/pkg/gui/controllers/local_commits_controller.go +++ b/pkg/gui/controllers/local_commits_controller.go @@ -869,7 +869,7 @@ func (self *LocalCommitsController) revert(commits []*models.Commit, start, end return err } self.context().MoveSelection(len(commits)) - self.context().HandleFocus(types.OnFocusOpts{}) + self.context().HandleFocus(types.OnFocusOpts{ScrollSelectionIntoView: true}) if mustStash { if err := self.c.Git().Stash.Pop(0); err != nil { diff --git a/pkg/gui/controllers/stash_controller.go b/pkg/gui/controllers/stash_controller.go index 87f84d7ea6c..7027cd5fec1 100644 --- a/pkg/gui/controllers/stash_controller.go +++ b/pkg/gui/controllers/stash_controller.go @@ -205,7 +205,7 @@ func (self *StashController) handleRenameStashEntry(stashEntry *models.StashEntr return err } self.context().SetSelection(0) // Select the renamed stash - self.context().FocusLine() + self.context().FocusLine(true) self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.STASH}}) return nil }, diff --git a/pkg/gui/types/context.go b/pkg/gui/types/context.go index 917342776e3..339cf2b492a 100644 --- a/pkg/gui/types/context.go +++ b/pkg/gui/types/context.go @@ -109,7 +109,7 @@ type Context interface { HandleFocus(opts OnFocusOpts) HandleFocusLost(opts OnFocusLostOpts) - FocusLine() + FocusLine(scrollIntoView bool) HandleRender() HandleRenderToMain() } @@ -201,7 +201,7 @@ type IPatchExplorerContext interface { } type IViewTrait interface { - FocusPoint(yIdx int) + FocusPoint(yIdx int, scrollIntoView bool) SetRangeSelectStart(yIdx int) CancelRangeSelect() SetViewPortContent(content string) @@ -221,8 +221,9 @@ type IViewTrait interface { } type OnFocusOpts struct { - ClickedWindowName string - ClickedViewLineIdx int + ClickedWindowName string + ClickedViewLineIdx int + ScrollSelectionIntoView bool } type OnFocusLostOpts struct { diff --git a/pkg/gui/view_helpers.go b/pkg/gui/view_helpers.go index 3dfea4c399c..b8ea49c4488 100644 --- a/pkg/gui/view_helpers.go +++ b/pkg/gui/view_helpers.go @@ -140,7 +140,7 @@ func (gui *Gui) postRefreshUpdate(c types.Context) { // 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() + c.FocusLine(false) currentCtx := gui.State.ContextMgr.Current() if currentCtx.GetKey() == context.NORMAL_MAIN_CONTEXT_KEY || currentCtx.GetKey() == context.NORMAL_SECONDARY_CONTEXT_KEY { diff --git a/pkg/integration/clients/tui.go b/pkg/integration/clients/tui.go index 992f107e877..13b03726e4a 100644 --- a/pkg/integration/clients/tui.go +++ b/pkg/integration/clients/tui.go @@ -51,7 +51,7 @@ func RunTUI(raceDetector bool) { if err != nil { return err } - listView.FocusPoint(0, app.itemIdx) + listView.FocusPoint(0, app.itemIdx, true) return nil }); err != nil { log.Panicln(err) @@ -66,7 +66,7 @@ func RunTUI(raceDetector bool) { if err != nil { return err } - listView.FocusPoint(0, app.itemIdx) + listView.FocusPoint(0, app.itemIdx, true) return nil }); err != nil { log.Panicln(err) diff --git a/vendor/github.com/jesseduffield/gocui/view.go b/vendor/github.com/jesseduffield/gocui/view.go index 70cbd9735e4..87f61eebd31 100644 --- a/vendor/github.com/jesseduffield/gocui/view.go +++ b/vendor/github.com/jesseduffield/gocui/view.go @@ -262,7 +262,7 @@ func (v *View) SelectSearchResult(index int) error { y := v.searcher.searchPositions[index].Y - v.FocusPoint(v.ox, y) + v.FocusPoint(v.ox, y, true) if v.searcher.onSelectItem != nil { return v.searcher.onSelectItem(y, index, itemCount) } @@ -325,14 +325,17 @@ func (v *View) IsSearching() bool { return v.searcher.searchString != "" } -func (v *View) FocusPoint(cx int, cy int) { +func (v *View) FocusPoint(cx int, cy int, scrollIntoView bool) { lineCount := len(v.lines) if cy < 0 || cy > lineCount { return } - height := v.InnerHeight() - v.oy = calculateNewOrigin(cy, v.oy, lineCount, height) + if scrollIntoView { + height := v.InnerHeight() + v.oy = calculateNewOrigin(cy, v.oy, lineCount, height) + } + v.cx = cx v.cy = cy - v.oy } diff --git a/vendor/modules.txt b/vendor/modules.txt index eaf7b8c7c1c..49ff6fe7ae0 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -221,7 +221,7 @@ github.com/jesseduffield/go-git/v5/utils/merkletrie/internal/frame github.com/jesseduffield/go-git/v5/utils/merkletrie/noder github.com/jesseduffield/go-git/v5/utils/sync github.com/jesseduffield/go-git/v5/utils/trace -# github.com/jesseduffield/gocui v0.3.1-0.20251214132308-02ab34c1c624 +# github.com/jesseduffield/gocui v0.3.1-0.20251223143206-950739ccd44a ## explicit; go 1.12 github.com/jesseduffield/gocui # github.com/jesseduffield/lazycore v0.0.0-20221012050358-03d2e40243c5 From 478d51c83e4a6c5fba9521e8895c4737cf0cdd5e Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 22 Dec 2025 17:58:05 +0100 Subject: [PATCH 3/3] When pressing up or down, scroll selection into view if it is outside, even if it didn't change We have this logic to avoid constantly rerendering the main view when hitting up-arrow when you are already at the top of the list. That's good, but we do want to scroll the selection into view if it is outside and you hit up or down, no matter if it changed. --- pkg/gui/controllers/list_controller.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pkg/gui/controllers/list_controller.go b/pkg/gui/controllers/list_controller.go index 27c3f402eff..a9107439269 100644 --- a/pkg/gui/controllers/list_controller.go +++ b/pkg/gui/controllers/list_controller.go @@ -117,6 +117,11 @@ func (self *ListController) handleLineChangeAux(f func(int), change int) error { if cursorMoved || rangeBefore != rangeAfter { self.context.HandleFocus(types.OnFocusOpts{ScrollSelectionIntoView: true}) + } 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 + // scrolling the selection out of view with the mouse. + self.context.FocusLine(true) } return nil