mirror of
https://github.com/jesseduffield/lazygit.git
synced 2025-05-12 12:55:47 +02:00
Add search history
Add search history for filterable and searchable views.
This commit is contained in:
parent
ab5875c78f
commit
edec116ceb
12 changed files with 270 additions and 8 deletions
|
@ -3,6 +3,7 @@ package context
|
||||||
type FilteredListViewModel[T any] struct {
|
type FilteredListViewModel[T any] struct {
|
||||||
*FilteredList[T]
|
*FilteredList[T]
|
||||||
*ListViewModel[T]
|
*ListViewModel[T]
|
||||||
|
*SearchHistory
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewFilteredListViewModel[T any](getList func() []T, getFilterFields func(T) []string) *FilteredListViewModel[T] {
|
func NewFilteredListViewModel[T any](getList func() []T, getFilterFields func(T) []string) *FilteredListViewModel[T] {
|
||||||
|
@ -10,6 +11,7 @@ func NewFilteredListViewModel[T any](getList func() []T, getFilterFields func(T)
|
||||||
|
|
||||||
self := &FilteredListViewModel[T]{
|
self := &FilteredListViewModel[T]{
|
||||||
FilteredList: filteredList,
|
FilteredList: filteredList,
|
||||||
|
SearchHistory: NewSearchHistory(),
|
||||||
}
|
}
|
||||||
|
|
||||||
listViewModel := NewListViewModel(filteredList.GetFilteredList)
|
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 {
|
type RemotesContext struct {
|
||||||
*FilteredListViewModel[*models.Remote]
|
*FilteredListViewModel[*models.Remote]
|
||||||
*ListContextTrait
|
*ListContextTrait
|
||||||
|
*SearchHistory
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|
|
@ -9,12 +9,16 @@ import (
|
||||||
|
|
||||||
type SearchTrait struct {
|
type SearchTrait struct {
|
||||||
c *ContextCommon
|
c *ContextCommon
|
||||||
|
*SearchHistory
|
||||||
|
|
||||||
searchString string
|
searchString string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewSearchTrait(c *ContextCommon) *SearchTrait {
|
func NewSearchTrait(c *ContextCommon) *SearchTrait {
|
||||||
return &SearchTrait{c: c}
|
return &SearchTrait{
|
||||||
|
c: c,
|
||||||
|
SearchHistory: NewSearchHistory(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (self *SearchTrait) GetSearchString() string {
|
func (self *SearchTrait) GetSearchString() string {
|
||||||
|
|
|
@ -36,7 +36,7 @@ func (self *SearchHelper) OpenFilterPrompt(context types.IFilterableContext) err
|
||||||
self.searchPrefixView().SetContent(self.c.Tr.FilterPrefix)
|
self.searchPrefixView().SetContent(self.c.Tr.FilterPrefix)
|
||||||
promptView := self.promptView()
|
promptView := self.promptView()
|
||||||
promptView.ClearTextArea()
|
promptView.ClearTextArea()
|
||||||
promptView.TextArea.TypeString(context.GetFilter())
|
self.OnPromptContentChanged("")
|
||||||
promptView.RenderTextArea()
|
promptView.RenderTextArea()
|
||||||
|
|
||||||
if err := self.c.PushContext(self.c.Contexts().Search); err != nil {
|
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 {
|
func (self *SearchHelper) OpenSearchPrompt(context types.ISearchableContext) error {
|
||||||
state := self.searchState()
|
state := self.searchState()
|
||||||
|
|
||||||
|
state.PrevSearchIndex = -1
|
||||||
|
|
||||||
state.Context = context
|
state.Context = context
|
||||||
searchString := context.GetSearchString()
|
|
||||||
|
|
||||||
self.searchPrefixView().SetContent(self.c.Tr.SearchPrefix)
|
self.searchPrefixView().SetContent(self.c.Tr.SearchPrefix)
|
||||||
promptView := self.promptView()
|
promptView := self.promptView()
|
||||||
promptView.ClearTextArea()
|
promptView.ClearTextArea()
|
||||||
promptView.TextArea.TypeString(searchString)
|
|
||||||
promptView.RenderTextArea()
|
promptView.RenderTextArea()
|
||||||
|
|
||||||
if err := self.c.PushContext(self.c.Contexts().Search); err != nil {
|
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
|
// We also do this on each keypress but we do it here again just in case
|
||||||
state := self.searchState()
|
state := self.searchState()
|
||||||
|
|
||||||
_, ok := state.Context.(types.IFilterableContext)
|
context, ok := state.Context.(types.IFilterableContext)
|
||||||
if !ok {
|
if !ok {
|
||||||
self.c.Log.Warnf("Context %s is not filterable", state.Context.GetKey())
|
self.c.Log.Warnf("Context %s is not filterable", state.Context.GetKey())
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
self.OnPromptContentChanged(self.promptContent())
|
self.OnPromptContentChanged(self.promptContent())
|
||||||
|
filterString := self.promptContent()
|
||||||
|
if filterString != "" {
|
||||||
|
context.GetSearchHistory().Push(filterString)
|
||||||
|
}
|
||||||
|
|
||||||
return self.c.PopContext()
|
return self.c.PopContext()
|
||||||
}
|
}
|
||||||
|
@ -147,6 +151,9 @@ func (self *SearchHelper) ConfirmSearch() error {
|
||||||
|
|
||||||
searchString := self.promptContent()
|
searchString := self.promptContent()
|
||||||
context.SetSearchString(searchString)
|
context.SetSearchString(searchString)
|
||||||
|
if searchString != "" {
|
||||||
|
context.GetSearchHistory().Push(searchString)
|
||||||
|
}
|
||||||
|
|
||||||
view := context.GetView()
|
view := context.GetView()
|
||||||
|
|
||||||
|
@ -167,6 +174,26 @@ func (self *SearchHelper) CancelPrompt() error {
|
||||||
return self.c.PopContext()
|
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() {
|
func (self *SearchHelper) Cancel() {
|
||||||
state := self.searchState()
|
state := self.searchState()
|
||||||
|
|
||||||
|
|
|
@ -33,6 +33,16 @@ func (self *SearchPromptController) GetKeybindings(opts types.KeybindingsOpts) [
|
||||||
Modifier: gocui.ModNone,
|
Modifier: gocui.ModNone,
|
||||||
Handler: self.cancel,
|
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 {
|
func (self *SearchPromptController) cancel() error {
|
||||||
return self.c.Helpers().Search.CancelPrompt()
|
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/gocui"
|
||||||
"github.com/jesseduffield/lazygit/pkg/config"
|
"github.com/jesseduffield/lazygit/pkg/config"
|
||||||
"github.com/jesseduffield/lazygit/pkg/gui/patch_exploring"
|
"github.com/jesseduffield/lazygit/pkg/gui/patch_exploring"
|
||||||
|
"github.com/jesseduffield/lazygit/pkg/utils"
|
||||||
"github.com/sasha-s/go-deadlock"
|
"github.com/sasha-s/go-deadlock"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -87,9 +88,16 @@ type Context interface {
|
||||||
HandleRenderToMain() error
|
HandleRenderToMain() error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ISearchHistoryContext interface {
|
||||||
|
Context
|
||||||
|
|
||||||
|
GetSearchHistory() *utils.HistoryBuffer[string]
|
||||||
|
}
|
||||||
|
|
||||||
type IFilterableContext interface {
|
type IFilterableContext interface {
|
||||||
Context
|
Context
|
||||||
IListPanelState
|
IListPanelState
|
||||||
|
ISearchHistoryContext
|
||||||
|
|
||||||
SetFilter(string)
|
SetFilter(string)
|
||||||
GetFilter() string
|
GetFilter() string
|
||||||
|
@ -100,6 +108,7 @@ type IFilterableContext interface {
|
||||||
|
|
||||||
type ISearchableContext interface {
|
type ISearchableContext interface {
|
||||||
Context
|
Context
|
||||||
|
ISearchHistoryContext
|
||||||
|
|
||||||
SetSearchString(string)
|
SetSearchString(string)
|
||||||
GetSearchString() string
|
GetSearchString() string
|
||||||
|
|
|
@ -13,10 +13,11 @@ const (
|
||||||
// TODO: could we remove this entirely?
|
// TODO: could we remove this entirely?
|
||||||
type SearchState struct {
|
type SearchState struct {
|
||||||
Context Context
|
Context Context
|
||||||
|
PrevSearchIndex int
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewSearchState() *SearchState {
|
func NewSearchState() *SearchState {
|
||||||
return &SearchState{}
|
return &SearchState{PrevSearchIndex: -1}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (self *SearchState) SearchType() SearchType {
|
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.FilterFuzzy,
|
||||||
filter_and_search.FilterMenu,
|
filter_and_search.FilterMenu,
|
||||||
filter_and_search.FilterRemoteBranches,
|
filter_and_search.FilterRemoteBranches,
|
||||||
|
filter_and_search.FilterSearchHistory,
|
||||||
filter_and_search.NestedFilter,
|
filter_and_search.NestedFilter,
|
||||||
filter_and_search.NestedFilterTransient,
|
filter_and_search.NestedFilterTransient,
|
||||||
filter_by_path.CliArg,
|
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())
|
||||||
|
}
|
Loading…
Add table
Add a link
Reference in a new issue