diff --git a/docs-master/Config.md b/docs-master/Config.md index aa149e9e8ea..63e66d69255 100644 --- a/docs-master/Config.md +++ b/docs-master/Config.md @@ -292,9 +292,19 @@ gui: portraitMode: auto # How things are filtered when typing '/'. - # One of 'substring' (default) | 'fuzzy' + # One of 'substring' (default) | 'fuzzy' | 'regexp' + # In substring or fuzzy mode, prefix the filter with regexpFilterPrefix (default + # 're:') to use a Go regular + # expression for that filter only. + # In regexp mode, '.' and other metacharacters are regexp syntax (escape '.' to + # match a literal dot in paths). filterMode: substring + # When filterMode is substring or fuzzy, if the filter starts with this string, + # the rest is treated as a + # Go regexp. Default is 're:'. Leave empty in config to use the default. + regexpFilterPrefix: 're:' + # Config relating to the spinner. spinner: # The frames of the spinner animation. diff --git a/docs-master/Searching.md b/docs-master/Searching.md index 589831c55e3..5ee57705ac2 100644 --- a/docs-master/Searching.md +++ b/docs-master/Searching.md @@ -4,6 +4,15 @@ Depending on the currently focused view, hitting '/' will bring up a filter or search prompt. When filtering, the contents of the view will be filtered down to only those lines which match the query string. When searching, the contents of the view are not filtered, but matching lines are highlighted and you can iterate through matches with `n`/`N`. +### Regular expression filters + +You can filter with a [Go regular expression](https://go.dev/s/re2syntax) in two ways: + +- Set `gui.filterMode` to `regexp` in your config so every filter string is treated as a regexp. +- Or keep `substring` / `fuzzy` and prefix a single filter with `gui.regexpFilterPrefix` (default `re:`, for example `re:^main` to match branch names that start with `main`). You can change the prefix in config if `re:` collides with how you name branches or paths. + +If the pattern has no uppercase letters, matching is case-insensitive (the same rule as plain substring filters). Invalid regexps match nothing. In regexp mode, `.` and other metacharacters are active, so file paths like `foo.go` need `foo\.go` to match a literal dot. Unlike substring mode, regexp mode uses one pattern for the whole line (whitespace inside the pattern is not split into multiple AND terms). + We intend to support filtering for the files view soon, but at the moment it uses searching. We intend to continue using search for the commits view because you typically care about the commits that come before/after a matching commit. If you would like both filtering and searching to be enabled on a given view, please raise an issue for this. diff --git a/docs/Config.md b/docs/Config.md index aa149e9e8ea..57f72ef0ff8 100644 --- a/docs/Config.md +++ b/docs/Config.md @@ -292,9 +292,17 @@ gui: portraitMode: auto # How things are filtered when typing '/'. - # One of 'substring' (default) | 'fuzzy' + # One of 'substring' (default) | 'fuzzy' | 'regexp' + # In substring or fuzzy mode, prefix the filter with regexpFilterPrefix (default + # 're:') to use a Go regular expression for that filter only. In regexp mode, the + # whole filter is a regexp ('.' matches any character; escape as '\\.' in YAML for + # a literal dot). filterMode: substring + # When filterMode is substring or fuzzy, filters starting with this prefix use the + # remainder as a Go regexp. Default is 're:'. Omit or leave empty to use the default. + regexpFilterPrefix: 're:' + # Config relating to the spinner. spinner: # The frames of the spinner animation. diff --git a/docs/Searching.md b/docs/Searching.md index 589831c55e3..62bc2b5dbbd 100644 --- a/docs/Searching.md +++ b/docs/Searching.md @@ -4,7 +4,16 @@ Depending on the currently focused view, hitting '/' will bring up a filter or search prompt. When filtering, the contents of the view will be filtered down to only those lines which match the query string. When searching, the contents of the view are not filtered, but matching lines are highlighted and you can iterate through matches with `n`/`N`. -We intend to support filtering for the files view soon, but at the moment it uses searching. We intend to continue using search for the commits view because you typically care about the commits that come before/after a matching commit. +### Regular expression filters + +You can filter with a [Go regular expression](https://go.dev/s/re2syntax) in two ways: + +- Set `gui.filterMode` to `regexp` in your config so every filter string is treated as a regexp. +- Or keep `substring` / `fuzzy` and prefix a single filter with `gui.regexpFilterPrefix` (default `re:`, for example `re:^main` to match branch names that start with `main`). You can change the prefix in config if `re:` collides with how you name branches or paths. + +If the pattern has no uppercase letters, matching is case-insensitive (the same rule as plain substring filters). Invalid regexps match nothing. In regexp mode, `.` and other metacharacters are active, so file paths like `foo.go` need `foo\.go` to match a literal dot. Unlike substring mode, regexp mode uses one pattern for the whole line (whitespace inside the pattern is not split into multiple AND terms). + +We intend to continue using search for the commits view because you typically care about the commits that come before/after a matching commit. If you would like both filtering and searching to be enabled on a given view, please raise an issue for this. diff --git a/pkg/config/user_config.go b/pkg/config/user_config.go index 192d13843d7..0087dd9bb66 100644 --- a/pkg/config/user_config.go +++ b/pkg/config/user_config.go @@ -1,6 +1,7 @@ package config import ( + "strings" "time" "github.com/karimkhaleel/jsonschema" @@ -183,8 +184,14 @@ type GuiConfig struct { // One of 'auto' (default) | 'always' | 'never' PortraitMode string `yaml:"portraitMode"` // How things are filtered when typing '/'. - // One of 'substring' (default) | 'fuzzy' - FilterMode string `yaml:"filterMode" jsonschema:"enum=substring,enum=fuzzy"` + // One of 'substring' (default) | 'fuzzy' | 'regexp' + // In substring or fuzzy mode, prefix the filter with regexpFilterPrefix (default 're:') to use a Go regular + // expression for that filter only. + // In regexp mode, '.' and other metacharacters are regexp syntax (escape '.' to match a literal dot in paths). + FilterMode string `yaml:"filterMode" jsonschema:"enum=substring,enum=fuzzy,enum=regexp"` + // When filterMode is substring or fuzzy, if the filter starts with this string, the rest is treated as a + // Go regexp. Default is 're:'. Leave empty in config to use the default. + RegexpFilterPrefix string `yaml:"regexpFilterPrefix"` // Config relating to the spinner. Spinner SpinnerConfig `yaml:"spinner"` // Status panel view. @@ -202,6 +209,17 @@ func (c *GuiConfig) UseFuzzySearch() bool { return c.FilterMode == "fuzzy" } +// DefaultRegexpFilterPrefix is used when regexpFilterPrefix is unset or whitespace-only in config. +const DefaultRegexpFilterPrefix = "re:" + +// RegexpFilterPrefixOrDefault returns the configured one-off regexp filter prefix, or DefaultRegexpFilterPrefix. +func (c *GuiConfig) RegexpFilterPrefixOrDefault() string { + if strings.TrimSpace(c.RegexpFilterPrefix) == "" { + return DefaultRegexpFilterPrefix + } + return c.RegexpFilterPrefix +} + type ThemeConfig struct { // Border color of focused window ActiveBorderColor []string `yaml:"activeBorderColor" jsonschema:"minItems=1,uniqueItems=true"` @@ -814,6 +832,7 @@ func GetDefaultConfig() *UserConfig { AnimateExplosion: true, PortraitMode: "auto", FilterMode: "substring", + RegexpFilterPrefix: DefaultRegexpFilterPrefix, Spinner: SpinnerConfig{ Frames: []string{"|", "/", "-", "\\"}, Rate: 50, diff --git a/pkg/config/user_config_validation_test.go b/pkg/config/user_config_validation_test.go index 7fbfd00d42d..0771be15acb 100644 --- a/pkg/config/user_config_validation_test.go +++ b/pkg/config/user_config_validation_test.go @@ -289,3 +289,14 @@ func TestUserConfigValidate_enums(t *testing.T) { }) } } + +func TestRegexpFilterPrefixOrDefault(t *testing.T) { + c := GuiConfig{} + assert.Equal(t, DefaultRegexpFilterPrefix, c.RegexpFilterPrefixOrDefault()) + + c.RegexpFilterPrefix = " " + assert.Equal(t, DefaultRegexpFilterPrefix, c.RegexpFilterPrefixOrDefault()) + + c.RegexpFilterPrefix = "rx:" + assert.Equal(t, "rx:", c.RegexpFilterPrefixOrDefault()) +} diff --git a/pkg/gui/context/filtered_list.go b/pkg/gui/context/filtered_list.go index e8112f95b04..1d20018f8e8 100644 --- a/pkg/gui/context/filtered_list.go +++ b/pkg/gui/context/filtered_list.go @@ -16,6 +16,8 @@ type FilteredList[T any] struct { getFilterFields func(T) []string preprocessFilter func(string) string filter string + lastFilterMode string // last non-empty gui.filterMode passed to SetFilter; default substring when empty + lastRegexpPrefix string // last gui.regexpFilterPrefix (OrDefault) passed to SetFilter mutex deadlock.Mutex } @@ -35,18 +37,46 @@ func (self *FilteredList[T]) GetFilter() string { return self.filter } -func (self *FilteredList[T]) SetFilter(filter string, useFuzzySearch bool) { +func (self *FilteredList[T]) SetFilter(filter string, useFuzzySearch bool, filterMode string, regexpPrefix string) { self.filter = filter + if filterMode != "" { + self.lastFilterMode = filterMode + } + if regexpPrefix != "" { + self.lastRegexpPrefix = regexpPrefix + } - self.applyFilter(useFuzzySearch) + self.applyFilter(useFuzzySearch, filterMode, regexpPrefix) } func (self *FilteredList[T]) ClearFilter() { - self.SetFilter("", false) + mode := self.lastFilterMode + if mode == "" { + mode = "substring" + } + prefix := self.lastRegexpPrefix + if prefix == "" { + prefix = "re:" + } + self.SetFilter("", false, mode, prefix) } -func (self *FilteredList[T]) ReApplyFilter(useFuzzySearch bool) { - self.applyFilter(useFuzzySearch) +func (self *FilteredList[T]) ReApplyFilter(useFuzzySearch bool, filterMode string, regexpPrefix string) { + mode := filterMode + if mode == "" { + mode = self.lastFilterMode + } + if mode == "" { + mode = "substring" + } + prefix := regexpPrefix + if prefix == "" { + prefix = self.lastRegexpPrefix + } + if prefix == "" { + prefix = "re:" + } + self.applyFilter(useFuzzySearch, mode, prefix) } func (self *FilteredList[T]) IsFiltering() bool { @@ -80,16 +110,16 @@ func (self *fuzzySource[T]) Len() int { return len(self.list) } -func (self *FilteredList[T]) applyFilter(useFuzzySearch bool) { +func (self *FilteredList[T]) applyFilter(useFuzzySearch bool, filterMode string, regexpPrefix string) { self.mutex.Lock() defer self.mutex.Unlock() - filter := self.filter + processed := self.filter if self.preprocessFilter != nil { - filter = self.preprocessFilter(filter) + processed = self.preprocessFilter(self.filter) } - if filter == "" { + if processed == "" { self.filteredIndices = nil } else { source := &fuzzySource[T]{ @@ -97,7 +127,12 @@ func (self *FilteredList[T]) applyFilter(useFuzzySearch bool) { getFilterFields: self.getFilterFields, } - matches := utils.FindFrom(filter, source, useFuzzySearch) + prefix := regexpPrefix + if prefix == "" { + prefix = "re:" + } + pattern, useRegexp := utils.ViewFilterPattern(filterMode, processed, prefix) + matches := utils.FindFrom(pattern, source, useFuzzySearch, useRegexp) self.filteredIndices = lo.Map(matches, func(match fuzzy.Match, _ int) int { return match.Index }) diff --git a/pkg/gui/controllers/helpers/search_helper.go b/pkg/gui/controllers/helpers/search_helper.go index 9b3dcec64a6..b83e4336ec5 100644 --- a/pkg/gui/controllers/helpers/search_helper.go +++ b/pkg/gui/controllers/helpers/search_helper.go @@ -227,7 +227,8 @@ func (self *SearchHelper) OnPromptContentChanged(searchString string) { case types.IFilterableContext: context.SetSelection(0) context.GetView().SetOriginY(0) - context.SetFilter(searchString, self.c.UserConfig().Gui.UseFuzzySearch()) + gui := self.c.UserConfig().Gui + context.SetFilter(searchString, gui.UseFuzzySearch(), gui.FilterMode, gui.RegexpFilterPrefixOrDefault()) self.c.PostRefreshUpdate(context) case types.ISearchableContext: // do nothing @@ -244,7 +245,8 @@ func (self *SearchHelper) ReApplyFilter(context types.Context) { filterableContext.SetSelection(0) filterableContext.GetView().SetOriginY(0) } - filterableContext.ReApplyFilter(self.c.UserConfig().Gui.UseFuzzySearch()) + gui := self.c.UserConfig().Gui + filterableContext.ReApplyFilter(gui.UseFuzzySearch(), gui.FilterMode, gui.RegexpFilterPrefixOrDefault()) } } diff --git a/pkg/gui/filetree/commit_file_tree.go b/pkg/gui/filetree/commit_file_tree.go index 38232ea3c5e..2b2a5f1c82e 100644 --- a/pkg/gui/filetree/commit_file_tree.go +++ b/pkg/gui/filetree/commit_file_tree.go @@ -15,8 +15,10 @@ type ICommitFileTree interface { GetAllItems() []*CommitFileNode GetAllFiles() []*models.CommitFile GetRoot() *CommitFileNode - SetTextFilter(filter string, useFuzzySearch bool) + SetTextFilter(filter string, useFuzzySearch bool, filterMode string, regexpPrefix string) GetTextFilter() string + TextFilterMode() string + TextRegexpFilterPrefix() string } type CommitFileTree struct { @@ -25,8 +27,10 @@ type CommitFileTree struct { showTree bool common *common.Common collapsedPaths *CollapsedPaths - textFilter string - useFuzzySearch bool + textFilter string + useFuzzySearch bool + textFilterMode string + textRegexpPrefix string } func (self *CommitFileTree) CollapseAll() { @@ -100,7 +104,7 @@ func (self *CommitFileTree) GetAllFiles() []*models.CommitFile { func (self *CommitFileTree) getFilesForDisplay() []*models.CommitFile { files := self.getFiles() if self.textFilter != "" { - files = filterCommitFilesByText(files, self.textFilter, self.useFuzzySearch) + files = filterCommitFilesByText(files, self.textFilter, self.useFuzzySearch, self.textFilterMode, self.textRegexpPrefix) } return files } @@ -115,9 +119,11 @@ func (self *CommitFileTree) SetTree() { } } -func (self *CommitFileTree) SetTextFilter(filter string, useFuzzySearch bool) { +func (self *CommitFileTree) SetTextFilter(filter string, useFuzzySearch bool, filterMode string, regexpPrefix string) { self.textFilter = filter self.useFuzzySearch = useFuzzySearch + self.textFilterMode = filterMode + self.textRegexpPrefix = regexpPrefix self.SetTree() } @@ -125,6 +131,14 @@ func (self *CommitFileTree) GetTextFilter() string { return self.textFilter } +func (self *CommitFileTree) TextFilterMode() string { + return self.textFilterMode +} + +func (self *CommitFileTree) TextRegexpFilterPrefix() string { + return self.textRegexpPrefix +} + func (self *CommitFileTree) IsCollapsed(path string) bool { return self.collapsedPaths.IsCollapsed(path) } diff --git a/pkg/gui/filetree/commit_file_tree_view_model.go b/pkg/gui/filetree/commit_file_tree_view_model.go index c2e7e74e4bd..7ac3258176a 100644 --- a/pkg/gui/filetree/commit_file_tree_view_model.go +++ b/pkg/gui/filetree/commit_file_tree_view_model.go @@ -211,8 +211,8 @@ func (self *CommitFileTreeViewModel) SelectPath(filepath string, showRootItem bo // IFilterableContext methods -func (self *CommitFileTreeViewModel) SetFilter(filter string, useFuzzySearch bool) { - self.ICommitFileTree.SetTextFilter(filter, useFuzzySearch) +func (self *CommitFileTreeViewModel) SetFilter(filter string, useFuzzySearch bool, filterMode string, regexpPrefix string) { + self.ICommitFileTree.SetTextFilter(filter, useFuzzySearch, filterMode, regexpPrefix) } func (self *CommitFileTreeViewModel) GetFilter() string { @@ -226,7 +226,15 @@ func (self *CommitFileTreeViewModel) ClearFilter() { selectedPath = selectedNode.GetInternalPath() } - self.ICommitFileTree.SetTextFilter("", false) + mode := self.ICommitFileTree.TextFilterMode() + if mode == "" { + mode = "substring" + } + prefix := self.ICommitFileTree.TextRegexpFilterPrefix() + if prefix == "" { + prefix = "re:" + } + self.ICommitFileTree.SetTextFilter("", false, mode, prefix) if selectedPath != "" { self.ExpandToPath(selectedPath) @@ -238,8 +246,22 @@ func (self *CommitFileTreeViewModel) ClearFilter() { self.ClampSelection() } -func (self *CommitFileTreeViewModel) ReApplyFilter(useFuzzySearch bool) { - self.ICommitFileTree.SetTextFilter(self.ICommitFileTree.GetTextFilter(), useFuzzySearch) +func (self *CommitFileTreeViewModel) ReApplyFilter(useFuzzySearch bool, filterMode string, regexpPrefix string) { + mode := filterMode + if mode == "" { + mode = self.ICommitFileTree.TextFilterMode() + } + if mode == "" { + mode = "substring" + } + prefix := regexpPrefix + if prefix == "" { + prefix = self.ICommitFileTree.TextRegexpFilterPrefix() + } + if prefix == "" { + prefix = "re:" + } + self.ICommitFileTree.SetTextFilter(self.ICommitFileTree.GetTextFilter(), useFuzzySearch, mode, prefix) } func (self *CommitFileTreeViewModel) IsFiltering() bool { diff --git a/pkg/gui/filetree/file_filter.go b/pkg/gui/filetree/file_filter.go index cb4916cf7c3..4e98521fdfe 100644 --- a/pkg/gui/filetree/file_filter.go +++ b/pkg/gui/filetree/file_filter.go @@ -19,9 +19,10 @@ func (s *filePathSource) Len() int { return len(s.files) } -func filterFilesByText(files []*models.File, filter string, useFuzzySearch bool) []*models.File { +func filterFilesByText(files []*models.File, filter string, useFuzzySearch bool, filterMode string, regexpPrefix string) []*models.File { source := &filePathSource{files: files} - matches := utils.FindFrom(filter, source, useFuzzySearch) + pattern, useRegexp := utils.ViewFilterPattern(filterMode, filter, regexpPrefix) + matches := utils.FindFrom(pattern, source, useFuzzySearch, useRegexp) return lo.Map(matches, func(match fuzzy.Match, _ int) *models.File { return files[match.Index] }) @@ -39,9 +40,10 @@ func (s *commitFilePathSource) Len() int { return len(s.files) } -func filterCommitFilesByText(files []*models.CommitFile, filter string, useFuzzySearch bool) []*models.CommitFile { +func filterCommitFilesByText(files []*models.CommitFile, filter string, useFuzzySearch bool, filterMode string, regexpPrefix string) []*models.CommitFile { source := &commitFilePathSource{files: files} - matches := utils.FindFrom(filter, source, useFuzzySearch) + pattern, useRegexp := utils.ViewFilterPattern(filterMode, filter, regexpPrefix) + matches := utils.FindFrom(pattern, source, useFuzzySearch, useRegexp) return lo.Map(matches, func(match fuzzy.Match, _ int) *models.CommitFile { return files[match.Index] }) diff --git a/pkg/gui/filetree/file_tree.go b/pkg/gui/filetree/file_tree.go index a70876221ef..fc279a9ff21 100644 --- a/pkg/gui/filetree/file_tree.go +++ b/pkg/gui/filetree/file_tree.go @@ -49,8 +49,10 @@ type IFileTree interface { GetAllFiles() []*models.File GetStatusFilter() FileTreeDisplayFilter GetRoot() *FileNode - SetTextFilter(filter string, useFuzzySearch bool) + SetTextFilter(filter string, useFuzzySearch bool, filterMode string, regexpPrefix string) GetTextFilter() string + TextFilterMode() string + TextRegexpFilterPrefix() string } type FileTree struct { @@ -60,8 +62,10 @@ type FileTree struct { common *common.Common filter FileTreeDisplayFilter collapsedPaths *CollapsedPaths - textFilter string - useFuzzySearch bool + textFilter string + useFuzzySearch bool + textFilterMode string + textRegexpPrefix string } var _ IFileTree = &FileTree{} @@ -106,7 +110,7 @@ func (self *FileTree) getFilesForDisplay() []*models.File { } if self.textFilter != "" { - files = filterFilesByText(files, self.textFilter, self.useFuzzySearch) + files = filterFilesByText(files, self.textFilter, self.useFuzzySearch, self.textFilterMode, self.textRegexpPrefix) } return files @@ -230,12 +234,22 @@ func (self *FileTree) GetStatusFilter() FileTreeDisplayFilter { return self.filter } -func (self *FileTree) SetTextFilter(filter string, useFuzzySearch bool) { +func (self *FileTree) SetTextFilter(filter string, useFuzzySearch bool, filterMode string, regexpPrefix string) { self.textFilter = filter self.useFuzzySearch = useFuzzySearch + self.textFilterMode = filterMode + self.textRegexpPrefix = regexpPrefix self.SetTree() } func (self *FileTree) GetTextFilter() string { return self.textFilter } + +func (self *FileTree) TextFilterMode() string { + return self.textFilterMode +} + +func (self *FileTree) TextRegexpFilterPrefix() string { + return self.textRegexpPrefix +} diff --git a/pkg/gui/filetree/file_tree_view_model.go b/pkg/gui/filetree/file_tree_view_model.go index 741550c19a4..18f27d55658 100644 --- a/pkg/gui/filetree/file_tree_view_model.go +++ b/pkg/gui/filetree/file_tree_view_model.go @@ -226,8 +226,8 @@ func (self *FileTreeViewModel) ExpandAll() { // IFilterableContext methods -func (self *FileTreeViewModel) SetFilter(filter string, useFuzzySearch bool) { - self.IFileTree.SetTextFilter(filter, useFuzzySearch) +func (self *FileTreeViewModel) SetFilter(filter string, useFuzzySearch bool, filterMode string, regexpPrefix string) { + self.IFileTree.SetTextFilter(filter, useFuzzySearch, filterMode, regexpPrefix) } func (self *FileTreeViewModel) GetFilter() string { @@ -241,7 +241,15 @@ func (self *FileTreeViewModel) ClearFilter() { selectedPath = selectedNode.GetInternalPath() } - self.IFileTree.SetTextFilter("", false) + mode := self.IFileTree.TextFilterMode() + if mode == "" { + mode = "substring" + } + prefix := self.IFileTree.TextRegexpFilterPrefix() + if prefix == "" { + prefix = "re:" + } + self.IFileTree.SetTextFilter("", false, mode, prefix) if selectedPath != "" { self.ExpandToPath(selectedPath) @@ -253,8 +261,22 @@ func (self *FileTreeViewModel) ClearFilter() { self.ClampSelection() } -func (self *FileTreeViewModel) ReApplyFilter(useFuzzySearch bool) { - self.IFileTree.SetTextFilter(self.IFileTree.GetTextFilter(), useFuzzySearch) +func (self *FileTreeViewModel) ReApplyFilter(useFuzzySearch bool, filterMode string, regexpPrefix string) { + mode := filterMode + if mode == "" { + mode = self.IFileTree.TextFilterMode() + } + if mode == "" { + mode = "substring" + } + prefix := regexpPrefix + if prefix == "" { + prefix = self.IFileTree.TextRegexpFilterPrefix() + } + if prefix == "" { + prefix = "re:" + } + self.IFileTree.SetTextFilter(self.IFileTree.GetTextFilter(), useFuzzySearch, mode, prefix) } func (self *FileTreeViewModel) IsFiltering() bool { diff --git a/pkg/gui/types/context.go b/pkg/gui/types/context.go index 09ed94e5a07..568f2ce8add 100644 --- a/pkg/gui/types/context.go +++ b/pkg/gui/types/context.go @@ -128,10 +128,10 @@ type IFilterableContext interface { IListPanelState ISearchHistoryContext - SetFilter(string, bool) + SetFilter(filter string, useFuzzySearch bool, filterMode string, regexpPrefix string) GetFilter() string ClearFilter() - ReApplyFilter(bool) + ReApplyFilter(useFuzzySearch bool, filterMode string, regexpPrefix string) IsFiltering() bool IsFilterableContext() FilterPrefix(tr *i18n.TranslationSet) string diff --git a/pkg/integration/tests/filter_and_search/filter_branches_regexp.go b/pkg/integration/tests/filter_and_search/filter_branches_regexp.go new file mode 100644 index 00000000000..adf947176ce --- /dev/null +++ b/pkg/integration/tests/filter_and_search/filter_branches_regexp.go @@ -0,0 +1,37 @@ +package filter_and_search + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var FilterBranchesRegexp = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Filter local branches with re: prefix so ^ anchors to start of name", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.EmptyCommit("initial") + shell.NewBranch("main") + shell.EmptyCommit("on-main-branch") + shell.Checkout("master") + shell.NewBranch("amain") + shell.EmptyCommit("on-amain-branch") + shell.Checkout("master") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Branches(). + Focus(). + Content(Contains(`amain`)). + Content(Contains(`main`)). + Content(Contains(`master`)). + FilterOrSearch("re:^main"). + Lines( + Contains(`main`).IsSelected(), + ). + PressEscape(). + Content(Contains(`amain`)). + Content(Contains(`main`)). + Content(Contains(`master`)) + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 3d8edfb56d5..6c08356e26a 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -234,6 +234,7 @@ var tests = []*components.IntegrationTest{ file.StageChildrenRangeSelect, file.StageDeletedRangeSelect, file.StageRangeSelect, + filter_and_search.FilterBranchesRegexp, filter_and_search.FilterByFileStatus, filter_and_search.FilterCommitFiles, filter_and_search.FilterCommitFilesToggleDirectory, diff --git a/pkg/utils/search.go b/pkg/utils/search.go index c96cda4abbe..ca4f0057b47 100644 --- a/pkg/utils/search.go +++ b/pkg/utils/search.go @@ -1,18 +1,32 @@ package utils import ( + "regexp" "strings" "github.com/sahilm/fuzzy" "github.com/samber/lo" ) +// ViewFilterPattern returns the regexp/substring pattern to match and whether to use regexp matching, +// given the GUI filter mode and the filter text after any view-specific preprocessing (e.g. menu '@' handling). +// regexpPrefix is the configured one-off regexp marker (e.g. "re:"); if empty, only filterMode == "regexp" enables regexp. +func ViewFilterPattern(filterMode, afterPreprocess, regexpPrefix string) (pattern string, useRegexp bool) { + if regexpPrefix != "" && strings.HasPrefix(afterPreprocess, regexpPrefix) { + return afterPreprocess[len(regexpPrefix):], true + } + if filterMode == "regexp" { + return afterPreprocess, true + } + return afterPreprocess, false +} + func FilterStrings(needle string, haystack []string, useFuzzySearch bool) []string { if needle == "" { return []string{} } - matches := Find(needle, haystack, useFuzzySearch) + matches := Find(needle, haystack, useFuzzySearch, false) return lo.Map(matches, func(match fuzzy.Match, _ int) string { return match.Str @@ -54,14 +68,47 @@ outer: return result } -func Find(pattern string, data []string, useFuzzySearch bool) fuzzy.Matches { +// FindRegexpFrom matches each row from data against a Go regular expression. +// Case rules match CaseAwareContains: if pattern has an uppercase letter, matching is case-sensitive; otherwise (?i) is used. +// Invalid patterns yield no matches. +func FindRegexpFrom(pattern string, data fuzzy.Source) fuzzy.Matches { + if pattern == "" { + return nil + } + + expr := pattern + if !ContainsUppercase(pattern) { + expr = "(?i)" + pattern + } + re, err := regexp.Compile(expr) + if err != nil { + return fuzzy.Matches{} + } + + result := fuzzy.Matches{} + for i := range data.Len() { + s := data.String(i) + if re.MatchString(s) { + result = append(result, fuzzy.Match{Str: s, Index: i}) + } + } + return result +} + +func Find(pattern string, data []string, useFuzzySearch bool, useRegexp bool) fuzzy.Matches { + if useRegexp { + return FindRegexpFrom(pattern, stringSource(data)) + } if useFuzzySearch { return fuzzy.Find(pattern, data) } return FindSubstrings(pattern, data) } -func FindFrom(pattern string, data fuzzy.Source, useFuzzySearch bool) fuzzy.Matches { +func FindFrom(pattern string, data fuzzy.Source, useFuzzySearch bool, useRegexp bool) fuzzy.Matches { + if useRegexp { + return FindRegexpFrom(pattern, data) + } if useFuzzySearch { return fuzzy.FindFrom(pattern, data) } diff --git a/pkg/utils/search_test.go b/pkg/utils/search_test.go index ad5dd12253d..e9103cca83a 100644 --- a/pkg/utils/search_test.go +++ b/pkg/utils/search_test.go @@ -71,6 +71,64 @@ func TestFilterStrings(t *testing.T) { } } +func TestViewFilterPattern(t *testing.T) { + const def = "re:" + pat, re := ViewFilterPattern("substring", "re:^main", def) + assert.True(t, re) + assert.Equal(t, "^main", pat) + + pat, re = ViewFilterPattern("substring", "plain", def) + assert.False(t, re) + assert.Equal(t, "plain", pat) + + pat, re = ViewFilterPattern("regexp", "^main", def) + assert.True(t, re) + assert.Equal(t, "^main", pat) + + pat, re = ViewFilterPattern("fuzzy", "re:a.c", def) + assert.True(t, re) + assert.Equal(t, "a.c", pat) + + pat, re = ViewFilterPattern("substring", "rx:^x", "rx:") + assert.True(t, re) + assert.Equal(t, "^x", pat) + + pat, re = ViewFilterPattern("substring", "re:^main", "rx:") + assert.False(t, re) + assert.Equal(t, "re:^main", pat) +} + +func TestFindFromRegexp(t *testing.T) { + haystack := []string{"main", "amain", "xmain"} + src := stringSource(haystack) + + got := FindFrom("^main", src, false, true) + assert.Len(t, got, 1) + assert.Equal(t, 0, got[0].Index) + assert.Equal(t, "main", got[0].Str) + + // '.' is regexp metacharacter: 'foo.go' matches 'fooXgo'; literal dot needs '\.' + dotHay := stringSource([]string{"fooXgo", "foo.go"}) + got = FindFrom("foo.go", dotHay, false, true) + assert.Len(t, got, 2) + got = FindFrom(`foo\.go`, dotHay, false, true) + assert.Len(t, got, 1) + assert.Equal(t, "foo.go", got[0].Str) + + // invalid pattern => no matches + got = FindFrom("(", src, false, true) + assert.Empty(t, got) + + // case: lowercase pattern matches uppercase (implicit (?i)) + got = FindFrom("main", stringSource([]string{"Main"}), false, true) + assert.Len(t, got, 1) + + // uppercase in pattern => case-sensitive + got = FindFrom("Main", stringSource([]string{"main", "Main"}), false, true) + assert.Len(t, got, 1) + assert.Equal(t, "Main", got[0].Str) +} + func TestCaseInsensitiveContains(t *testing.T) { testCases := []struct { haystack string diff --git a/schema-master/config.json b/schema-master/config.json index 54f9fa9ec4d..52d88815fb0 100644 --- a/schema-master/config.json +++ b/schema-master/config.json @@ -758,11 +758,17 @@ "type": "string", "enum": [ "substring", - "fuzzy" + "fuzzy", + "regexp" ], - "description": "How things are filtered when typing '/'.\nOne of 'substring' (default) | 'fuzzy'", + "description": "How things are filtered when typing '/'.\nOne of 'substring' (default) | 'fuzzy' | 'regexp'\nIn substring or fuzzy mode, prefix the filter with regexpFilterPrefix (default 're:') to use a Go regular\nexpression for that filter only.\nIn regexp mode, '.' and other metacharacters are regexp syntax (escape '.' to match a literal dot in paths).", "default": "substring" }, + "regexpFilterPrefix": { + "type": "string", + "description": "When filterMode is substring or fuzzy, if the filter starts with this string, the rest is treated as a\nGo regexp. Default is 're:'. Leave empty in config to use the default.", + "default": "re:" + }, "spinner": { "$ref": "#/$defs/SpinnerConfig", "description": "Config relating to the spinner." diff --git a/schema/config.json b/schema/config.json index 54f9fa9ec4d..52d88815fb0 100644 --- a/schema/config.json +++ b/schema/config.json @@ -758,11 +758,17 @@ "type": "string", "enum": [ "substring", - "fuzzy" + "fuzzy", + "regexp" ], - "description": "How things are filtered when typing '/'.\nOne of 'substring' (default) | 'fuzzy'", + "description": "How things are filtered when typing '/'.\nOne of 'substring' (default) | 'fuzzy' | 'regexp'\nIn substring or fuzzy mode, prefix the filter with regexpFilterPrefix (default 're:') to use a Go regular\nexpression for that filter only.\nIn regexp mode, '.' and other metacharacters are regexp syntax (escape '.' to match a literal dot in paths).", "default": "substring" }, + "regexpFilterPrefix": { + "type": "string", + "description": "When filterMode is substring or fuzzy, if the filter starts with this string, the rest is treated as a\nGo regexp. Default is 're:'. Leave empty in config to use the default.", + "default": "re:" + }, "spinner": { "$ref": "#/$defs/SpinnerConfig", "description": "Config relating to the spinner."