From f1c07b3aede0eb2a62be3a5c763d1ce18c986637 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 14 Aug 2025 14:45:05 +0200 Subject: [PATCH 1/4] Strengthen text expectation This assertion didn't test anything useful, given that the filtered view already has two lines. This should have been adapted in 9f0b4d0000c5 when we added section headers while filtering. --- pkg/integration/tests/filter_and_search/filter_menu.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/integration/tests/filter_and_search/filter_menu.go b/pkg/integration/tests/filter_and_search/filter_menu.go index 59c47fb71..e5b6b216e 100644 --- a/pkg/integration/tests/filter_and_search/filter_menu.go +++ b/pkg/integration/tests/filter_and_search/filter_menu.go @@ -48,7 +48,7 @@ var FilterMenu = NewIntegrationTest(NewIntegrationTestArgs{ Tap(func() { t.ExpectPopup().Menu(). Title(Equals("Keybindings")). - LineCount(GreaterThan(1)) + LineCount(GreaterThan(2)) }) }, }) From d52d5e6a9e0c05dd998cf451adfddf660e0ecc4a Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 5 Aug 2025 15:15:14 +0200 Subject: [PATCH 2/4] Allow filtered lists to preprocess the filter string An example for this can be seen in the next commit. We make this a setter rather than an added constructor argument so that we don't have to change all those contexts that don't want to make use of this. --- pkg/gui/context/filtered_list.go | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/pkg/gui/context/filtered_list.go b/pkg/gui/context/filtered_list.go index 8164cfa16..e8112f95b 100644 --- a/pkg/gui/context/filtered_list.go +++ b/pkg/gui/context/filtered_list.go @@ -12,9 +12,10 @@ import ( type FilteredList[T any] struct { filteredIndices []int // if nil, we are not filtering - getList func() []T - getFilterFields func(T) []string - filter string + getList func() []T + getFilterFields func(T) []string + preprocessFilter func(string) string + filter string mutex deadlock.Mutex } @@ -26,6 +27,10 @@ func NewFilteredList[T any](getList func() []T, getFilterFields func(T) []string } } +func (self *FilteredList[T]) SetPreprocessFilterFunc(preprocessFilter func(string) string) { + self.preprocessFilter = preprocessFilter +} + func (self *FilteredList[T]) GetFilter() string { return self.filter } @@ -79,7 +84,12 @@ func (self *FilteredList[T]) applyFilter(useFuzzySearch bool) { self.mutex.Lock() defer self.mutex.Unlock() - if self.filter == "" { + filter := self.filter + if self.preprocessFilter != nil { + filter = self.preprocessFilter(filter) + } + + if filter == "" { self.filteredIndices = nil } else { source := &fuzzySource[T]{ @@ -87,7 +97,7 @@ func (self *FilteredList[T]) applyFilter(useFuzzySearch bool) { getFilterFields: self.getFilterFields, } - matches := utils.FindFrom(self.filter, source, useFuzzySearch) + matches := utils.FindFrom(filter, source, useFuzzySearch) self.filteredIndices = lo.Map(matches, func(match fuzzy.Match, _ int) int { return match.Index }) From 2ed11336b56586f645804f912059f827891d19bf Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 5 Aug 2025 15:16:17 +0200 Subject: [PATCH 3/4] Allow filtering for keybindings by prepending filter string with '@' --- pkg/gui/context/menu_context.go | 36 +++++++++++++++--- pkg/gui/controllers/options_menu_action.go | 9 +++-- pkg/gui/menu_panel.go | 1 + pkg/gui/types/common.go | 11 +++--- .../filter_menu_by_keybinding.go | 38 +++++++++++++++++++ pkg/integration/tests/test_list.go | 1 + 6 files changed, 81 insertions(+), 15 deletions(-) create mode 100644 pkg/integration/tests/filter_and_search/filter_menu_by_keybinding.go diff --git a/pkg/gui/context/menu_context.go b/pkg/gui/context/menu_context.go index 3d5937a9d..d3f1b9cc4 100644 --- a/pkg/gui/context/menu_context.go +++ b/pkg/gui/context/menu_context.go @@ -2,6 +2,7 @@ package context import ( "errors" + "strings" "github.com/jesseduffield/lazygit/pkg/gui/keybindings" "github.com/jesseduffield/lazygit/pkg/gui/style" @@ -48,11 +49,12 @@ func NewMenuContext( } type MenuViewModel struct { - c *ContextCommon - menuItems []*types.MenuItem - prompt string - promptLines []string - columnAlignment []utils.Alignment + c *ContextCommon + menuItems []*types.MenuItem + prompt string + promptLines []string + columnAlignment []utils.Alignment + allowFilteringKeybindings bool *FilteredListViewModel[*types.MenuItem] } @@ -62,11 +64,29 @@ func NewMenuViewModel(c *ContextCommon) *MenuViewModel { c: c, } + filterKeybindings := false + self.FilteredListViewModel = NewFilteredListViewModel( func() []*types.MenuItem { return self.menuItems }, - func(item *types.MenuItem) []string { return item.LabelColumns }, + func(item *types.MenuItem) []string { + if filterKeybindings { + return []string{keybindings.LabelFromKey(item.Key)} + } + + return item.LabelColumns + }, ) + self.FilteredListViewModel.SetPreprocessFilterFunc(func(filter string) string { + if self.allowFilteringKeybindings && strings.HasPrefix(filter, "@") { + filterKeybindings = true + return filter[1:] + } + + filterKeybindings = false + return filter + }) + return self } @@ -92,6 +112,10 @@ func (self *MenuViewModel) SetPromptLines(promptLines []string) { self.promptLines = promptLines } +func (self *MenuViewModel) SetAllowFilteringKeybindings(allow bool) { + self.allowFilteringKeybindings = allow +} + // TODO: move into presentation package func (self *MenuViewModel) GetDisplayStrings(_ int, _ int) [][]string { menuItems := self.FilteredListViewModel.GetItems() diff --git a/pkg/gui/controllers/options_menu_action.go b/pkg/gui/controllers/options_menu_action.go index f295a19f4..01d4f1bb6 100644 --- a/pkg/gui/controllers/options_menu_action.go +++ b/pkg/gui/controllers/options_menu_action.go @@ -46,10 +46,11 @@ func (self *OptionsMenuAction) Call() error { appendBindings(navigation, &types.MenuSection{Title: self.c.Tr.KeybindingsMenuSectionNavigation, Column: 1}) return self.c.Menu(types.CreateMenuOptions{ - Title: self.c.Tr.Keybindings, - Items: menuItems, - HideCancel: true, - ColumnAlignment: []utils.Alignment{utils.AlignRight, utils.AlignLeft}, + Title: self.c.Tr.Keybindings, + Items: menuItems, + HideCancel: true, + ColumnAlignment: []utils.Alignment{utils.AlignRight, utils.AlignLeft}, + AllowFilteringKeybindings: true, }) } diff --git a/pkg/gui/menu_panel.go b/pkg/gui/menu_panel.go index 75fd6700d..b0e69cfe0 100644 --- a/pkg/gui/menu_panel.go +++ b/pkg/gui/menu_panel.go @@ -43,6 +43,7 @@ func (gui *Gui) createMenu(opts types.CreateMenuOptions) error { gui.State.Contexts.Menu.SetMenuItems(opts.Items, opts.ColumnAlignment) gui.State.Contexts.Menu.SetPrompt(opts.Prompt) + gui.State.Contexts.Menu.SetAllowFilteringKeybindings(opts.AllowFilteringKeybindings) gui.State.Contexts.Menu.SetSelection(0) gui.Views.Menu.Title = opts.Title diff --git a/pkg/gui/types/common.go b/pkg/gui/types/common.go index 7d8528ee8..be8c10a15 100644 --- a/pkg/gui/types/common.go +++ b/pkg/gui/types/common.go @@ -147,11 +147,12 @@ const ( ) type CreateMenuOptions struct { - Title string - Prompt string // a message that will be displayed above the menu options - Items []*MenuItem - HideCancel bool - ColumnAlignment []utils.Alignment + Title string + Prompt string // a message that will be displayed above the menu options + Items []*MenuItem + HideCancel bool + ColumnAlignment []utils.Alignment + AllowFilteringKeybindings bool } type CreatePopupPanelOpts struct { diff --git a/pkg/integration/tests/filter_and_search/filter_menu_by_keybinding.go b/pkg/integration/tests/filter_and_search/filter_menu_by_keybinding.go new file mode 100644 index 000000000..aee4b907a --- /dev/null +++ b/pkg/integration/tests/filter_and_search/filter_menu_by_keybinding.go @@ -0,0 +1,38 @@ +package filter_and_search + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var FilterMenuByKeybinding = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Filtering the keybindings menu by keybinding", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + Press(keys.Universal.OptionMenu). + Tap(func() { + t.ExpectPopup().Menu(). + Title(Equals("Keybindings")). + Filter("@+"). + Lines( + // menu has filtered down to the one item that matches the filter + Contains("--- Global ---"), + Contains("+ Next screen mode").IsSelected(), + ). + Confirm() + }). + + // Upon opening the menu again, the filter should have been reset + Press(keys.Universal.OptionMenu). + Tap(func() { + t.ExpectPopup().Menu(). + Title(Equals("Keybindings")). + LineCount(GreaterThan(1)) + }) + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 3e94d39ea..000b21339 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -220,6 +220,7 @@ var tests = []*components.IntegrationTest{ filter_and_search.FilterFiles, filter_and_search.FilterFuzzy, filter_and_search.FilterMenu, + filter_and_search.FilterMenuByKeybinding, filter_and_search.FilterMenuCancelFilterWithEscape, filter_and_search.FilterMenuWithNoKeybindings, filter_and_search.FilterRemoteBranches, From a08799ac152de70dfb5820f79da9c1c1ee192cdd Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 5 Aug 2025 15:17:08 +0200 Subject: [PATCH 4/4] Allow filterable contexts to customize the filter label --- pkg/gui/context/filtered_list_view_model.go | 7 +++++++ pkg/gui/context/menu_context.go | 9 +++++++++ pkg/gui/controllers/helpers/search_helper.go | 4 ++-- pkg/gui/controllers/helpers/window_arrangement_helper.go | 6 +++--- pkg/gui/types/context.go | 2 ++ pkg/i18n/english.go | 2 ++ 6 files changed, 25 insertions(+), 5 deletions(-) diff --git a/pkg/gui/context/filtered_list_view_model.go b/pkg/gui/context/filtered_list_view_model.go index 2c2841964..ce2f8ac36 100644 --- a/pkg/gui/context/filtered_list_view_model.go +++ b/pkg/gui/context/filtered_list_view_model.go @@ -1,5 +1,7 @@ package context +import "github.com/jesseduffield/lazygit/pkg/i18n" + type FilteredListViewModel[T HasID] struct { *FilteredList[T] *ListViewModel[T] @@ -33,3 +35,8 @@ func (self *FilteredListViewModel[T]) ClearFilter() { self.SetSelection(unfilteredIndex) } + +// Default implementation of most filterable contexts. Can be overridden if needed. +func (self *FilteredListViewModel[T]) FilterPrefix(tr *i18n.TranslationSet) string { + return tr.FilterPrefix +} diff --git a/pkg/gui/context/menu_context.go b/pkg/gui/context/menu_context.go index d3f1b9cc4..0d59f1fa9 100644 --- a/pkg/gui/context/menu_context.go +++ b/pkg/gui/context/menu_context.go @@ -7,6 +7,7 @@ import ( "github.com/jesseduffield/lazygit/pkg/gui/keybindings" "github.com/jesseduffield/lazygit/pkg/gui/style" "github.com/jesseduffield/lazygit/pkg/gui/types" + "github.com/jesseduffield/lazygit/pkg/i18n" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/samber/lo" ) @@ -238,3 +239,11 @@ func (self *MenuContext) OnMenuPress(selectedItem *types.MenuItem) error { func (self *MenuContext) RangeSelectEnabled() bool { return false } + +func (self *MenuContext) FilterPrefix(tr *i18n.TranslationSet) string { + if self.allowFilteringKeybindings { + return tr.FilterPrefixMenu + } + + return self.FilteredListViewModel.FilterPrefix(tr) +} diff --git a/pkg/gui/controllers/helpers/search_helper.go b/pkg/gui/controllers/helpers/search_helper.go index cc9f22b22..67af0695b 100644 --- a/pkg/gui/controllers/helpers/search_helper.go +++ b/pkg/gui/controllers/helpers/search_helper.go @@ -35,7 +35,7 @@ func (self *SearchHelper) OpenFilterPrompt(context types.IFilterableContext) err state.Context = context - self.searchPrefixView().SetContent(self.c.Tr.FilterPrefix) + self.searchPrefixView().SetContent(context.FilterPrefix(self.c.Tr)) promptView := self.promptView() promptView.ClearTextArea() self.OnPromptContentChanged("") @@ -69,7 +69,7 @@ func (self *SearchHelper) DisplayFilterStatus(context types.IFilterableContext) state.Context = context searchString := context.GetFilter() - self.searchPrefixView().SetContent(self.c.Tr.FilterPrefix) + self.searchPrefixView().SetContent(context.FilterPrefix(self.c.Tr)) promptView := self.promptView() keybindingConfig := self.c.UserConfig().Keybinding diff --git a/pkg/gui/controllers/helpers/window_arrangement_helper.go b/pkg/gui/controllers/helpers/window_arrangement_helper.go index 51c5cf37a..accd61adc 100644 --- a/pkg/gui/controllers/helpers/window_arrangement_helper.go +++ b/pkg/gui/controllers/helpers/window_arrangement_helper.go @@ -79,10 +79,10 @@ func (self *WindowArrangementHelper) GetWindowDimensions(informationStr string, repoState := self.c.State().GetRepoState() var searchPrefix string - if repoState.GetSearchState().SearchType() == types.SearchTypeSearch { - searchPrefix = self.c.Tr.SearchPrefix + if filterableContext, ok := repoState.GetSearchState().Context.(types.IFilterableContext); ok { + searchPrefix = filterableContext.FilterPrefix(self.c.Tr) } else { - searchPrefix = self.c.Tr.FilterPrefix + searchPrefix = self.c.Tr.SearchPrefix } args := WindowArrangementArgs{ diff --git a/pkg/gui/types/context.go b/pkg/gui/types/context.go index 6d5f5d573..917342776 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/i18n" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/sasha-s/go-deadlock" ) @@ -130,6 +131,7 @@ type IFilterableContext interface { ReApplyFilter(bool) IsFiltering() bool IsFilterableContext() + FilterPrefix(tr *i18n.TranslationSet) string } type ISearchableContext interface { diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index 3a8823b8b..39227d25d 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -816,6 +816,7 @@ type TranslationSet struct { SearchKeybindings string SearchPrefix string FilterPrefix string + FilterPrefixMenu string ExitSearchMode string ExitTextFilterMode string Switch string @@ -1881,6 +1882,7 @@ func EnglishTranslationSet() *TranslationSet { SearchKeybindings: "%s: Next match, %s: Previous match, %s: Exit search mode", SearchPrefix: "Search: ", FilterPrefix: "Filter: ", + FilterPrefixMenu: "Filter (prepend '@' to filter keybindings): ", WorktreesTitle: "Worktrees", WorktreeTitle: "Worktree", Switch: "Switch",