diff --git a/pkg/gui/context/filtered_list_view_model.go b/pkg/gui/context/filtered_list_view_model.go index 77f6e1174..1e649550a 100644 --- a/pkg/gui/context/filtered_list_view_model.go +++ b/pkg/gui/context/filtered_list_view_model.go @@ -3,13 +3,15 @@ package context type FilteredListViewModel[T any] struct { *FilteredList[T] *ListViewModel[T] + *SearchHistory } func NewFilteredListViewModel[T any](getList func() []T, getFilterFields func(T) []string) *FilteredListViewModel[T] { filteredList := NewFilteredList(getList, getFilterFields) self := &FilteredListViewModel[T]{ - FilteredList: filteredList, + FilteredList: filteredList, + SearchHistory: NewSearchHistory(), } listViewModel := NewListViewModel(filteredList.GetFilteredList) diff --git a/pkg/gui/context/history_trait.go b/pkg/gui/context/history_trait.go new file mode 100644 index 000000000..f850a3b77 --- /dev/null +++ b/pkg/gui/context/history_trait.go @@ -0,0 +1,20 @@ +package context + +import ( + "github.com/jesseduffield/lazygit/pkg/utils" +) + +// Maintains a list of strings that have previously been searched/filtered for +type SearchHistory struct { + history *utils.HistoryBuffer[string] +} + +func NewSearchHistory() *SearchHistory { + return &SearchHistory{ + history: utils.NewHistoryBuffer[string](1000), + } +} + +func (self *SearchHistory) GetSearchHistory() *utils.HistoryBuffer[string] { + return self.history +} diff --git a/pkg/gui/context/remotes_context.go b/pkg/gui/context/remotes_context.go index 035fb2321..38be54149 100644 --- a/pkg/gui/context/remotes_context.go +++ b/pkg/gui/context/remotes_context.go @@ -9,6 +9,7 @@ import ( type RemotesContext struct { *FilteredListViewModel[*models.Remote] *ListContextTrait + *SearchHistory } var ( diff --git a/pkg/gui/context/search_trait.go b/pkg/gui/context/search_trait.go index fad68d794..264c8217d 100644 --- a/pkg/gui/context/search_trait.go +++ b/pkg/gui/context/search_trait.go @@ -9,12 +9,16 @@ import ( type SearchTrait struct { c *ContextCommon + *SearchHistory searchString string } func NewSearchTrait(c *ContextCommon) *SearchTrait { - return &SearchTrait{c: c} + return &SearchTrait{ + c: c, + SearchHistory: NewSearchHistory(), + } } func (self *SearchTrait) GetSearchString() string { diff --git a/pkg/gui/controllers/helpers/search_helper.go b/pkg/gui/controllers/helpers/search_helper.go index b244f20e4..8764337b1 100644 --- a/pkg/gui/controllers/helpers/search_helper.go +++ b/pkg/gui/controllers/helpers/search_helper.go @@ -36,7 +36,7 @@ func (self *SearchHelper) OpenFilterPrompt(context types.IFilterableContext) err self.searchPrefixView().SetContent(self.c.Tr.FilterPrefix) promptView := self.promptView() promptView.ClearTextArea() - promptView.TextArea.TypeString(context.GetFilter()) + self.OnPromptContentChanged("") promptView.RenderTextArea() if err := self.c.PushContext(self.c.Contexts().Search); err != nil { @@ -49,13 +49,13 @@ func (self *SearchHelper) OpenFilterPrompt(context types.IFilterableContext) err func (self *SearchHelper) OpenSearchPrompt(context types.ISearchableContext) error { state := self.searchState() + state.PrevSearchIndex = -1 + state.Context = context - searchString := context.GetSearchString() self.searchPrefixView().SetContent(self.c.Tr.SearchPrefix) promptView := self.promptView() promptView.ClearTextArea() - promptView.TextArea.TypeString(searchString) promptView.RenderTextArea() if err := self.c.PushContext(self.c.Contexts().Search); err != nil { @@ -125,13 +125,17 @@ func (self *SearchHelper) ConfirmFilter() error { // We also do this on each keypress but we do it here again just in case state := self.searchState() - _, ok := state.Context.(types.IFilterableContext) + context, ok := state.Context.(types.IFilterableContext) if !ok { self.c.Log.Warnf("Context %s is not filterable", state.Context.GetKey()) return nil } self.OnPromptContentChanged(self.promptContent()) + filterString := self.promptContent() + if filterString != "" { + context.GetSearchHistory().Push(filterString) + } return self.c.PopContext() } @@ -147,6 +151,9 @@ func (self *SearchHelper) ConfirmSearch() error { searchString := self.promptContent() context.SetSearchString(searchString) + if searchString != "" { + context.GetSearchHistory().Push(searchString) + } view := context.GetView() @@ -167,6 +174,26 @@ func (self *SearchHelper) CancelPrompt() error { return self.c.PopContext() } +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() diff --git a/pkg/gui/controllers/search_prompt_controller.go b/pkg/gui/controllers/search_prompt_controller.go index 2326ed1c1..014edd094 100644 --- a/pkg/gui/controllers/search_prompt_controller.go +++ b/pkg/gui/controllers/search_prompt_controller.go @@ -33,6 +33,16 @@ func (self *SearchPromptController) GetKeybindings(opts types.KeybindingsOpts) [ Modifier: gocui.ModNone, Handler: self.cancel, }, + { + Key: opts.GetKey(opts.Config.Universal.PrevItem), + Modifier: gocui.ModNone, + Handler: self.prevHistory, + }, + { + Key: opts.GetKey(opts.Config.Universal.NextItem), + Modifier: gocui.ModNone, + Handler: self.nextHistory, + }, } } @@ -51,3 +61,13 @@ func (self *SearchPromptController) confirm() error { func (self *SearchPromptController) cancel() error { return self.c.Helpers().Search.CancelPrompt() } + +func (self *SearchPromptController) prevHistory() error { + self.c.Helpers().Search.ScrollHistory(1) + return nil +} + +func (self *SearchPromptController) nextHistory() error { + self.c.Helpers().Search.ScrollHistory(-1) + return nil +} diff --git a/pkg/gui/types/context.go b/pkg/gui/types/context.go index 7aa07056e..fbc38df82 100644 --- a/pkg/gui/types/context.go +++ b/pkg/gui/types/context.go @@ -4,6 +4,7 @@ import ( "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazygit/pkg/config" "github.com/jesseduffield/lazygit/pkg/gui/patch_exploring" + "github.com/jesseduffield/lazygit/pkg/utils" "github.com/sasha-s/go-deadlock" ) @@ -87,9 +88,16 @@ type Context interface { HandleRenderToMain() error } +type ISearchHistoryContext interface { + Context + + GetSearchHistory() *utils.HistoryBuffer[string] +} + type IFilterableContext interface { Context IListPanelState + ISearchHistoryContext SetFilter(string) GetFilter() string @@ -100,6 +108,7 @@ type IFilterableContext interface { type ISearchableContext interface { Context + ISearchHistoryContext SetSearchString(string) GetSearchString() string diff --git a/pkg/gui/types/search_state.go b/pkg/gui/types/search_state.go index 9b24af095..af806f2c3 100644 --- a/pkg/gui/types/search_state.go +++ b/pkg/gui/types/search_state.go @@ -12,11 +12,12 @@ const ( // TODO: could we remove this entirely? type SearchState struct { - Context Context + Context Context + PrevSearchIndex int } func NewSearchState() *SearchState { - return &SearchState{} + return &SearchState{PrevSearchIndex: -1} } func (self *SearchState) SearchType() SearchType { diff --git a/pkg/integration/tests/filter_and_search/filter_search_history.go b/pkg/integration/tests/filter_and_search/filter_search_history.go new file mode 100644 index 000000000..1b906319f --- /dev/null +++ b/pkg/integration/tests/filter_and_search/filter_search_history.go @@ -0,0 +1,77 @@ +package filter_and_search + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var FilterSearchHistory = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Navigating search history", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) {}, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + // populate search history with some values + FilterOrSearch("1"). + FilterOrSearch("2"). + FilterOrSearch("3"). + Press(keys.Universal.StartSearch). + // clear initial search value + Tap(func() { + t.ExpectSearch().Clear() + }). + // test main search history functionality + Tap(func() { + t.Views().Search(). + Press(keys.Universal.PrevItem). + Content(Contains("3")). + Press(keys.Universal.PrevItem). + Content(Contains("2")). + Press(keys.Universal.PrevItem). + Content(Contains("1")). + Press(keys.Universal.PrevItem). + Content(Contains("1")). + Press(keys.Universal.NextItem). + Content(Contains("2")). + Press(keys.Universal.NextItem). + Content(Contains("3")). + Press(keys.Universal.NextItem). + Content(Contains("")). + Press(keys.Universal.NextItem). + Content(Contains("")). + Press(keys.Universal.PrevItem). + Content(Contains("3")). + PressEscape() + }). + // test that it resets after you enter and exit a search + Press(keys.Universal.StartSearch). + Tap(func() { + t.Views().Search(). + Press(keys.Universal.PrevItem). + Content(Contains("3")). + PressEscape() + }) + + // test that the histories are separate for each view + t.Views().Commits(). + Focus(). + FilterOrSearch("a"). + FilterOrSearch("b"). + FilterOrSearch("c"). + Press(keys.Universal.StartSearch). + Tap(func() { + t.ExpectSearch().Clear() + }). + Tap(func() { + t.Views().Search(). + Press(keys.Universal.PrevItem). + Content(Contains("c")). + Press(keys.Universal.PrevItem). + Content(Contains("b")). + Press(keys.Universal.PrevItem). + Content(Contains("a")) + }) + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index d16da747d..12c4464b5 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -128,6 +128,7 @@ var tests = []*components.IntegrationTest{ filter_and_search.FilterMenu, filter_and_search.FilterMenuCancelFilterWithEscape, filter_and_search.FilterRemoteBranches, + filter_and_search.FilterSearchHistory, filter_and_search.NestedFilter, filter_and_search.NestedFilterTransient, filter_by_path.CliArg, diff --git a/pkg/utils/history_buffer.go b/pkg/utils/history_buffer.go new file mode 100644 index 000000000..73c33cb82 --- /dev/null +++ b/pkg/utils/history_buffer.go @@ -0,0 +1,36 @@ +package utils + +import "fmt" + +type HistoryBuffer[T any] struct { + maxSize int + items []T +} + +func NewHistoryBuffer[T any](maxSize int) *HistoryBuffer[T] { + return &HistoryBuffer[T]{ + maxSize: maxSize, + items: make([]T, 0, maxSize), + } +} + +func (self *HistoryBuffer[T]) Push(item T) { + if len(self.items) == self.maxSize { + self.items = self.items[:len(self.items)-1] + } + self.items = append([]T{item}, self.items...) +} + +func (self *HistoryBuffer[T]) PeekAt(index int) (T, error) { + var item T + if len(self.items) == 0 { + return item, fmt.Errorf("Buffer is empty") + } + if len(self.items) <= index || index < -1 { + return item, fmt.Errorf("Index out of range") + } + if index == -1 { + return item, nil + } + return self.items[index], nil +} diff --git a/pkg/utils/history_buffer_test.go b/pkg/utils/history_buffer_test.go new file mode 100644 index 000000000..51644d42d --- /dev/null +++ b/pkg/utils/history_buffer_test.go @@ -0,0 +1,64 @@ +package utils + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNewHistoryBuffer(t *testing.T) { + hb := NewHistoryBuffer[int](5) + assert.NotNil(t, hb) + assert.Equal(t, 5, hb.maxSize) + assert.Equal(t, 0, len(hb.items)) +} + +func TestPush(t *testing.T) { + hb := NewHistoryBuffer[int](3) + hb.Push(1) + hb.Push(2) + hb.Push(3) + hb.Push(4) + + assert.Equal(t, 3, len(hb.items)) + assert.Equal(t, []int{4, 3, 2}, hb.items) +} + +func TestPeekAt(t *testing.T) { + hb := NewHistoryBuffer[int](3) + hb.Push(1) + hb.Push(2) + hb.Push(3) + + item, err := hb.PeekAt(0) + assert.Nil(t, err) + assert.Equal(t, 3, item) + + item, err = hb.PeekAt(1) + assert.Nil(t, err) + assert.Equal(t, 2, item) + + item, err = hb.PeekAt(2) + assert.Nil(t, err) + assert.Equal(t, 1, item) + + item, err = hb.PeekAt(-1) + assert.Nil(t, err) + assert.Equal(t, 0, item) + + _, err = hb.PeekAt(3) + assert.NotNil(t, err) + assert.Equal(t, "Index out of range", err.Error()) + + _, err = hb.PeekAt(-2) + assert.NotNil(t, err) + assert.Equal(t, "Index out of range", err.Error()) +} + +func TestPeekAtEmptyBuffer(t *testing.T) { + hb := NewHistoryBuffer[int](3) + + _, err := hb.PeekAt(0) + assert.NotNil(t, err) + assert.Equal(t, "Buffer is empty", err.Error()) +}