From a9e2c8129f6e1cdfd58446d7ce5080fcabc2ea04 Mon Sep 17 00:00:00 2001 From: Jesse Duffield Date: Sat, 27 May 2023 14:14:43 +1000 Subject: Introduce filtered list view model We're going to start supporting filtering of list views --- pkg/gui/command_log_panel.go | 4 - pkg/gui/context.go | 53 +++++- pkg/gui/context/basic_view_model.go | 34 ---- pkg/gui/context/branches_context.go | 13 +- pkg/gui/context/filtered_list.go | 56 ++++++ pkg/gui/context/filtered_list_view_model.go | 26 +++ pkg/gui/context/list_view_model.go | 38 ++++ pkg/gui/context/local_commits_context.go | 15 +- pkg/gui/context/menu_context.go | 13 +- pkg/gui/context/patch_explorer_context.go | 14 +- pkg/gui/context/reflog_commits_context.go | 13 +- pkg/gui/context/remote_branches_context.go | 15 +- pkg/gui/context/remotes_context.go | 13 +- pkg/gui/context/search_trait.go | 70 ++++++++ pkg/gui/context/stash_context.go | 13 +- pkg/gui/context/sub_commits_context.go | 18 +- pkg/gui/context/submodules_context.go | 13 +- pkg/gui/context/suggestions_context.go | 8 +- pkg/gui/context/tags_context.go | 13 +- pkg/gui/controllers.go | 15 ++ pkg/gui/controllers/filter_controller.go | 48 +++++ pkg/gui/controllers/helpers/confirmation_helper.go | 4 +- pkg/gui/controllers/helpers/helpers.go | 2 + pkg/gui/controllers/helpers/search_helper.go | 196 +++++++++++++++++++++ .../helpers/window_arrangement_helper.go | 12 +- pkg/gui/controllers/list_controller.go | 13 +- pkg/gui/controllers/local_commits_controller.go | 4 +- pkg/gui/controllers/patch_explorer_controller.go | 6 - pkg/gui/controllers/search_controller.go | 48 +++++ pkg/gui/controllers/search_prompt_controller.go | 53 ++++++ pkg/gui/editors.go | 11 ++ pkg/gui/filetree/file_tree_view_model.go | 2 + pkg/gui/gui.go | 20 +-- pkg/gui/gui_common.go | 4 - pkg/gui/keybindings.go | 12 -- pkg/gui/layout.go | 14 -- pkg/gui/menu_panel.go | 3 - pkg/gui/searching.go | 103 ----------- pkg/gui/types/common.go | 5 +- pkg/gui/types/context.go | 21 ++- pkg/gui/types/search_state.go | 31 ++++ pkg/gui/views.go | 2 + pkg/i18n/english.go | 6 +- pkg/utils/slice.go | 11 ++ 44 files changed, 827 insertions(+), 261 deletions(-) delete mode 100644 pkg/gui/context/basic_view_model.go create mode 100644 pkg/gui/context/filtered_list.go create mode 100644 pkg/gui/context/filtered_list_view_model.go create mode 100644 pkg/gui/context/list_view_model.go create mode 100644 pkg/gui/context/search_trait.go create mode 100644 pkg/gui/controllers/filter_controller.go create mode 100644 pkg/gui/controllers/helpers/search_helper.go create mode 100644 pkg/gui/controllers/search_controller.go create mode 100644 pkg/gui/controllers/search_prompt_controller.go delete mode 100644 pkg/gui/searching.go create mode 100644 pkg/gui/types/search_state.go (limited to 'pkg') diff --git a/pkg/gui/command_log_panel.go b/pkg/gui/command_log_panel.go index 0a5ccfae3..2faee3572 100644 --- a/pkg/gui/command_log_panel.go +++ b/pkg/gui/command_log_panel.go @@ -135,10 +135,6 @@ func (gui *Gui) getRandomTip() string { "To escape a mode, for example cherry-picking, patch-building, diffing, or filtering mode, you can just spam the '%s' button. Unless of course you have `quitOnTopLevelReturn` enabled in your config", formattedKey(config.Universal.Return), ), - fmt.Sprintf( - "To search for a string in your panel, press '%s'", - formattedKey(config.Universal.StartSearch), - ), fmt.Sprintf( "You can page through the items of a panel using '%s' and '%s'", formattedKey(config.Universal.PrevPage), diff --git a/pkg/gui/context.go b/pkg/gui/context.go index b55713f27..26cec4c23 100644 --- a/pkg/gui/context.go +++ b/pkg/gui/context.go @@ -200,9 +200,21 @@ func (self *ContextMgr) RemoveContexts(contextsToRemove []types.Context) error { func (self *ContextMgr) deactivateContext(c types.Context, opts types.OnFocusLostOpts) error { view, _ := self.gui.c.GocuiGui().View(c.GetViewName()) - if view != nil && view.IsSearching() { - if err := self.gui.onSearchEscape(); err != nil { - return err + if opts.NewContextKey != context.SEARCH_CONTEXT_KEY { + + if searchableContext, ok := c.(types.ISearchableContext); ok { + if view != nil && view.IsSearching() { + view.ClearSearch() + searchableContext.ClearSearchString() + self.gui.helpers.Search.Cancel() + } + } + + if filterableContext, ok := c.(types.IFilterableContext); ok { + if filterableContext.GetFilter() != "" { + filterableContext.ClearFilter() + self.gui.helpers.Search.Cancel() + } } } @@ -234,6 +246,17 @@ func (self *ContextMgr) ActivateContext(c types.Context, opts types.OnFocusOpts) return err } + if searchableContext, ok := c.(types.ISearchableContext); ok { + if searchableContext.GetSearchString() != "" { + self.gui.helpers.Search.DisplaySearchPrompt(searchableContext) + } + } + if filterableContext, ok := c.(types.IFilterableContext); ok { + if filterableContext.GetFilter() != "" { + self.gui.helpers.Search.DisplayFilterPrompt(filterableContext) + } + } + desiredTitle := c.Title() if desiredTitle != "" { v.Title = desiredTitle @@ -326,6 +349,30 @@ func (self *ContextMgr) IsCurrent(c types.Context) bool { return self.Current().GetKey() == c.GetKey() } +func (self *ContextMgr) AllFilterable() []types.IFilterableContext { + var result []types.IFilterableContext + + for _, context := range self.allContexts.Flatten() { + if ctx, ok := context.(types.IFilterableContext); ok { + result = append(result, ctx) + } + } + + return result +} + +func (self *ContextMgr) AllSearchable() []types.ISearchableContext { + var result []types.ISearchableContext + + for _, context := range self.allContexts.Flatten() { + if ctx, ok := context.(types.ISearchableContext); ok { + result = append(result, ctx) + } + } + + return result +} + // all list contexts func (self *ContextMgr) AllList() []types.IListContext { var listContexts []types.IListContext diff --git a/pkg/gui/context/basic_view_model.go b/pkg/gui/context/basic_view_model.go deleted file mode 100644 index a53be4d91..000000000 --- a/pkg/gui/context/basic_view_model.go +++ /dev/null @@ -1,34 +0,0 @@ -package context - -import "github.com/jesseduffield/lazygit/pkg/gui/context/traits" - -type BasicViewModel[T any] struct { - *traits.ListCursor - getModel func() []T -} - -func NewBasicViewModel[T any](getModel func() []T) *BasicViewModel[T] { - self := &BasicViewModel[T]{ - getModel: getModel, - } - - self.ListCursor = traits.NewListCursor(self) - - return self -} - -func (self *BasicViewModel[T]) Len() int { - return len(self.getModel()) -} - -func (self *BasicViewModel[T]) GetSelected() T { - if self.Len() == 0 { - return Zero[T]() - } - - return self.getModel()[self.GetSelectedLineIdx()] -} - -func Zero[T any]() T { - return *new(T) -} diff --git a/pkg/gui/context/branches_context.go b/pkg/gui/context/branches_context.go index c2463ad20..497b3a2c4 100644 --- a/pkg/gui/context/branches_context.go +++ b/pkg/gui/context/branches_context.go @@ -7,7 +7,7 @@ import ( ) type BranchesContext struct { - *BasicViewModel[*models.Branch] + *FilteredListViewModel[*models.Branch] *ListContextTrait } @@ -17,11 +17,16 @@ var ( ) func NewBranchesContext(c *ContextCommon) *BranchesContext { - viewModel := NewBasicViewModel(func() []*models.Branch { return c.Model().Branches }) + viewModel := NewFilteredListViewModel( + func() []*models.Branch { return c.Model().Branches }, + func(branch *models.Branch) []string { + return []string{branch.Name} + }, + ) getDisplayStrings := func(startIdx int, length int) [][]string { return presentation.GetBranchListDisplayStrings( - c.Model().Branches, + viewModel.GetItems(), c.State().GetRepoState().GetScreenMode() != types.SCREEN_NORMAL, c.Modes().Diffing.Ref, c.Tr, @@ -30,7 +35,7 @@ func NewBranchesContext(c *ContextCommon) *BranchesContext { } self := &BranchesContext{ - BasicViewModel: viewModel, + FilteredListViewModel: viewModel, ListContextTrait: &ListContextTrait{ Context: NewSimpleContext(NewBaseContext(NewBaseContextOpts{ View: c.Views().Branches, diff --git a/pkg/gui/context/filtered_list.go b/pkg/gui/context/filtered_list.go new file mode 100644 index 000000000..1317924ad --- /dev/null +++ b/pkg/gui/context/filtered_list.go @@ -0,0 +1,56 @@ +package context + +import ( + "strings" + + "github.com/jesseduffield/lazygit/pkg/utils" +) + +type FilteredList[T any] struct { + filteredIndices []int // if nil, we are not filtering + + getList func() []T + getFilterFields func(T) []string + filter string +} + +func (self *FilteredList[T]) GetFilter() string { + return self.filter +} + +func (self *FilteredList[T]) SetFilter(filter string) { + self.filter = filter + + self.applyFilter() +} + +func (self *FilteredList[T]) ClearFilter() { + self.SetFilter("") +} + +func (self *FilteredList[T]) GetList() []T { + if self.filteredIndices == nil { + return self.getList() + } + return utils.ValuesAtIndices(self.getList(), self.filteredIndices) +} + +func (self *FilteredList[T]) UnfilteredLen() int { + return len(self.getList()) +} + +func (self *FilteredList[T]) applyFilter() { + if self.filter == "" { + self.filteredIndices = nil + } else { + self.filteredIndices = []int{} + for i, item := range self.getList() { + for _, field := range self.getFilterFields(item) { + if strings.Contains(field, self.filter) { + self.filteredIndices = append(self.filteredIndices, i) + break + } + } + } + } +} diff --git a/pkg/gui/context/filtered_list_view_model.go b/pkg/gui/context/filtered_list_view_model.go new file mode 100644 index 000000000..01b020841 --- /dev/null +++ b/pkg/gui/context/filtered_list_view_model.go @@ -0,0 +1,26 @@ +package context + +type FilteredListViewModel[T any] struct { + *FilteredList[T] + *ListViewModel[T] +} + +func NewFilteredListViewModel[T any](getList func() []T, getFilterFields func(T) []string) *FilteredListViewModel[T] { + filteredList := &FilteredList[T]{ + getList: getList, + getFilterFields: getFilterFields, + } + + self := &FilteredListViewModel[T]{ + FilteredList: filteredList, + } + + listViewModel := NewListViewModel(filteredList.GetList) + + self.ListViewModel = listViewModel + + return self +} + +// used for type switch +func (self *FilteredListViewModel[T]) IsFilterableContext() {} diff --git a/pkg/gui/context/list_view_model.go b/pkg/gui/context/list_view_model.go new file mode 100644 index 000000000..b70330d7d --- /dev/null +++ b/pkg/gui/context/list_view_model.go @@ -0,0 +1,38 @@ +package context + +import "github.com/jesseduffield/lazygit/pkg/gui/context/traits" + +type ListViewModel[T any] struct { + *traits.ListCursor + getModel func() []T +} + +func NewListViewModel[T any](getModel func() []T) *ListViewModel[T] { + self := &ListViewModel[T]{ + getModel: getModel, + } + + self.ListCursor = traits.NewListCursor(self) + + return self +} + +func (self *ListViewModel[T]) Len() int { + return len(self.getModel()) +} + +func (self *ListViewModel[T]) GetSelected() T { + if self.Len() == 0 { + return Zero[T]() + } + + return self.getModel()[self.GetSelectedLineIdx()] +} + +func (self *ListViewModel[T]) GetItems() []T { + return self.getModel() +} + +func Zero[T any]() T { + return *new(T) +} diff --git a/pkg/gui/context/local_commits_context.go b/pkg/gui/context/local_commits_context.go index 74363c52f..84204591c 100644 --- a/pkg/gui/context/local_commits_context.go +++ b/pkg/gui/context/local_commits_context.go @@ -13,6 +13,7 @@ import ( type LocalCommitsContext struct { *LocalCommitsViewModel *ListContextTrait + *SearchTrait } var ( @@ -57,8 +58,9 @@ func NewLocalCommitsContext(c *ContextCommon) *LocalCommitsContext { ) } - return &LocalCommitsContext{ + ctx := &LocalCommitsContext{ LocalCommitsViewModel: viewModel, + SearchTrait: NewSearchTrait(c), ListContextTrait: &ListContextTrait{ Context: NewSimpleContext(NewBaseContext(NewBaseContextOpts{ View: c.Views().Commits, @@ -73,6 +75,13 @@ func NewLocalCommitsContext(c *ContextCommon) *LocalCommitsContext { refreshViewportOnChange: true, }, } + + ctx.GetView().SetOnSelectItem(ctx.SearchTrait.onSelectItemWrapper(func(selectedLineIdx int) error { + ctx.GetList().SetSelectedLineIdx(selectedLineIdx) + return ctx.HandleFocus(types.OnFocusOpts{}) + })) + + return ctx } func (self *LocalCommitsContext) GetSelectedItemId() string { @@ -85,7 +94,7 @@ func (self *LocalCommitsContext) GetSelectedItemId() string { } type LocalCommitsViewModel struct { - *BasicViewModel[*models.Commit] + *ListViewModel[*models.Commit] // If this is true we limit the amount of commits we load, for the sake of keeping things fast. // If the user attempts to scroll past the end of the list, we will load more commits. @@ -97,7 +106,7 @@ type LocalCommitsViewModel struct { func NewLocalCommitsViewModel(getModel func() []*models.Commit, c *ContextCommon) *LocalCommitsViewModel { self := &LocalCommitsViewModel{ - BasicViewModel: NewBasicViewModel(getModel), + ListViewModel: NewListViewModel(getModel), limitCommits: true, showWholeGitGraph: c.UserConfig.Git.Log.ShowWholeGraph, } diff --git a/pkg/gui/context/menu_context.go b/pkg/gui/context/menu_context.go index 6f84a8274..088640ea0 100644 --- a/pkg/gui/context/menu_context.go +++ b/pkg/gui/context/menu_context.go @@ -56,7 +56,7 @@ func (self *MenuContext) GetSelectedItemId() string { type MenuViewModel struct { c *ContextCommon menuItems []*types.MenuItem - *BasicViewModel[*types.MenuItem] + *FilteredListViewModel[*types.MenuItem] } func NewMenuViewModel(c *ContextCommon) *MenuViewModel { @@ -65,7 +65,10 @@ func NewMenuViewModel(c *ContextCommon) *MenuViewModel { c: c, } - self.BasicViewModel = NewBasicViewModel(func() []*types.MenuItem { return self.menuItems }) + self.FilteredListViewModel = NewFilteredListViewModel( + func() []*types.MenuItem { return self.menuItems }, + func(item *types.MenuItem) []string { return item.LabelColumns }, + ) return self } @@ -76,11 +79,12 @@ func (self *MenuViewModel) SetMenuItems(items []*types.MenuItem) { // TODO: move into presentation package func (self *MenuViewModel) GetDisplayStrings(_startIdx int, _length int) [][]string { - showKeys := slices.Some(self.menuItems, func(item *types.MenuItem) bool { + menuItems := self.FilteredListViewModel.GetItems() + showKeys := slices.Some(menuItems, func(item *types.MenuItem) bool { return item.Key != nil }) - return slices.Map(self.menuItems, func(item *types.MenuItem) []string { + return slices.Map(menuItems, func(item *types.MenuItem) []string { displayStrings := item.LabelColumns if !showKeys { @@ -93,6 +97,7 @@ func (self *MenuViewModel) GetDisplayStrings(_startIdx int, _length int) [][]str self.c.UserConfig.Keybinding.Universal.Confirm, self.c.UserConfig.Keybinding.Universal.Select, self.c.UserConfig.Keybinding.Universal.Return, + self.c.UserConfig.Keybinding.Universal.StartSearch, } keyLabel := keybindings.LabelFromKey(item.Key) keyStyle := style.FgCyan diff --git a/pkg/gui/context/patch_explorer_context.go b/pkg/gui/context/patch_explorer_context.go index 1c986ee1d..17ecae4ae 100644 --- a/pkg/gui/context/patch_explorer_context.go +++ b/pkg/gui/context/patch_explorer_context.go @@ -9,6 +9,7 @@ import ( type PatchExplorerContext struct { *SimpleContext + *SearchTrait state *patch_exploring.State viewTrait *ViewTrait @@ -28,7 +29,7 @@ func NewPatchExplorerContext( c *ContextCommon, ) *PatchExplorerContext { - return &PatchExplorerContext{ + ctx := &PatchExplorerContext{ state: nil, viewTrait: NewViewTrait(view), c: c, @@ -42,7 +43,18 @@ func NewPatchExplorerContext( Focusable: true, HighlightOnFocus: true, })), + SearchTrait: NewSearchTrait(c), } + + ctx.GetView().SetOnSelectItem(ctx.SearchTrait.onSelectItemWrapper( + func(selectedLineIdx int) error { + ctx.GetMutex().Lock() + defer ctx.GetMutex().Unlock() + return ctx.NavigateTo(ctx.c.IsCurrentContext(ctx), selectedLineIdx) + }), + ) + + return ctx } func (self *PatchExplorerContext) IsPatchExplorerContext() {} diff --git a/pkg/gui/context/reflog_commits_context.go b/pkg/gui/context/reflog_commits_context.go index a92d605c8..421a7c8d5 100644 --- a/pkg/gui/context/reflog_commits_context.go +++ b/pkg/gui/context/reflog_commits_context.go @@ -9,7 +9,7 @@ import ( ) type ReflogCommitsContext struct { - *BasicViewModel[*models.Commit] + *FilteredListViewModel[*models.Commit] *ListContextTrait } @@ -19,11 +19,16 @@ var ( ) func NewReflogCommitsContext(c *ContextCommon) *ReflogCommitsContext { - viewModel := NewBasicViewModel(func() []*models.Commit { return c.Model().FilteredReflogCommits }) + viewModel := NewFilteredListViewModel( + func() []*models.Commit { return c.Model().FilteredReflogCommits }, + func(commit *models.Commit) []string { + return []string{commit.ShortSha(), commit.Name} + }, + ) getDisplayStrings := func(startIdx int, length int) [][]string { return presentation.GetReflogCommitListDisplayStrings( - c.Model().FilteredReflogCommits, + viewModel.GetItems(), c.State().GetRepoState().GetScreenMode() != types.SCREEN_NORMAL, c.Modes().CherryPicking.SelectedShaSet(), c.Modes().Diffing.Ref, @@ -35,7 +40,7 @@ func NewReflogCommitsContext(c *ContextCommon) *ReflogCommitsContext { } return &ReflogCommitsContext{ - BasicViewModel: viewModel, + FilteredListViewModel: viewModel, ListContextTrait: &ListContextTrait{ Context: NewSimpleContext(NewBaseContext(NewBaseContextOpts{ View: c.Views().ReflogCommits, diff --git a/pkg/gui/context/remote_branches_context.go b/pkg/gui/context/remote_branches_context.go index a085c18cc..602a19a65 100644 --- a/pkg/gui/context/remote_branches_context.go +++ b/pkg/gui/context/remote_branches_context.go @@ -7,7 +7,7 @@ import ( ) type RemoteBranchesContext struct { - *BasicViewModel[*models.RemoteBranch] + *FilteredListViewModel[*models.RemoteBranch] *ListContextTrait *DynamicTitleBuilder } @@ -20,15 +20,20 @@ var ( func NewRemoteBranchesContext( c *ContextCommon, ) *RemoteBranchesContext { - viewModel := NewBasicViewModel(func() []*models.RemoteBranch { return c.Model().RemoteBranches }) + viewModel := NewFilteredListViewModel( + func() []*models.RemoteBranch { return c.Model().RemoteBranches }, + func(remoteBranch *models.RemoteBranch) []string { + return []string{remoteBranch.Name} + }, + ) getDisplayStrings := func(startIdx int, length int) [][]string { - return presentation.GetRemoteBranchListDisplayStrings(c.Model().RemoteBranches, c.Modes().Diffing.Ref) + return presentation.GetRemoteBranchListDisplayStrings(viewModel.GetItems(), c.Modes().Diffing.Ref) } return &RemoteBranchesContext{ - BasicViewModel: viewModel, - DynamicTitleBuilder: NewDynamicTitleBuilder(c.Tr.RemoteBranchesDynamicTitle), + FilteredListViewModel: viewModel, + DynamicTitleBuilder: NewDynamicTitleBuilder(c.Tr.RemoteBranchesDynamicTitle), ListContextTrait: &ListContextTrait{ Context: NewSimpleContext(NewBaseContext(NewBaseContextOpts{ View: c.Views().RemoteBranches, diff --git a/pkg/gui/context/remotes_context.go b/pkg/gui/context/remotes_context.go index d1082ab52..f5e2a97ab 100644 --- a/pkg/gui/context/remotes_context.go +++ b/pkg/gui/context/remotes_context.go @@ -7,7 +7,7 @@ import ( ) type RemotesContext struct { - *BasicViewModel[*models.Remote] + *FilteredListViewModel[*models.Remote] *ListContextTrait } @@ -17,14 +17,19 @@ var ( ) func NewRemotesContext(c *ContextCommon) *RemotesContext { - viewModel := NewBasicViewModel(func() []*models.Remote { return c.Model().Remotes }) + viewModel := NewFilteredListViewModel( + func() []*models.Remote { return c.Model().Remotes }, + func(remote *models.Remote) []string { + return []string{remote.Name} + }, + ) getDisplayStrings := func(startIdx int, length int) [][]string { - return presentation.GetRemoteListDisplayStrings(c.Model().Remotes, c.Modes().Diffing.Ref) + return presentation.GetRemoteListDisplayStrings(viewModel.GetItems(), c.Modes().Diffing.Ref) } return &RemotesContext{ - BasicViewModel: viewModel, + FilteredListViewModel: viewModel, ListContextTrait: &ListContextTrait{ Context: NewSimpleContext(NewBaseContext(NewBaseContextOpts{ View: c.Views().Remotes, diff --git a/pkg/gui/context/search_trait.go b/pkg/gui/context/search_trait.go new file mode 100644 index 000000000..5e745f995 --- /dev/null +++ b/pkg/gui/context/search_trait.go @@ -0,0 +1,70 @@ +package context + +import ( + "fmt" + + "github.com/jesseduffield/lazygit/pkg/gui/keybindings" + "github.com/jesseduffield/lazygit/pkg/theme" +) + +type SearchTrait struct { + c *ContextCommon + + searchString string +} + +func NewSearchTrait(c *ContextCommon) *SearchTrait { + return &SearchTrait{c: c} +} + +func (self *SearchTrait) GetSearchString() string { + return self.searchString +} + +func (self *SearchTrait) SetSearchString(searchString string) { + self.searchString = searchString +} + +func (self *SearchTrait) ClearSearchString() { + self.SetSearchString("") +} + +// used for type switch +func (self *SearchTrait) IsSearchableContext() {} + +func (self *SearchTrait) onSelectItemWrapper(innerFunc func(int) error) func(int, int, int) error { + keybindingConfig := self.c.UserConfig.Keybinding + + return func(y int, index int, total int) error { + if total == 0 { + self.c.SetViewContent( + self.c.Views().Search, + fmt.Sprintf( + self.c.Tr.NoMatchesFor, + self.searchString, + theme.OptionsFgColor.Sprintf(self.c.Tr.ExitSearchMode, keybindings.Label(keybindingConfig.Universal.Return)), + ), + ) + return nil + } + self.c.SetViewContent( + self.c.Views().Search, + fmt.Sprintf( + self.c.Tr.MatchesFor, + self.searchString, + index+1, + total, + theme.OptionsFgColor.Sprintf( + self.c.Tr.SearchKeybindings, + keybindings.Label(keybindingConfig.Universal.NextMatch), + keybindings.Label(keybindingConfig.Universal.PrevMatch), + keybindings.Label(keybindingConfig.Universal.Return), + ), + ), + ) + if err := innerFunc(y); err != nil { + return err + } + return nil + } +} diff --git a/pkg/gui/context/stash_context.go b/pkg/gui/context/stash_context.go index 386292c00..7bd4740f8 100644 --- a/pkg/gui/context/stash_context.go +++ b/pkg/gui/context/stash_context.go @@ -7,7 +7,7 @@ import ( ) type StashContext struct { - *BasicViewModel[*models.StashEntry] + *FilteredListViewModel[*models.StashEntry] *ListContextTrait } @@ -19,14 +19,19 @@ var ( func NewStashContext( c *ContextCommon, ) *StashContext { - viewModel := NewBasicViewModel(func() []*models.StashEntry { return c.Model().StashEntries }) + viewModel := NewFilteredListViewModel( + func() []*models.StashEntry { return c.Model().StashEntries }, + func(stashEntry *models.StashEntry) []string { + return []string{stashEntry.Name} + }, + ) getDisplayStrings := func(startIdx int, length int) [][]string { - return presentation.GetStashEntryListDisplayStrings(c.Model().StashEntries, c.Modes().Diffing.Ref) + return presentation.GetStashEntryListDisplayStrings(viewModel.GetItems(), c.Modes().Diffing.Ref) } return &StashContext{ - BasicViewModel: viewModel, + FilteredListViewModel: viewModel, ListContextTrait: &ListContextTrait{ Context: NewSimpleContext(NewBaseContext(NewBaseContextOpts{ View: c.Views().Stash, diff --git a/pkg/gui/context/sub_commits_context.go b/pkg/gui/context/sub_commits_context.go index e2c89aa14..0cf884589 100644 --- a/pkg/gui/context/sub_commits_context.go +++ b/pkg/gui/context/sub_commits_context.go @@ -12,9 +12,12 @@ import ( ) type SubCommitsContext struct { + c *ContextCommon + *SubCommitsViewModel *ListContextTrait *DynamicTitleBuilder + *SearchTrait } var ( @@ -26,7 +29,7 @@ func NewSubCommitsContext( c *ContextCommon, ) *SubCommitsContext { viewModel := &SubCommitsViewModel{ - BasicViewModel: NewBasicViewModel( + ListViewModel: NewListViewModel( func() []*models.Commit { return c.Model().SubCommits }, ), ref: nil, @@ -60,8 +63,10 @@ func NewSubCommitsContext( ) } - return &SubCommitsContext{ + ctx := &SubCommitsContext{ + c: c, SubCommitsViewModel: viewModel, + SearchTrait: NewSearchTrait(c), DynamicTitleBuilder: NewDynamicTitleBuilder(c.Tr.SubCommitsDynamicTitle), ListContextTrait: &ListContextTrait{ Context: NewSimpleContext(NewBaseContext(NewBaseContextOpts{ @@ -78,12 +83,19 @@ func NewSubCommitsContext( refreshViewportOnChange: true, }, } + + ctx.GetView().SetOnSelectItem(ctx.SearchTrait.onSelectItemWrapper(func(selectedLineIdx int) error { + ctx.GetList().SetSelectedLineIdx(selectedLineIdx) + return ctx.HandleFocus(types.OnFocusOpts{}) + })) + + return ctx } type SubCommitsViewModel struct { // name of the ref that the sub-commits are shown for ref types.Ref - *BasicViewModel[*models.Commit] + *ListViewModel[*models.Commit] limitCommits bool } diff --git a/pkg/gui/context/submodules_context.go b/pkg/gui/context/submodules_context.go index 675e01cd1..e97fa4f5c 100644 --- a/pkg/gui/context/submodules_context.go +++ b/pkg/gui/context/submodules_context.go @@ -7,21 +7,26 @@ import ( ) type SubmodulesContext struct { - *BasicViewModel[*models.SubmoduleConfig] + *FilteredListViewModel[*models.SubmoduleConfig] *ListContextTrait } var _ types.IListContext = (*SubmodulesContext)(nil) func NewSubmodulesContext(c *ContextCommon) *SubmodulesContext { - viewModel := NewBasicViewModel(func() []*models.SubmoduleConfig { return c.Model().Submodules }) + viewModel := NewFilteredListViewModel( + func() []*models.SubmoduleConfig { return c.Model().Submodules }, + func(submodule *models.SubmoduleConfig) []string { + return []string{submodule.Name} + }, + ) getDisplayStrings := func(startIdx int, length int) [][]string { - return presentation.GetSubmoduleListDisplayStrings(c.Model().Submodules) + return presentation.GetSubmoduleListDisplayStrings(viewModel.GetItems()) } return &SubmodulesContext{ - BasicViewModel: viewModel, + FilteredListViewModel: viewModel, ListContextTrait: &ListContextTrait{ Context: NewSimpleContext(NewBaseContext(NewBaseContextOpts{ View: c.Views().Submodules, diff --git a/pkg/gui/context/suggestions_context.go b/pkg/gui/context/suggestions_context.go index 022e96daf..58b2205a4 100644 --- a/pkg/gui/context/suggestions_context.go +++ b/pkg/gui/context/suggestions_context.go @@ -7,7 +7,7 @@ import ( ) type SuggestionsContext struct { - *BasicViewModel[*types.Suggestion] + *ListViewModel[*types.Suggestion] *ListContextTrait State *SuggestionsContextState @@ -40,11 +40,11 @@ func NewSuggestionsContext( return presentation.GetSuggestionListDisplayStrings(state.Suggestions) } - viewModel := NewBasicViewModel(getModel) + viewModel := NewListViewModel(getModel) return &SuggestionsContext{ - State: state, - BasicViewModel: viewModel, + State: state, + ListViewModel: viewModel, ListContextTrait: &ListContextTrait{ Context: NewSimpleContext(NewBaseContext(NewBaseContextOpts{ View: c.Views().Suggestions, diff --git a/pkg/gui/context/tags_context.go b/pkg/gui/context/tags_context.go index e49cdad9b..71ea36981 100644 --- a/pkg/gui/context/tags_context.go +++ b/pkg/gui/context/tags_context.go @@ -7,7 +7,7 @@ import ( ) type TagsContext struct { - *BasicViewModel[*models.Tag] + *FilteredListViewModel[*models.Tag] *ListContextTrait } @@ -19,14 +19,19 @@ var ( func NewTagsContext( c *ContextCommon, ) *TagsContext { - viewModel := NewBasicViewModel(func() []*models.Tag { return c.Model().Tags }) + viewModel := NewFilteredListViewModel( + func() []*models.Tag { return c.Model().Tags }, + func(tag *models.Tag) []string { + return []string{tag.Name, tag.Message} + }, + ) getDisplayStrings := func(startIdx int, length int) [][]string { - return presentation.GetTagListDisplayStrings(c.Model().Tags, c.Modes().Diffing.Ref) + return presentation.GetTagListDisplayStrings(viewModel.GetItems(), c.Modes().Diffing.Ref) } return &TagsContext{ - BasicViewModel: viewModel, + FilteredListViewModel: viewModel, ListContextTrait: &ListContextTrait{ Context: NewSimpleContext(NewBaseContext(NewBaseContextOpts{ View: c.Views().Tags, diff --git a/pkg/gui/controllers.go b/pkg/gui/controllers.go index 78943e798..a17592513 100644 --- a/pkg/gui/controllers.go +++ b/pkg/gui/controllers.go @@ -99,6 +99,7 @@ func (gui *Gui) resetHelpersAndControllers() { modeHelper, appStatusHelper, ), + Search: helpers.NewSearchHelper(helperCommon), } gui.CustomCommandsClient = custom_commands.NewClient( @@ -162,6 +163,16 @@ func (gui *Gui) resetHelpersAndControllers() { sideWindowControllerFactory := controllers.NewSideWindowControllerFactory(common) + filterControllerFactory := controllers.NewFilterControllerFactory(common) + for _, context := range gui.c.Context().AllFilterable() { + controllers.AttachControllers(context, filterControllerFactory.Create(context)) + } + + searchControllerFactory := controllers.NewSearchControllerFactory(common) + for _, context := range gui.c.Context().AllSearchable() { + controllers.AttachControllers(context, searchControllerFactory.Create(context)) + } + // allow for navigating between side window contexts for _, context := range []types.Context{ gui.State.Contexts.Status, @@ -323,6 +334,10 @@ func (gui *Gui) resetHelpersAndControllers() { suggestionsController, ) + controllers.AttachControllers(gui.State.Contexts.Search, + controllers.NewSearchPromptController(common), + ) + controllers.AttachControllers(gui.State.Contexts.Global, syncController, undoController, diff --git a/pkg/gui/controllers/filter_controller.go b/pkg/gui/controllers/filter_controller.go new file mode 100644 index 000000000..8b049b26c --- /dev/null +++ b/pkg/gui/controllers/filter_controller.go @@ -0,0 +1,48 @@ +package controllers + +import ( + "github.com/jesseduffield/lazygit/pkg/gui/types" +) + +type FilterControllerFactory struct { + c *ControllerCommon +} + +func NewFilterControllerFactory(c *ControllerCommon) *FilterControllerFactory { + return &FilterControllerFactory{ + c: c, + } +} + +func (self *FilterControllerFactory) Create(context types.IFilterableContext) *FilterController { + return &FilterController{ + baseController: baseController{}, + c: self.c, + context: context, + } +} + +type FilterController struct { + baseController + c *ControllerCommon + + context types.IFilterableContext +} + +func (self *FilterController) Context() types.Context { + return self.context +} + +func (self *FilterController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { + return []*types.Binding{ + { + Key: opts.GetKey(opts.Config.Universal.StartSearch), + Handler: self.OpenFilterPrompt, + Description: self.c.Tr.StartFilter, + }, + } +} + +func (self *FilterController) OpenFilterPrompt() error { + return self.c.Helpers().Search.OpenFilterPrompt(self.context) +} diff --git a/pkg/gui/controllers/helpers/confirmation_helper.go b/pkg/gui/controllers/helpers/confirmation_helper.go index 7968933fc..c721310b2 100644 --- a/pkg/gui/controllers/helpers/confirmation_helper.go +++ b/pkg/gui/controllers/helpers/confirmation_helper.go @@ -292,7 +292,9 @@ func (self *ConfirmationHelper) ResizePopupPanel(v *gocui.View, content string) } func (self *ConfirmationHelper) resizeMenu() { - itemCount := self.c.Contexts().Menu.GetList().Len() + // we want the unfiltered length here so that if we're filtering we don't + // resize the window + itemCount := self.c.Contexts().Menu.UnfilteredLen() offset := 3 panelWidth := self.getPopupPanelWidth() x0, y0, x1, y1 := self.getPopupPanelDimensionsForContentHeight(panelWidth, itemCount+offset) diff --git a/pkg/gui/controllers/helpers/helpers.go b/pkg/gui/controllers/helpers/helpers.go index faf342f0a..846638249 100644 --- a/pkg/gui/controllers/helpers/helpers.go +++ b/pkg/gui/controllers/helpers/helpers.go @@ -46,6 +46,7 @@ type Helpers struct { Mode *ModeHelper AppStatus *AppStatusHelper WindowArrangement *WindowArrangementHelper + Search *SearchHelper } func NewStubHelpers() *Helpers { @@ -78,5 +79,6 @@ func NewStubHelpers() *Helpers { Mode: &ModeHelper{}, AppStatus: &AppStatusHelper{}, WindowArrangement: &WindowArrangementHelper{}, + Search: &SearchHelper{}, } } diff --git a/pkg/gui/controllers/helpers/search_helper.go b/pkg/gui/controllers/helpers/search_helper.go new file mode 100644 index 000000000..9c0e09db4 --- /dev/null +++ b/pkg/gui/controllers/helpers/search_helper.go @@ -0,0 +1,196 @@ +package helpers + +import ( + "github.com/jesseduffield/gocui" + "github.com/jesseduffield/lazygit/pkg/gui/types" +) + +// 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.Context = context + + self.searchPrefixView().SetContent(self.c.Tr.FilterPrefix) + promptView := self.promptView() + promptView.ClearTextArea() + promptView.TextArea.TypeString(context.GetFilter()) + promptView.RenderTextArea() + + if err := self.c.PushContext(self.c.Contexts().Search); err != nil { + return err + } + + return nil +} + +func (self *SearchHelper) OpenSearchPrompt(context types.Context) error { + state := self.searchState() + + state.Context = context + + self.searchPrefixView().SetContent(self.c.Tr.SearchPrefix) + promptView := self.promptView() + // TODO: should we show the currently searched thing here? Perhaps we can store that on the context + promptView.ClearTextArea() + promptView.RenderTextArea() + + if err := self.c.PushContext(self.c.Contexts().Search); err != nil { + return err + } + + return nil +} + +func (self *SearchHelper) DisplayFilterPrompt(context types.IFilterableContext) { + state := self.searchState() + + state.Context = context + searchString := context.GetFilter() + + self.searchPrefixView().SetContent(self.c.Tr.FilterPrefix) + promptView := self.promptView() + promptView.ClearTextArea() + promptView.TextArea.TypeString(searchString) + promptView.RenderTextArea() +} + +func (self *SearchHelper) DisplaySearchPrompt(context types.ISearchableContext) { + state := self.searchState() + + state.Context = context + searchString := context.GetSearchString() + + self.searchPrefixView().SetContent(self.c.Tr.SearchPrefix) + promptView := self.promptView() + promptView.ClearTextArea() + promptView.TextArea.TypeString(searchString) + promptView.RenderTextArea() +} + +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: + return self.ConfirmFilter() + case types.SearchTypeSearch: + return self.ConfirmSearch() + case types.SearchTypeNone: + return self.c.PopContext() + } + + return nil +} + +func (self *SearchHelper) ConfirmFilter() error { + // 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 nil + } + + context.SetFilter(self.promptContent()) + _ = self.c.PostRefreshUpdate(state.Context) + + return self.c.PopContext() +} + +func (self *SearchHelper) ConfirmSearch() error { + state := self.searchState() + + if err := self.c.PopContext(); err != nil { + return err + } + + context, ok := state.Context.(types.ISearchableContext) + if !ok { + self.c.Log.Warnf("Context %s is searchable", state.Context.GetKey()) + return nil + } + + searchString := self.promptContent() + context.SetSearchString(searchString) + + view := context.GetView() + + if err := view.Search(searchString); err != nil { + return err + } + + return nil +} + +func (self *SearchHelper) CancelPrompt() error { + self.Cancel() + + return self.c.PopContext() +} + +func (self *SearchHelper) Cancel() { + state := self.searchState() + + switch context := state.Context.(type) { + case types.IFilterableContext: + context.SetFilter("") + _ = self.c.PostRefreshUpdate(context) + case types.ISearchableContext: + context.GetView().ClearSearch() + default: + // do nothing + } + + state.Context = nil +} + +func (self *SearchHelper) OnPromptContentChanged(searchString string) { + state := self.searchState() + switch context := state.Context.(type) { + case types.IFilterableContext: + context.SetFilter(searchString) + _ = self.c.PostRefreshUpdate(context) + case types.ISearchableContext: + // do nothing + default: + // do nothing (shouldn't land here) + } +} diff --git a/pkg/gui/controllers/helpers/window_arrangement_helper.go b/pkg/gui/controllers/helpers/window_arrangement_helper.go index 20459993f..b45586764 100644 --- a/pkg/gui/controllers/helpers/window_arrangement_helper.go +++ b/pkg/gui/controllers/helpers/window_arrangement_helper.go @@ -55,7 +55,7 @@ func (self *WindowArrangementHelper) GetWindowDimensions(informationStr string, self.c.Modes().Filtering.Active() showInfoSection := self.c.UserConfig.Gui.ShowBottomLine || - self.c.State().GetRepoState().IsSearching() || + self.c.State().GetRepoState().InSearchPrompt() || self.modeHelper.IsAnyModeActive() || self.appStatusHelper.HasStatus() infoSectionSize := 0 @@ -174,11 +174,17 @@ func (self *WindowArrangementHelper) getMidSectionWeights() (int, int) { } func (self *WindowArrangementHelper) infoSectionChildren(informationStr string, appStatus string) []*boxlayout.Box { - if self.c.State().GetRepoState().IsSearching() { + if self.c.State().GetRepoState().InSearchPrompt() { + var prefix string + if self.c.State().GetRepoState().GetSearchState().SearchType() == types.SearchTypeSearch { + prefix = self.c.Tr.SearchPrefix + } else { + prefix = self.c.Tr.FilterPrefix + } return []*boxlayout.Box{ { Window: "searchPrefix", - Size: runewidth.StringWidth(self.c.Tr.SearchPrefix), + Size: runewidth.StringWidth(prefix), }, { Window: "search", diff --git a/pkg/gui/controllers/list_controller.go b/pkg/gui/controllers/list_controller.go index 2f995ebc8..fb6d8736a 100644 --- a/pkg/gui/controllers/list_controller.go +++ b/pkg/gui/controllers/list_controller.go @@ -150,18 +150,7 @@ func (self *ListController) GetKeybindings(opts types.KeybindingsOpts) []*types. {Tag: "navigation", Key: opts.GetKey(opts.Config.Universal.GotoTop), Handler: self.HandleGotoTop, Description: self.c.Tr.GotoTop}, {Tag: "navigation", Key: opts.GetKey(opts.Config.Universal.ScrollLeft), Handler: self.HandleScrollLeft}, {Tag: "navigation", Key: opts.GetKey(opts.Config.Universal.ScrollRight), Handler: self.HandleScrollRight}, - { - Key: opts.GetKey(opts.Config.Universal.StartSearch), - Handler: func() error { self.c.OpenSearch(); return nil }, - Description: self.c.Tr.StartSearch, - Tag: "navigation", - }, - { - Key: opts.GetKey(opts.Config.Universal.GotoBottom), - Description: self.c.Tr.GotoBottom, - Handler: self.HandleGotoBottom, - Tag: "navigation", - }, + {Tag: "navigation", Key: opts.GetKey(opts.Config.Universal.GotoBottom), Handler: self.HandleGotoBottom, Description: self.c.Tr.GotoBottom}, } } diff --git a/pkg/gui/controllers/local_commits_controller.go b/pkg/gui/controllers/local_commits_controller.go index 0ba80c768..49abe02ff 100644 --- a/pkg/gui/controllers/local_commits_controller.go +++ b/pkg/gui/controllers/local_commits_controller.go @@ -693,9 +693,7 @@ func (self *LocalCommitsController) openSearch() error { } } - self.c.OpenSearch() - - return nil + return self.c.Helpers().Search.OpenSearchPrompt(self.context()) } func (self *LocalCommitsController) gotoBottom() error { diff --git a/pkg/gui/controllers/patch_explorer_controller.go b/pkg/gui/controllers/patch_explorer_controller.go index 6de8fb8b9..dd19d08db 100644 --- a/pkg/gui/controllers/patch_explorer_controller.go +++ b/pkg/gui/controllers/patch_explorer_controller.go @@ -123,12 +123,6 @@ func (self *PatchExplorerController) GetKeybindings(opts types.KeybindingsOpts) Key: opts.GetKey(opts.Config.Universal.ScrollRight), Handler: self.withRenderAndFocus(self.HandleScrollRight), }, - { - Tag: "navigation", - Key: opts.GetKey(opts.Config.Universal.StartSearch), - Handler: func() error { self.c.OpenSearch(); return nil }, - Description: self.c.Tr.StartSearch, - }, { Key: opts.GetKey(opts.Config.Universal.CopyToClipboard), Handler: self.withLock(self.CopySelectedToClipboard), diff --git a/pkg/gui/controllers/search_controller.go b/pkg/gui/controllers/search_controller.go new file mode 100644 index 000000000..395784d10 --- /dev/null +++ b/pkg/gui/controllers/search_controller.go @@ -0,0 +1,48 @@ +package controllers + +import ( + "github.com/jesseduffield/lazygit/pkg/gui/types" +) + +type SearchControllerFactory struct { + c *ControllerCommon +} + +func NewSearchControllerFactory(c *ControllerCommon) *SearchControllerFactory { + return &SearchControllerFactory{ + c: c, + } +} + +func (self *SearchControllerFactory) Create(context types.ISearchableContext) *SearchController { + return &SearchController{ + baseController: baseController{}, + c: self.c, + context: context, + } +} + +type SearchController struct { + baseController + c *ControllerCommon + + context types.ISearchableContext +} + +func (self *SearchController) Context() types.Context { + return self.context +} + +func (self *SearchController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { + return []*types.Binding{ + { + Key: opts.GetKey(opts.Config.Universal.StartSearch), + Handler: self.OpenSearchPrompt, + Description: self.c.Tr.StartSearch, + }, + } +} + +func (self *SearchController) OpenSearchPrompt() error { + return self.c.Helpers().Search.OpenSearchPrompt(self.context) +} diff --git a/pkg/gui/controllers/search_prompt_controller.go b/pkg/gui/controllers/search_prompt_controller.go new file mode 100644 index 000000000..2326ed1c1 --- /dev/null +++ b/pkg/gui/controllers/search_prompt_controller.go @@ -0,0 +1,53 @@ +package controllers + +import ( + "github.com/jesseduffield/gocui" + "github.com/jesseduffield/lazygit/pkg/gui/types" +) + +type SearchPromptController struct { + baseController + c *ControllerCommon +} + +var _ types.IController = &SearchPromptController{} + +func NewSearchPromptController( + common *ControllerCommon, +) *SearchPromptController { + return &SearchPromptController{ + baseController: baseController{}, + c: common, + } +} + +func (self *SearchPromptController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { + return []*types.Binding{ + { + Key: opts.GetKey(opts.Config.Universal.Confirm), + Modifier: gocui.ModNone, + Handler: self.confirm, + }, + { + Key: opts.GetKey(opts.Config.Universal.Return), + Modifier: gocui.ModNone, + Handler: self.cancel, + }, + } +} + +func (self *SearchPromptController) Context() types.Context { + return self.context() +} + +func (self *SearchPromptController) context() types.Context { + return self.c.Contexts().Search +} + +func (self *SearchPromptController) confirm() error { + return self.c.Helpers().Search.Confirm() +} + +func (self *SearchPromptController) cancel() error { + return self.c.Helpers().Search.CancelPrompt() +} diff --git a/pkg/gui/editors.go b/pkg/gui/editors.go index 1fbba2aad..b095630ec 100644 --- a/pkg/gui/editors.go +++ b/pkg/gui/editors.go @@ -89,3 +89,14 @@ func (gui *Gui) promptEditor(v *gocui.View, key gocui.Key, ch rune, mod gocui.Mo return matched } + +func (gui *Gui) searchEditor(v *gocui.View, key gocui.Key, ch rune, mod gocui.Modifier) bool { + matched := gui.handleEditorKeypress(v.TextArea, key, ch, mod, false) + v.RenderTextArea() + + searchString := v.TextArea.GetContent() + + gui.helpers.Search.OnPromptContentChanged(searchString) + + return matched +} diff --git a/pkg/gui/filetree/file_tree_view_model.go b/pkg/gui/filetree/file_tree_view_model.go index 333be8da2..b48aaffab 100644 --- a/pkg/gui/filetree/file_tree_view_model.go +++ b/pkg/gui/filetree/file_tree_view_model.go @@ -26,6 +26,8 @@ type FileTreeViewModel struct { var _ IFileTreeViewModel = &FileTreeViewModel{} +// how to tackle this? We could just filter down the list of files at a high point and then the rest will take care of itself. + func NewFileTreeViewModel(getFiles func() []*models.File, log *logrus.Entry, showTree bool) *FileTreeViewModel { fileTree := NewFileTree(getFiles, log, showTree) listCursor := traits.NewListCursor(fileTree) diff --git a/pkg/gui/gui.go b/pkg/gui/gui.go index b922c5f77..106aee7a9 100644 --- a/pkg/gui/gui.go +++ b/pkg/gui/gui.go @@ -201,7 +201,7 @@ type GuiRepoState struct { SplitMainPanel bool LimitCommits bool - Searching searchingState + SearchState *types.SearchState StartupStage types.StartupStage // Allows us to not load everything at once ContextMgr *ContextMgr @@ -256,8 +256,12 @@ func (self *GuiRepoState) SetScreenMode(value types.WindowMaximisation) { self.ScreenMode = value } -func (self *GuiRepoState) IsSearching() bool { - return self.Searching.isSearching +func (self *GuiRepoState) InSearchPrompt() bool { + return self.SearchState.SearchType() != types.SearchTypeNone +} + +func (self *GuiRepoState) GetSearchState() *types.SearchState { + return self.SearchState } func (self *GuiRepoState) SetSplitMainPanel(value bool) { @@ -268,12 +272,6 @@ func (self *GuiRepoState) GetSplitMainPanel() bool { return self.SplitMainPanel } -type searchingState struct { - view *gocui.View - isSearching bool - searchString string -} - func (gui *Gui) onNewRepo(startArgs appTypes.StartArgs, reuseState bool) error { var err error gui.git, err = commands.NewGitCommand( @@ -358,6 +356,7 @@ func (gui *Gui) resetState(startArgs appTypes.StartArgs, reuseState bool) types. ContextMgr: NewContextMgr(gui, contextTree), Contexts: contextTree, WindowViewNameMap: initialWindowViewNameMap(contextTree), + SearchState: types.NewSearchState(), } gui.RepoStateMap[Repo(currentDir)] = gui.State @@ -584,11 +583,12 @@ func (gui *Gui) Run(startArgs appTypes.StartArgs) error { }) deadlock.Opts.Disable = !gui.Debug - gui.g.OnSearchEscape = gui.onSearchEscape if err := gui.Config.ReloadUserConfig(); err != nil { return nil } userConfig := gui.UserConfig + + gui.g.OnSearchEscape = func() error { gui.helpers.Search.Cancel(); return nil } gui.g.SearchEscapeKey = keybindings.GetKey(userConfig.Keybinding.Universal.Return) gui.g.NextSearchMatchKey = keybindings.GetKey(userConfig.Keybinding.Universal.NextMatch) gui.g.PrevSearchMatchKey = keybindings.GetKey(userConfig.Keybinding.Universal.PrevMatch) diff --git a/pkg/gui/gui_common.go b/pkg/gui/gui_common.go index dfed29c44..8fc7732fc 100644 --- a/pkg/gui/gui_common.go +++ b/pkg/gui/gui_common.go @@ -128,10 +128,6 @@ func (self *guiCommon) Mutexes() types.Mutexes { return self.gui.Mutexes } -func (self *guiCommon) OpenSearch() { - _ = self.gui.handleOpenSearch(self.gui.currentViewName()) -} - func (self *guiCommon) GocuiGui() *gocui.Gui { return self.gui.g } diff --git a/pkg/gui/keybindings.go b/pkg/gui/keybindings.go index 30ddb8951..cb14d0266 100644 --- a/pkg/gui/keybindings.go +++ b/pkg/gui/keybindings.go @@ -215,18 +215,6 @@ func (self *Gui) GetInitialKeybindings() ([]*types.Binding, []*gocui.ViewMouseBi Modifier: gocui.ModNone, Handler: self.scrollUpSecondary, }, - { - ViewName: "search", - Key: opts.GetKey(opts.Config.Universal.Confirm), - Modifier: gocui.ModNone, - Handler: self.handleSearch, - }, - { - ViewName: "search", - Key: opts.GetKey(opts.Config.Universal.Return), - Modifier: gocui.ModNone, - Handler: self.handleSearchEscape, - }, { ViewName: "confirmation", Key: opts.GetKey(opts.Config.Universal.PrevItem), diff --git a/pkg/gui/layout.go b/pkg/gui/layout.go index 88ddeca5f..ed10fda92 100644 --- a/pkg/gui/layout.go +++ b/pkg/gui/layout.go @@ -132,20 +132,6 @@ func (gui *Gui) layout(g *gocui.Gui) error { } view.SelBgColor = theme.GocuiSelectedLineBgColor - - // I doubt this is expensive though it's admittedly redundant after the first render - view.SetOnSelectItem(gui.onSelectItemWrapper(listContext.OnSearchSelect)) - } - - for _, context := range gui.c.Context().AllPatchExplorer() { - context := context - context.GetView().SetOnSelectItem(gui.onSelectItemWrapper( - func(selectedLineIdx int) error { - context.GetMutex().Lock() - defer context.GetMutex().Unlock() - return context.NavigateTo(gui.c.IsCurrentContext(context), selectedLineIdx) - }), - ) } mainViewWidth, mainViewHeight := gui.Views.Main.Size() diff --git a/pkg/gui/menu_panel.go b/pkg/gui/menu_panel.go index f830526db..19c58b145 100644 --- a/pkg/gui/menu_panel.go +++ b/pkg/gui/menu_panel.go @@ -46,9 +46,6 @@ func (gui *Gui) createMenu(opts types.CreateMenuOptions) error { gui.Views.Menu.Title = opts.Title gui.Views.Menu.FgColor = theme.GocuiDefaultTextColor - gui.Views.Menu.SetOnSelectItem(gui.onSelectItemWrapper(func(selectedLine int) error { - return nil - })) gui.Views.Tooltip.Wrap = true gui.Views.Tooltip.FgColor = theme.GocuiDefaultTextColor diff --git a/pkg/gui/searching.go b/pkg/gui/searching.go deleted file mode 100644 index 21a07c4c2..000000000 --- a/pkg/gui/searching.go +++ /dev/null @@ -1,103 +0,0 @@ -package gui - -import ( - "fmt" - - "github.com/jesseduffield/lazygit/pkg/gui/keybindings" - "github.com/jesseduffield/lazygit/pkg/theme" -) - -func (gui *Gui) handleOpenSearch(viewName string) error { - view, err := gui.g.View(viewName) - if err != nil { - return nil - } - - gui.State.Searching.isSearching = true - gui.State.Searching.view = view - - gui.Views.Search.ClearTextArea() - - if err := gui.c.PushContext(gui.State.Contexts.Search); err != nil { - return err - } - - return nil -} - -func (gui *Gui) handleSearch() error { - gui.State.Searching.searchString = gui.Views.Search.TextArea.GetContent() - if err := gui.c.PopContext(); err != nil { - return err - } - - view := gui.State.Searching.view - if view == nil { - return nil - } - - if err := view.Search(gui.State.Searching.searchString); err != nil { - return err - } - - return nil -} - -func (gui *Gui) onSelectItemWrapper(innerFunc func(int) error) func(int, int, int) error { - keybindingConfig := gui.c.UserConfig.Keybinding - - return func(y int, index int, total int) error { - if total == 0 { - gui.c.SetViewContent( - gui.Views.Search, - fmt.Sprintf( - gui.Tr.NoMatchesFor, - gui.State.Searching.searchString, - theme.OptionsFgColor.Sprintf(gui.Tr.ExitSearchMode, keybindings.Label(keybindingConfig.Universal.Return)), - ), - ) - return nil - } - gui.c.SetViewContent( - gui.Views.Search, - fmt.Sprintf( - gui.Tr.MatchesFor, - gui.State.Searching.searchString, - index+1, - total, - theme.OptionsFgColor.Sprintf( - gui.Tr.SearchKeybindings, - keybindings.Label(keybindingConfig.Universal.NextMatch), - keybindings.Label(keybindingConfig.Universal.PrevMatch), - keybindings.Label(keybindingConfig.Universal.Return), - ), - ), - ) - if err := innerFunc(y); err != nil { - return err - } - return nil - } -} - -func (gui *Gui) onSearchEscape() error { - gui.State.Searching.isSearching = false - if gui.State.Searching.view != nil { - gui.State.Searching.view.ClearSearch() - gui.State.Searching.view = nil - } - - return nil -} - -func (gui *Gui) handleSearchEscape() error { - if err := gui.onSearchEscape(); err != nil { - return err - } - - if err := gui.c.PopContext(); err != nil { - return err - } - - return nil -} diff --git a/pkg/gui/types/common.go b/pkg/gui/types/common.go index 3709d3c7b..09ab040f2 100644 --- a/pkg/gui/types/common.go +++ b/pkg/gui/types/common.go @@ -68,8 +68,6 @@ type IGuiCommon interface { Context() IContextMgr ActivateContext(context Context) error - // enters search mode for the current view - OpenSearch() GetConfig() config.AppConfigurer GetAppState() *config.AppState @@ -251,7 +249,8 @@ type IRepoStateAccessor interface { SetCurrentPopupOpts(*CreatePopupPanelOpts) GetScreenMode() WindowMaximisation SetScreenMode(WindowMaximisation) - IsSearching() bool + InSearchPrompt() bool + GetSearchState() *SearchState SetSplitMainPanel(bool) GetSplitMainPanel() bool } diff --git a/pkg/gui/types/context.go b/pkg/gui/types/context.go index bb8630bff..9c2ccd0d5 100644 --- a/pkg/gui/types/context.go +++ b/pkg/gui/types/context.go @@ -87,6 +87,24 @@ type Context interface { HandleRenderToMain() error } +type IFilterableContext interface { + Context + + SetFilter(string) + GetFilter() string + ClearFilter() + IsFilterableContext() +} + +type ISearchableContext interface { + Context + + SetSearchString(string) + GetSearchString() string + ClearSearchString() + IsSearchableContext() +} + type DiffableContext interface { Context @@ -104,7 +122,6 @@ type IListContext interface { GetList() IList - OnSearchSelect(selectedLineIdx int) error FocusLine() IsListContext() // used for type switch } @@ -211,5 +228,7 @@ type IContextMgr interface { IsCurrent(c Context) bool ForEach(func(Context)) AllList() []IListContext + AllFilterable() []IFilterableContext + AllSearchable() []ISearchableContext AllPatchExplorer() []IPatchExplorerContext } diff --git a/pkg/gui/types/search_state.go b/pkg/gui/types/search_state.go new file mode 100644 index 000000000..9b24af095 --- /dev/null +++ b/pkg/gui/types/search_state.go @@ -0,0 +1,31 @@ +package types + +type SearchType int + +const ( + SearchTypeNone SearchType = iota + // searching is where matches are highlighted but the content is not filtered down + SearchTypeSearch + // filter is where the list is filtered down to only matches + SearchTypeFilter +) + +// TODO: could we remove this entirely? +type SearchState struct { + Context Context +} + +func NewSearchState() *SearchState { + return &SearchState{} +} + +func (self *SearchState) SearchType() SearchType { + switch self.Context.(type) { + case IFilterableContext: + return SearchTypeFilter + case ISearchableContext: + return SearchTypeSearch + default: + return SearchTypeNone + } +} diff --git a/pkg/gui/views.go b/pkg/gui/views.go index 6b97f17b7..043acdaed 100644 --- a/pkg/gui/views.go +++ b/pkg/gui/views.go @@ -95,6 +95,8 @@ func (gui *Gui) createAllViews() error { gui.Views.SearchPrefix.Frame = false gui.c.SetViewContent(gui.Views.SearchPrefix, gui.Tr.SearchPrefix) + gui.Views.Search.Editor = gocui.EditorFunc(gui.searchEditor) + gui.Views.Stash.Title = gui.c.Tr.StashTitle gui.Views.Commits.Title = gui.c.Tr.CommitsTitle diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index b803df360..e59d5e4df 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -371,6 +371,7 @@ type TranslationSet struct { NextScreenMode string PrevScreenMode string StartSearch string + StartFilter string Panel string Keybindings string KeybindingsLegend string @@ -536,6 +537,7 @@ type TranslationSet struct { MatchesFor string SearchKeybindings string SearchPrefix string + FilterPrefix string ExitSearchMode string Actions Actions Bisect Bisect @@ -1061,7 +1063,8 @@ func EnglishTranslationSet() TranslationSet { ViewResetToUpstreamOptions: "View upstream reset options", NextScreenMode: "Next screen mode (normal/half/fullscreen)", PrevScreenMode: "Prev screen mode", - StartSearch: "Start search", + StartSearch: "Search the current view", + StartFilter: "Filter the current view", Panel: "Panel", KeybindingsLegend: "Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b", RenameBranch: "Rename branch", @@ -1226,6 +1229,7 @@ func EnglishTranslationSet() TranslationSet { MatchesFor: "matches for '%s' (%d of %d) %s", // lowercase because it's after other text SearchKeybindings: "%s: Next match, %s: Previous match, %s: Exit search mode", SearchPrefix: "Search: ", + FilterPrefix: "Filter: ", Actions: Actions{ // TODO: combine this with the original keybinding descriptions (those are all in lowercase atm) CheckoutCommit: "Checkout commit", diff --git a/pkg/utils/slice.go b/pkg/utils/slice.go index aff6ae470..4a47f43b1 100644 --- a/pkg/utils/slice.go +++ b/pkg/utils/slice.go @@ -113,3 +113,14 @@ func MoveElement[T any](slice []T, from int, to int) []T { return newSlice } + +func ValuesAtIndices[T any](slice []T, indices []int) []T { + result := make([]T, len(indices)) + for i, index := range indices { + // gracefully handling the situation where the index is out of bounds + if index < len(slice) { + result[i] = slice[index] + } + } + return result +} -- cgit v1.2.3