jesseduffield.lazygit/pkg/gui/controllers/helpers/search_helper.go
Stefan Haller 5c4c7139d7 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) <noreply@anthropic.com>
2026-08-14 08:04:13 +02:00

330 lines
8.8 KiB
Go

package helpers
import (
"fmt"
"strings"
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/context"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/theme"
"github.com/jesseduffield/lazygit/pkg/utils"
)
// NOTE: this helper supports both filtering and searching. Filtering is when
// the contents of the list are filtered, whereas searching does not actually
// change the contents of the list but instead just highlights the search.
// The general term we use to capture both searching and filtering is...
// 'searching', which is unfortunate but I can't think of a better name.
type SearchHelper struct {
c *HelperCommon
}
func NewSearchHelper(
c *HelperCommon,
) *SearchHelper {
return &SearchHelper{
c: c,
}
}
func (self *SearchHelper) OpenFilterPrompt(context types.IFilterableContext) error {
state := self.searchState()
state.PrevSearchIndex = -1
state.Context = context
self.searchPrefixView().SetContent(context.FilterPrefix(self.c.Tr))
promptView := self.promptView()
promptView.ClearTextArea()
self.OnPromptContentChanged("")
promptView.RenderTextArea()
self.c.Context().Push(self.c.Contexts().Search, types.OnFocusOpts{})
return self.c.ResetKeybindings()
}
func (self *SearchHelper) OpenSearchPrompt(context types.ISearchableContext) error {
state := self.searchState()
state.PrevSearchIndex = -1
state.Context = context
self.searchPrefixView().SetContent(self.c.Tr.SearchPrefix)
promptView := self.promptView()
promptView.ClearTextArea()
promptView.RenderTextArea()
self.c.Context().Push(self.c.Contexts().Search, types.OnFocusOpts{})
return self.c.ResetKeybindings()
}
func (self *SearchHelper) DisplayFilterStatus(context types.IFilterableContext) {
state := self.searchState()
state.Context = context
searchString := context.GetFilter()
self.searchPrefixView().SetContent(context.FilterPrefix(self.c.Tr))
promptView := self.promptView()
keybindingConfig := self.c.UserConfig().Keybinding
promptView.SetContent(fmt.Sprintf("matches for '%s' ", searchString) + theme.OptionsFgColor.Sprintf(self.c.Tr.ExitTextFilterMode, keybindingConfig.Universal.Return))
}
func (self *SearchHelper) DisplaySearchStatus(context types.ISearchableContext) {
state := self.searchState()
state.Context = context
self.searchPrefixView().SetContent(self.c.Tr.SearchPrefix)
index, totalCount := context.GetView().GetSearchStatus()
context.RenderSearchStatus(index, totalCount)
}
func (self *SearchHelper) searchState() *types.SearchState {
return self.c.State().GetRepoState().GetSearchState()
}
func (self *SearchHelper) searchPrefixView() *gocui.View {
return self.c.Views().SearchPrefix
}
func (self *SearchHelper) promptView() *gocui.View {
return self.c.Contexts().Search.GetView()
}
func (self *SearchHelper) promptContent() string {
return self.c.Contexts().Search.GetView().TextArea.GetContent()
}
func (self *SearchHelper) Confirm() error {
state := self.searchState()
if self.promptContent() == "" {
return self.CancelPrompt()
}
switch state.SearchType() {
case types.SearchTypeFilter:
self.ConfirmFilter()
case types.SearchTypeSearch:
self.ConfirmSearch()
case types.SearchTypeNone:
self.c.Context().Pop()
}
return self.c.ResetKeybindings()
}
func (self *SearchHelper) ConfirmFilter() {
// We also do this on each keypress but we do it here again just in case
state := self.searchState()
context, ok := state.Context.(types.IFilterableContext)
if !ok {
self.c.Log.Warnf("Context %s is not filterable", state.Context.GetKey())
return
}
self.OnPromptContentChanged(self.promptContent())
filterString := self.promptContent()
if filterString != "" {
context.GetSearchHistory().Push(filterString)
}
self.c.Context().Pop()
}
func (self *SearchHelper) ConfirmSearch() {
state := self.searchState()
context, ok := state.Context.(types.ISearchableContext)
if !ok {
self.c.Log.Warnf("Context %s is searchable", state.Context.GetKey())
return
}
searchString := self.promptContent()
context.SetSearchString(searchString)
if searchString != "" {
context.GetSearchHistory().Push(searchString)
}
self.c.Context().Pop()
context.GetView().Search(searchString, modelSearchResults(context))
}
func modelSearchResults(context types.ISearchableContext) []gocui.SearchPosition {
searchString := context.GetSearchString()
var normalizedSearchStr string
// if we have any uppercase characters we'll do a case-sensitive search
caseSensitive := utils.ContainsUppercase(searchString)
if caseSensitive {
normalizedSearchStr = searchString
} else {
normalizedSearchStr = strings.ToLower(searchString)
}
return context.ModelSearchResults(normalizedSearchStr, caseSensitive)
}
func (self *SearchHelper) CancelPrompt() error {
self.Cancel()
self.c.Context().Pop()
return self.c.ResetKeybindings()
}
func (self *SearchHelper) ScrollHistory(scrollIncrement int) {
state := self.searchState()
context, ok := state.Context.(types.ISearchHistoryContext)
if !ok {
return
}
states := context.GetSearchHistory()
if val, err := states.PeekAt(state.PrevSearchIndex + scrollIncrement); err == nil {
state.PrevSearchIndex += scrollIncrement
promptView := self.promptView()
promptView.ClearTextArea()
promptView.TextArea.TypeString(val)
promptView.RenderTextArea()
self.OnPromptContentChanged(val)
}
}
func (self *SearchHelper) Cancel() {
state := self.searchState()
switch context := state.Context.(type) {
case types.IFilterableContext:
context.ClearFilter()
self.c.PostRefreshUpdate(context)
case types.ISearchableContext:
context.ClearSearchString()
context.GetView().ClearSearch()
default:
// do nothing
}
self.HidePrompt()
}
func (self *SearchHelper) OnPromptContentChanged(searchString string) {
state := self.searchState()
switch context := state.Context.(type) {
case types.IFilterableContext:
context.SetSelection(0)
context.SetFilter(searchString, self.c.UserConfig().Gui.UseFuzzySearch())
self.c.PostRefreshUpdate(context)
case types.ISearchableContext:
// do nothing
default:
// do nothing (shouldn't land here)
}
}
func (self *SearchHelper) ReApplyFilter(context types.Context) {
filterableContext, ok := context.(types.IFilterableContext)
if ok {
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())
}
}
func (self *SearchHelper) ReApplySearch(ctx types.Context) {
// Reapply the search if the model has changed. This is needed for contexts
// that use the model for searching, to pass the new model search positions
// to the view.
searchableContext, ok := ctx.(types.ISearchableContext)
if ok {
ctx.GetView().UpdateSearchResults(searchableContext.GetSearchString(), modelSearchResults(searchableContext))
state := self.searchState()
if ctx == state.Context {
// Re-render the "x of y" search status, unless the search prompt is
// open for typing.
if self.c.Context().Current().GetKey() != context.SEARCH_CONTEXT_KEY {
self.RenderSearchStatus(searchableContext)
}
}
}
}
func (self *SearchHelper) RenderSearchStatus(c types.Context) {
if c.GetKey() == context.SEARCH_CONTEXT_KEY {
return
}
if searchableContext, ok := c.(types.ISearchableContext); ok {
if searchableContext.IsSearching() {
self.setSearchingFrameColor()
self.DisplaySearchStatus(searchableContext)
return
}
}
if filterableContext, ok := c.(types.IFilterableContext); ok {
if filterableContext.IsFiltering() {
self.setSearchingFrameColor()
self.DisplayFilterStatus(filterableContext)
return
}
}
self.HidePrompt()
}
func (self *SearchHelper) CancelSearchIfSearching(c types.Context) {
if searchableContext, ok := c.(types.ISearchableContext); ok {
view := searchableContext.GetView()
if view != nil && view.IsSearching() {
view.ClearSearch()
searchableContext.ClearSearchString()
self.Cancel()
}
return
}
if filterableContext, ok := c.(types.IFilterableContext); ok {
if filterableContext.IsFiltering() {
filterableContext.ClearFilter()
self.Cancel()
}
return
}
}
func (self *SearchHelper) HidePrompt() {
self.setNonSearchingFrameColor()
state := self.searchState()
state.Context = nil
}
func (self *SearchHelper) setSearchingFrameColor() {
self.c.GocuiGui().SelFgColor = theme.SearchingActiveBorderColor
self.c.GocuiGui().SelFrameColor = theme.SearchingActiveBorderColor
}
func (self *SearchHelper) setNonSearchingFrameColor() {
self.c.GocuiGui().SelFgColor = theme.ActiveBorderColor
self.c.GocuiGui().SelFrameColor = theme.ActiveBorderColor
}