jesseduffield.lazygit/pkg/gui/context/list_context_trait.go
Stefan Haller aebf495dce Scroll the selection into view by default
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) <noreply@anthropic.com>
2026-08-14 08:04:13 +02:00

174 lines
5.6 KiB
Go

package context
import (
"fmt"
"github.com/jesseduffield/lazygit/pkg/gui/types"
)
type ListContextTrait struct {
types.Context
ListRenderer
c *ContextCommon
// Some contexts, like the commit context, will highlight the path from the selected commit
// to its parents, because it's ambiguous otherwise. For these, we need to refresh the viewport
// so that we show the highlighted path.
// TODO: now that we allow scrolling, we should be smarter about what gets refreshed:
// we should find out exactly which lines are now part of the path and refresh those.
// We should also keep track of the previous path and refresh those lines too.
refreshViewportOnChange bool
// If this is true, we only render the visible lines of the list. Useful for lists that can
// get very long, because it can save a lot of memory
renderOnlyVisibleLines bool
// If renderOnlyVisibleLines is true, needRerenderVisibleLines indicates whether we need to
// rerender the visible lines e.g. because the scroll position changed
needRerenderVisibleLines bool
// true if we're inside the OnSearchSelect call; in that case we don't want to update the search
// result index.
inOnSearchSelect bool
}
func (self *ListContextTrait) IsListContext() {}
func (self *ListContextTrait) FocusLine(scrollIntoView bool) {
self.Context.FocusLine(scrollIntoView)
// Need to capture this in a local variable because by the time the AfterLayout function runs,
// the field will have been reset to false already
inOnSearchSelect := self.inOnSearchSelect
// 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
// the view could be squashed and won't know how to adjust the cursor/origin.
// Also, refreshing the viewport needs to happen after the view has been resized.
self.c.AfterLayout(func() error {
oldOrigin, _ := self.GetViewTrait().ViewPortYBounds()
self.GetViewTrait().FocusPoint(
self.ModelIndexToViewIndex(self.list.GetSelectedLineIdx()), scrollIntoView)
if !inOnSearchSelect {
self.GetView().SetNearestSearchPosition()
}
selectRangeIndex, isSelectingRange := self.list.GetRangeStartIdx()
if isSelectingRange {
selectRangeIndex = self.ModelIndexToViewIndex(selectRangeIndex)
self.GetViewTrait().SetRangeSelectStart(selectRangeIndex)
} else {
self.GetViewTrait().CancelRangeSelect()
}
if self.refreshViewportOnChange {
self.refreshViewport()
} else if self.renderOnlyVisibleLines {
newOrigin, _ := self.GetViewTrait().ViewPortYBounds()
if oldOrigin != newOrigin || self.needRerenderVisibleLines {
self.refreshViewport()
}
}
return nil
})
self.setFooter()
}
func (self *ListContextTrait) refreshViewport() {
startIdx, length := self.GetViewTrait().ViewPortYBounds()
content := self.renderLines(startIdx, startIdx+length)
self.GetViewTrait().SetViewPortContent(content)
}
func (self *ListContextTrait) setFooter() {
self.GetViewTrait().SetFooter(formatListFooter(self.list.GetSelectedLineIdx(), self.list.Len()))
}
func formatListFooter(selectedLineIdx int, length int) string {
return fmt.Sprintf("%d of %d", selectedLineIdx+1, length)
}
func (self *ListContextTrait) HandleFocus(opts types.OnFocusOpts) {
self.FocusLine(!opts.KeepScrollPosition)
self.GetViewTrait().SetHighlight(self.list.Len() > 0)
self.Context.HandleFocus(opts)
}
func (self *ListContextTrait) HandleFocusLost(opts types.OnFocusLostOpts) {
self.GetViewTrait().SetOriginX(0)
if self.refreshViewportOnChange {
self.refreshViewport()
}
self.Context.HandleFocusLost(opts)
}
// OnFocus assumes that the content of the context has already been rendered to the view. OnRender is the function which actually renders the content to the view
func (self *ListContextTrait) HandleRender() {
self.list.ClampSelection()
if self.renderOnlyVisibleLines {
// Rendering only the visible area can save a lot of cell memory for
// those views that support it.
totalLength := self.list.Len()
if self.getNonModelItems != nil {
totalLength += len(self.getNonModelItems())
}
startIdx, length := self.GetViewTrait().ViewPortYBounds()
content := self.renderLines(startIdx, startIdx+length)
self.GetViewTrait().SetViewPortContentAndClearEverythingElse(totalLength, content)
self.needRerenderVisibleLines = false
} else {
content := self.renderLines(-1, -1)
self.GetViewTrait().SetContent(content)
}
self.setFooter()
}
func (self *ListContextTrait) OnSearchSelect(selectedLineIdx int) {
self.GetList().SetSelection(self.ViewIndexToModelIndex(selectedLineIdx))
self.inOnSearchSelect = true
self.HandleFocus(types.OnFocusOpts{})
self.inOnSearchSelect = false
}
func (self *ListContextTrait) IsItemVisible(item types.HasUrn) bool {
startIdx, length := self.GetViewTrait().ViewPortYBounds()
selectionStart := self.ViewIndexToModelIndex(startIdx)
selectionEnd := self.ViewIndexToModelIndex(startIdx + length)
for i := selectionStart; i < selectionEnd; i++ {
iterItem := self.GetList().GetItem(i)
if iterItem != nil && iterItem.URN() == item.URN() {
return true
}
}
return false
}
// By default, list contexts supports range select
func (self *ListContextTrait) RangeSelectEnabled() bool {
return true
}
func (self *ListContextTrait) RenderOnlyVisibleLines() bool {
return self.renderOnlyVisibleLines
}
func (self *ListContextTrait) SetNeedRerenderVisibleLines() {
self.needRerenderVisibleLines = true
}
func (self *ListContextTrait) TotalContentHeight() int {
result := self.list.Len()
if self.getNonModelItems != nil {
result += len(self.getNonModelItems())
}
return result
}
func (self *ListContextTrait) IndexForGotoBottom() int {
return self.list.Len() - 1
}