1
0
mirror of https://github.com/jesseduffield/lazygit.git synced 2025-04-27 12:32:37 +02:00

Use substring filtering instead of fuzzy filtering by default (#3376)

By default we now search for substrings; you can search for multiple
substrings by separating them with spaces. Add a config option
`gui.filterMode` that can be set to 'fuzzy' to switch back to the
previous behavior.

Addresses #3373.
This commit is contained in:
Stefan Haller 2024-03-17 12:28:13 +01:00 committed by GitHub
commit 435a835c51
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 177 additions and 84 deletions

View File

@ -86,6 +86,7 @@ gui:
border: 'rounded' # one of 'single' | 'double' | 'rounded' | 'hidden'
animateExplosion: true # shows an explosion animation when nuking the working tree
portraitMode: 'auto' # one of 'auto' | 'never' | 'always'
filterMode: 'substring' # one of 'substring' | 'fuzzy'; see 'Filtering' section below
git:
paging:
colorArg: always
@ -374,6 +375,12 @@ That's the behavior when `gui.scrollOffBehavior` is set to "margin" (the default
This setting applies both to all list views (e.g. commits and branches etc), and to the staging view.
## Filtering
We have two ways to filter things, substring matching (the default) and fuzzy searching. With substring matching, the text you enter gets searched for verbatim (usually case-insensitive, except when your filter string contains uppercase letters, in which case we search case-sensitively). You can search for multiple non-contiguous substrings by separating them with spaces; for example, "int test" will match "integration-testing". All substrings have to match, but not necessarily in the given order.
Fuzzy searching is smarter in that it allows every letter of the filter string to match anywhere in the text (only in order though), assigning a weight to the quality of the match and sorting by that order. This has the advantage that it allows typing "clt" to match "commit_loader_test" (letters at the beginning of subwords get more weight); but it has the disadvantage that it tends to return lots of irrelevant results, especially with short filter strings.
## Color Attributes
For color attributes you can choose an array of attributes (with max one color attribute)

View File

@ -142,6 +142,13 @@ type GuiConfig struct {
// Whether to stack UI components on top of each other.
// 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"`
}
func (c *GuiConfig) UseFuzzySearch() bool {
return c.FilterMode == "fuzzy"
}
type ThemeConfig struct {
@ -660,6 +667,7 @@ func GetDefaultConfig() *UserConfig {
Border: "rounded",
AnimateExplosion: true,
PortraitMode: "auto",
FilterMode: "substring",
},
Git: GitConfig{
Paging: PagingConfig{

View File

@ -22,7 +22,6 @@ func NewBranchesContext(c *ContextCommon) *BranchesContext {
func(branch *models.Branch) []string {
return []string{branch.Name}
},
func() bool { return c.AppState.LocalBranchSortOrder != "alphabetical" },
)
getDisplayStrings := func(_ int, _ int) [][]string {

View File

@ -1,7 +1,6 @@
package context
import (
"slices"
"strings"
"github.com/jesseduffield/lazygit/pkg/utils"
@ -17,21 +16,14 @@ type FilteredList[T any] struct {
getFilterFields func(T) []string
filter string
// Normally, filtered items are presented sorted by best match. If this
// function returns true, they retain their original sort order instead;
// this is useful for lists that show items sorted by date, for example.
// Leaving this nil is equivalent to returning false.
shouldRetainSortOrder func() bool
mutex *deadlock.Mutex
}
func NewFilteredList[T any](getList func() []T, getFilterFields func(T) []string, shouldRetainSortOrder func() bool) *FilteredList[T] {
func NewFilteredList[T any](getList func() []T, getFilterFields func(T) []string) *FilteredList[T] {
return &FilteredList[T]{
getList: getList,
getFilterFields: getFilterFields,
shouldRetainSortOrder: shouldRetainSortOrder,
mutex: &deadlock.Mutex{},
getList: getList,
getFilterFields: getFilterFields,
mutex: &deadlock.Mutex{},
}
}
@ -39,18 +31,18 @@ func (self *FilteredList[T]) GetFilter() string {
return self.filter
}
func (self *FilteredList[T]) SetFilter(filter string) {
func (self *FilteredList[T]) SetFilter(filter string, useFuzzySearch bool) {
self.filter = filter
self.applyFilter()
self.applyFilter(useFuzzySearch)
}
func (self *FilteredList[T]) ClearFilter() {
self.SetFilter("")
self.SetFilter("", false)
}
func (self *FilteredList[T]) ReApplyFilter() {
self.applyFilter()
func (self *FilteredList[T]) ReApplyFilter(useFuzzySearch bool) {
self.applyFilter(useFuzzySearch)
}
func (self *FilteredList[T]) IsFiltering() bool {
@ -84,7 +76,7 @@ func (self *fuzzySource[T]) Len() int {
return len(self.list)
}
func (self *FilteredList[T]) applyFilter() {
func (self *FilteredList[T]) applyFilter(useFuzzySearch bool) {
self.mutex.Lock()
defer self.mutex.Unlock()
@ -96,13 +88,10 @@ func (self *FilteredList[T]) applyFilter() {
getFilterFields: self.getFilterFields,
}
matches := fuzzy.FindFrom(self.filter, source)
matches := utils.FindFrom(self.filter, source, useFuzzySearch)
self.filteredIndices = lo.Map(matches, func(match fuzzy.Match, _ int) int {
return match.Index
})
if self.shouldRetainSortOrder != nil && self.shouldRetainSortOrder() {
slices.Sort(self.filteredIndices)
}
}
}

View File

@ -6,8 +6,8 @@ type FilteredListViewModel[T HasID] struct {
*SearchHistory
}
func NewFilteredListViewModel[T HasID](getList func() []T, getFilterFields func(T) []string, shouldRetainSortOrder func() bool) *FilteredListViewModel[T] {
filteredList := NewFilteredList(getList, getFilterFields, shouldRetainSortOrder)
func NewFilteredListViewModel[T HasID](getList func() []T, getFilterFields func(T) []string) *FilteredListViewModel[T] {
filteredList := NewFilteredList(getList, getFilterFields)
self := &FilteredListViewModel[T]{
FilteredList: filteredList,

View File

@ -61,10 +61,6 @@ func NewMenuViewModel(c *ContextCommon) *MenuViewModel {
self.FilteredListViewModel = NewFilteredListViewModel(
func() []*types.MenuItem { return self.menuItems },
func(item *types.MenuItem) []string { return item.LabelColumns },
// The only menu that the user is likely to filter in is the keybindings
// menu; retain the sort order in that one because this allows us to
// keep the section headers while filtering:
func() bool { return true },
)
return self
@ -99,6 +95,13 @@ func (self *MenuViewModel) GetDisplayStrings(_ int, _ int) [][]string {
}
func (self *MenuViewModel) GetNonModelItems() []*NonModelItem {
// Don't display section headers when we are filtering, and the filter mode
// is fuzzy. The reason is that filtering changes the order of the items
// (they are sorted by best match), so all the sections would be messed up.
if self.FilteredListViewModel.IsFiltering() && self.c.UserConfig.Gui.UseFuzzySearch() {
return []*NonModelItem{}
}
result := []*NonModelItem{}
menuItems := self.FilteredListViewModel.GetItems()
var prevSection *types.MenuSection = nil

View File

@ -24,7 +24,6 @@ func NewReflogCommitsContext(c *ContextCommon) *ReflogCommitsContext {
func(commit *models.Commit) []string {
return []string{commit.ShortSha(), commit.Name}
},
func() bool { return true },
)
getDisplayStrings := func(_ int, _ int) [][]string {

View File

@ -26,7 +26,6 @@ func NewRemoteBranchesContext(
func(remoteBranch *models.RemoteBranch) []string {
return []string{remoteBranch.Name}
},
func() bool { return c.AppState.RemoteBranchSortOrder != "alphabetical" },
)
getDisplayStrings := func(_ int, _ int) [][]string {

View File

@ -22,7 +22,6 @@ func NewRemotesContext(c *ContextCommon) *RemotesContext {
func(remote *models.Remote) []string {
return []string{remote.Name}
},
nil,
)
getDisplayStrings := func(_ int, _ int) [][]string {

View File

@ -24,7 +24,6 @@ func NewStashContext(
func(stashEntry *models.StashEntry) []string {
return []string{stashEntry.Name}
},
func() bool { return true },
)
getDisplayStrings := func(_ int, _ int) [][]string {

View File

@ -19,7 +19,6 @@ func NewSubmodulesContext(c *ContextCommon) *SubmodulesContext {
func(submodule *models.SubmoduleConfig) []string {
return []string{submodule.FullName()}
},
nil,
)
getDisplayStrings := func(_ int, _ int) [][]string {

View File

@ -24,7 +24,6 @@ func NewTagsContext(
func(tag *models.Tag) []string {
return []string{tag.Name, tag.Message}
},
nil,
)
getDisplayStrings := func(_ int, _ int) [][]string {

View File

@ -19,7 +19,6 @@ func NewWorktreesContext(c *ContextCommon) *WorktreesContext {
func(Worktree *models.Worktree) []string {
return []string{Worktree.Name}
},
nil,
)
getDisplayStrings := func(_ int, _ int) [][]string {

View File

@ -38,7 +38,7 @@ func (self *CustomCommandAction) Call() error {
func (self *CustomCommandAction) GetCustomCommandsHistorySuggestionsFunc() func(string) []*types.Suggestion {
history := self.c.GetAppState().CustomCommandsHistory
return helpers.FuzzySearchFunc(history)
return helpers.FilterFunc(history, self.c.UserConfig.Gui.UseFuzzySearch())
}
// this mimics the shell functionality `ignorespace`

View File

@ -218,7 +218,7 @@ func (self *SearchHelper) OnPromptContentChanged(searchString string) {
case types.IFilterableContext:
context.SetSelection(0)
_ = context.GetView().SetOriginY(0)
context.SetFilter(searchString)
context.SetFilter(searchString, self.c.UserConfig.Gui.UseFuzzySearch())
_ = self.c.PostRefreshUpdate(context)
case types.ISearchableContext:
// do nothing
@ -234,7 +234,7 @@ func (self *SearchHelper) ReApplyFilter(context types.Context) {
if ok {
filterableContext.SetSelection(0)
_ = filterableContext.GetView().SetOriginY(0)
filterableContext.ReApplyFilter()
filterableContext.ReApplyFilter(self.c.UserConfig.Gui.UseFuzzySearch())
}
}
}

View File

@ -3,6 +3,7 @@ package helpers
import (
"fmt"
"os"
"strings"
"github.com/jesseduffield/gocui"
"github.com/jesseduffield/lazygit/pkg/commands/models"
@ -65,7 +66,7 @@ func matchesToSuggestions(matches []string) []*types.Suggestion {
func (self *SuggestionsHelper) GetRemoteSuggestionsFunc() func(string) []*types.Suggestion {
remoteNames := self.getRemoteNames()
return FuzzySearchFunc(remoteNames)
return FilterFunc(remoteNames, self.c.UserConfig.Gui.UseFuzzySearch())
}
func (self *SuggestionsHelper) getBranchNames() []string {
@ -82,7 +83,7 @@ func (self *SuggestionsHelper) GetBranchNameSuggestionsFunc() func(string) []*ty
if input == "" {
matchingBranchNames = branchNames
} else {
matchingBranchNames = utils.FuzzySearch(input, branchNames)
matchingBranchNames = utils.FilterStrings(input, branchNames, self.c.UserConfig.Gui.UseFuzzySearch())
}
return lo.Map(matchingBranchNames, func(branchName string, _ int) *types.Suggestion {
@ -128,13 +129,26 @@ func (self *SuggestionsHelper) GetFilePathSuggestionsFunc() func(string) []*type
return func(input string) []*types.Suggestion {
matchingNames := []string{}
_ = self.c.Model().FilesTrie.VisitFuzzy(patricia.Prefix(input), true, func(prefix patricia.Prefix, item patricia.Item, skipped int) error {
matchingNames = append(matchingNames, item.(string))
return nil
})
if self.c.UserConfig.Gui.UseFuzzySearch() {
_ = self.c.Model().FilesTrie.VisitFuzzy(patricia.Prefix(input), true, func(prefix patricia.Prefix, item patricia.Item, skipped int) error {
matchingNames = append(matchingNames, item.(string))
return nil
})
// doing another fuzzy search for good measure
matchingNames = utils.FuzzySearch(input, matchingNames)
// doing another fuzzy search for good measure
matchingNames = utils.FilterStrings(input, matchingNames, true)
} else {
substrings := strings.Fields(input)
_ = self.c.Model().FilesTrie.Visit(func(prefix patricia.Prefix, item patricia.Item) error {
for _, sub := range substrings {
if !utils.CaseAwareContains(item.(string), sub) {
return nil
}
}
matchingNames = append(matchingNames, item.(string))
return nil
})
}
return matchesToSuggestions(matchingNames)
}
@ -149,7 +163,7 @@ func (self *SuggestionsHelper) getRemoteBranchNames(separator string) []string {
}
func (self *SuggestionsHelper) GetRemoteBranchesSuggestionsFunc(separator string) func(string) []*types.Suggestion {
return FuzzySearchFunc(self.getRemoteBranchNames(separator))
return FilterFunc(self.getRemoteBranchNames(separator), self.c.UserConfig.Gui.UseFuzzySearch())
}
func (self *SuggestionsHelper) getTagNames() []string {
@ -161,7 +175,7 @@ func (self *SuggestionsHelper) getTagNames() []string {
func (self *SuggestionsHelper) GetTagsSuggestionsFunc() func(string) []*types.Suggestion {
tagNames := self.getTagNames()
return FuzzySearchFunc(tagNames)
return FilterFunc(tagNames, self.c.UserConfig.Gui.UseFuzzySearch())
}
func (self *SuggestionsHelper) GetRefsSuggestionsFunc() func(string) []*types.Suggestion {
@ -172,7 +186,7 @@ func (self *SuggestionsHelper) GetRefsSuggestionsFunc() func(string) []*types.Su
refNames := append(append(append(remoteBranchNames, localBranchNames...), tagNames...), additionalRefNames...)
return FuzzySearchFunc(refNames)
return FilterFunc(refNames, self.c.UserConfig.Gui.UseFuzzySearch())
}
func (self *SuggestionsHelper) GetAuthorsSuggestionsFunc() func(string) []*types.Suggestion {
@ -182,16 +196,16 @@ func (self *SuggestionsHelper) GetAuthorsSuggestionsFunc() func(string) []*types
slices.Sort(authors)
return FuzzySearchFunc(authors)
return FilterFunc(authors, self.c.UserConfig.Gui.UseFuzzySearch())
}
func FuzzySearchFunc(options []string) func(string) []*types.Suggestion {
func FilterFunc(options []string, useFuzzySearch bool) func(string) []*types.Suggestion {
return func(input string) []*types.Suggestion {
var matches []string
if input == "" {
matches = options
} else {
matches = utils.FuzzySearch(input, options)
matches = utils.FilterStrings(input, options, useFuzzySearch)
}
return matchesToSuggestions(matches)

View File

@ -102,10 +102,10 @@ type IFilterableContext interface {
IListPanelState
ISearchHistoryContext
SetFilter(string)
SetFilter(string, bool)
GetFilter() string
ClearFilter()
ReApplyFilter()
ReApplyFilter(bool)
IsFiltering() bool
IsFilterableContext()
}

View File

@ -1906,6 +1906,10 @@ keybinding:
- Push/pull/fetch loading statuses are now shown against the branch rather than in a popup. This allows you to e.g. fetch multiple branches in parallel and see the status for each branch.
- The git log graph in the commits view is now always shown by default (previously it was only shown when the view was maximised). If you find this too noisy, you can change it back via ctrl+L -> 'Show git graph' -> 'when maximised'
- Pressing space on a remote branch used to show a prompt for entering a name for a new local branch to check out from the remote branch. Now it just checks out the remote branch directly, letting you choose between a new local branch with the same name, or a detached head. The old behavior is still available via the 'n' keybinding.
- Filtering (e.g. when pressing '/') is less fuzzy by default; it only matches substrings now. Multiple substrings can be matched by separating them with spaces. If you want to revert to the old behavior, set the following in your config:
gui:
filterMode: 'fuzzy'
`,
},
}

View File

@ -9,7 +9,9 @@ var FilterFuzzy = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Verify that fuzzy filtering works (not just exact matches)",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {},
SetupConfig: func(config *config.AppConfig) {
config.UserConfig.Gui.FilterMode = "fuzzy"
},
SetupRepo: func(shell *Shell) {
shell.NewBranch("this-is-my-branch")
shell.EmptyCommit("first commit")

View File

@ -1,26 +1,73 @@
package utils
import (
"sort"
"strings"
"github.com/sahilm/fuzzy"
"github.com/samber/lo"
)
func FuzzySearch(needle string, haystack []string) []string {
func FilterStrings(needle string, haystack []string, useFuzzySearch bool) []string {
if needle == "" {
return []string{}
}
matches := fuzzy.Find(needle, haystack)
sort.Sort(matches)
matches := Find(needle, haystack, useFuzzySearch)
return lo.Map(matches, func(match fuzzy.Match, _ int) string {
return match.Str
})
}
// Duplicated from the fuzzy package because it's private there
type stringSource []string
func (ss stringSource) String(i int) string {
return ss[i]
}
func (ss stringSource) Len() int { return len(ss) }
// Drop-in replacement for fuzzy.Find (except that it doesn't fill out
// MatchedIndexes or Score, but we are not using these)
func FindSubstrings(pattern string, data []string) fuzzy.Matches {
return FindSubstringsFrom(pattern, stringSource(data))
}
// Drop-in replacement for fuzzy.FindFrom (except that it doesn't fill out
// MatchedIndexes or Score, but we are not using these)
func FindSubstringsFrom(pattern string, data fuzzy.Source) fuzzy.Matches {
substrings := strings.Fields(pattern)
result := fuzzy.Matches{}
outer:
for i := 0; i < data.Len(); i++ {
s := data.String(i)
for _, sub := range substrings {
if !CaseAwareContains(s, sub) {
continue outer
}
}
result = append(result, fuzzy.Match{Str: s, Index: i})
}
return result
}
func Find(pattern string, data []string, useFuzzySearch bool) fuzzy.Matches {
if useFuzzySearch {
return fuzzy.Find(pattern, data)
}
return FindSubstrings(pattern, data)
}
func FindFrom(pattern string, data fuzzy.Source, useFuzzySearch bool) fuzzy.Matches {
if useFuzzySearch {
return fuzzy.FindFrom(pattern, data)
}
return FindSubstringsFrom(pattern, data)
}
func CaseAwareContains(haystack, needle string) bool {
// if needle contains an uppercase letter, we'll do a case sensitive search
if ContainsUppercase(needle) {

View File

@ -7,49 +7,67 @@ import (
"github.com/stretchr/testify/assert"
)
// TestFuzzySearch is a function.
func TestFuzzySearch(t *testing.T) {
func TestFilterStrings(t *testing.T) {
type scenario struct {
needle string
haystack []string
expected []string
needle string
haystack []string
useFuzzySearch bool
expected []string
}
scenarios := []scenario{
{
needle: "",
haystack: []string{"test"},
expected: []string{},
needle: "",
haystack: []string{"test"},
useFuzzySearch: true,
expected: []string{},
},
{
needle: "test",
haystack: []string{"test"},
expected: []string{"test"},
needle: "test",
haystack: []string{"test"},
useFuzzySearch: true,
expected: []string{"test"},
},
{
needle: "o",
haystack: []string{"a", "o", "e"},
expected: []string{"o"},
needle: "o",
haystack: []string{"a", "o", "e"},
useFuzzySearch: true,
expected: []string{"o"},
},
{
needle: "mybranch",
haystack: []string{"my_branch", "mybranch", "branch", "this is my branch"},
expected: []string{"mybranch", "my_branch", "this is my branch"},
needle: "mybranch",
haystack: []string{"my_branch", "mybranch", "branch", "this is my branch"},
useFuzzySearch: true,
expected: []string{"mybranch", "my_branch", "this is my branch"},
},
{
needle: "test",
haystack: []string{"not a good match", "this 'test' is a good match", "test"},
expected: []string{"test", "this 'test' is a good match"},
needle: "test",
haystack: []string{"not a good match", "this 'test' is a good match", "test"},
useFuzzySearch: true,
expected: []string{"test", "this 'test' is a good match"},
},
{
needle: "test",
haystack: []string{"Test"},
expected: []string{"Test"},
needle: "test",
haystack: []string{"Test"},
useFuzzySearch: true,
expected: []string{"Test"},
},
{
needle: "test",
haystack: []string{"integration-testing", "t_e_s_t"},
useFuzzySearch: false,
expected: []string{"integration-testing"},
},
{
needle: "integr test",
haystack: []string{"integration-testing", "testing-integration"},
useFuzzySearch: false,
expected: []string{"integration-testing", "testing-integration"},
},
}
for _, s := range scenarios {
assert.EqualValues(t, s.expected, FuzzySearch(s.needle, s.haystack))
assert.EqualValues(t, s.expected, FilterStrings(s.needle, s.haystack, s.useFuzzySearch))
}
}

View File

@ -357,6 +357,15 @@
"type": "string",
"description": "Whether to stack UI components on top of each other.\nOne of 'auto' (default) | 'always' | 'never'",
"default": "auto"
},
"filterMode": {
"type": "string",
"enum": [
"substring",
"fuzzy"
],
"description": "How things are filtered when typing '/'.\nOne of 'substring' (default) | 'fuzzy'",
"default": "substring"
}
},
"additionalProperties": false,