mirror of
				https://github.com/jesseduffield/lazygit.git
				synced 2025-10-30 23:57:43 +02:00 
			
		
		
		
	Add search history
Add search history for filterable and searchable views.
This commit is contained in:
		| @@ -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) | ||||
|   | ||||
							
								
								
									
										20
									
								
								pkg/gui/context/history_trait.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								pkg/gui/context/history_trait.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||
| } | ||||
| @@ -9,6 +9,7 @@ import ( | ||||
| type RemotesContext struct { | ||||
| 	*FilteredListViewModel[*models.Remote] | ||||
| 	*ListContextTrait | ||||
| 	*SearchHistory | ||||
| } | ||||
|  | ||||
| var ( | ||||
|   | ||||
| @@ -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 { | ||||
|   | ||||
| @@ -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() | ||||
|  | ||||
|   | ||||
| @@ -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 | ||||
| } | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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 { | ||||
|   | ||||
| @@ -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")) | ||||
| 			}) | ||||
| 	}, | ||||
| }) | ||||
| @@ -118,6 +118,7 @@ var tests = []*components.IntegrationTest{ | ||||
| 	filter_and_search.FilterFuzzy, | ||||
| 	filter_and_search.FilterMenu, | ||||
| 	filter_and_search.FilterRemoteBranches, | ||||
| 	filter_and_search.FilterSearchHistory, | ||||
| 	filter_and_search.NestedFilter, | ||||
| 	filter_and_search.NestedFilterTransient, | ||||
| 	filter_by_path.CliArg, | ||||
|   | ||||
							
								
								
									
										36
									
								
								pkg/utils/history_buffer.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										36
									
								
								pkg/utils/history_buffer.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||
| } | ||||
							
								
								
									
										64
									
								
								pkg/utils/history_buffer_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										64
									
								
								pkg/utils/history_buffer_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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()) | ||||
| } | ||||
		Reference in New Issue
	
	Block a user