diff --git a/pkg/gui/controllers/options_menu_action.go b/pkg/gui/controllers/options_menu_action.go index c92fdd589..be2899632 100644 --- a/pkg/gui/controllers/options_menu_action.go +++ b/pkg/gui/controllers/options_menu_action.go @@ -64,6 +64,7 @@ func (self *OptionsMenuAction) Call() error { ColumnAlignment: []utils.Alignment{utils.AlignRight, utils.AlignLeft}, AllowFilteringKeybindings: true, KeepConflictingKeybindings: true, + FilterAsYouType: true, }) } diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index 896c40670..9c72b53df 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -2040,7 +2040,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): ", + FilterPrefixMenu: "Filter ('@' for keybindings): ", MenuFilterHint: "(Type to filter)", WorktreesTitle: "Worktrees", WorktreeTitle: "Worktree", diff --git a/pkg/integration/components/menu_driver.go b/pkg/integration/components/menu_driver.go index 95f29dcd3..e0d6133e6 100644 --- a/pkg/integration/components/menu_driver.go +++ b/pkg/integration/components/menu_driver.go @@ -56,8 +56,12 @@ func (self *MenuDriver) ContainsLines(matchers ...*TextMatcher) *MenuDriver { return self } +// types the text into the menu's filter row. Only for menus that filter as you +// type; other menus are filtered through the search prompt. func (self *MenuDriver) Filter(text string) *MenuDriver { - self.getViewDriver().FilterOrSearch(text) + self.getViewDriver().IsFocused() + self.t.typeContent(text) + self.t.Views().MenuFilter().IsVisible() return self } diff --git a/pkg/integration/tests/filter_and_search/filter_menu_as_you_type.go b/pkg/integration/tests/filter_and_search/filter_menu_as_you_type.go new file mode 100644 index 000000000..13b39e5eb --- /dev/null +++ b/pkg/integration/tests/filter_and_search/filter_menu_as_you_type.go @@ -0,0 +1,94 @@ +package filter_and_search + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var FilterMenuAsYouType = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Filtering a menu by typing into the filter row that appears as you type", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) {}, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files().IsFocused().Press(keys.Universal.OptionMenu) + + // The menu offers the filter, but stays as it is until we take it up on it + t.Views().Menu(). + IsFocused(). + Subtitle(Equals("(Type to filter)")). + Footer(Contains(" of ")) + t.Views().MenuFilter().IsInvisible() + t.Views().MenuFilterFrame().IsInvisible() + t.CursorIsHidden() + + t.ExpectPopup().Menu().Filter("whitespace") + + t.Views().Menu(). + Lines( + Contains("─── Global"), + Contains("Toggle whitespace").IsSelected(), + ). + // the row covers the border the footer was on, so it moves there + Subtitle(Equals("")). + Footer(Equals("")) + t.Views().MenuFilterFrame(). + IsVisible(). + Content(Equals("Filter ('@' for keybindings): ")). + Footer(Equals("1 of 1")). + SharesTopBorderWithBottomOf(t.Views().Menu()) + t.Views().MenuFilter().IsVisible().Content(Equals("whitespace")) + t.Views().Tooltip(). + IsVisible(). + Content(Contains("Toggle whether or not whitespace changes are shown")). + IsImmediatelyBelow(t.Views().MenuFilterFrame()) + t.CursorIsVisible() + + // Emptying the filter shows all the items again, and keeps the row + t.GlobalPress(config.Keybinding{""}) + t.Views().MenuFilter().IsVisible().Content(Equals("")) + t.Views().Menu().LineCount(GreaterThan(2)) + t.CursorIsVisible() + + // Moving the text cursor within the filter leaves the menu's selection alone + t.ExpectPopup().Menu().Filter("co") + t.Views().Menu().LineCount(GreaterThan(2)) + t.GlobalPress(config.Keybinding{""}) + t.Views().Menu().SelectedLineIdxAtLeast(2) + t.GlobalPress(config.Keybinding{""}) + t.Views().Menu().SelectedLineIdxAtLeast(2) + t.GlobalPress(config.Keybinding{""}) + + // Clicking an item selects it and leaves the filter where it is + t.Views().Menu().Click(0, 1).SelectedLineIdx(1) + t.GlobalPress(config.Keybinding{"m"}) + t.Views().MenuFilter().Content(Equals("com")) + + t.GlobalPress(config.Keybinding{""}) + + // Escape gives up the filter, keeping the item that was selected + t.ExpectPopup().Menu().Filter("whitespace") + t.Views().Menu().SelectedLine(Contains("Toggle whitespace")) + t.GlobalPress(keys.Universal.Return) + t.Views().Menu(). + IsFocused(). + SelectedLine(Contains("Toggle whitespace")). + Subtitle(Equals("(Type to filter)")). + Footer(Contains(" of ")) + t.Views().MenuFilter().IsInvisible() + t.Views().MenuFilterFrame().IsInvisible() + t.CursorIsHidden() + + // The next escape closes the menu + t.GlobalPress(keys.Universal.Return) + t.Views().Files().IsFocused() + + // A menu opened afterwards starts with no filter + t.Views().Files().Press(keys.Universal.OptionMenu) + t.ExpectPopup().Menu(). + Title(Equals("Keybindings")). + LineCount(GreaterThan(2)). + Cancel() + }, +}) diff --git a/pkg/integration/tests/filter_and_search/filter_menu_key_handling.go b/pkg/integration/tests/filter_and_search/filter_menu_key_handling.go new file mode 100644 index 000000000..7ab25cbb8 --- /dev/null +++ b/pkg/integration/tests/filter_and_search/filter_menu_key_handling.go @@ -0,0 +1,71 @@ +package filter_and_search + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var FilterMenuKeyHandling = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Which keys drive a menu that filters as you type, and which ones are filter text", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(cfg *config.AppConfig) { + // so that quitting is observable instead of ending the test + cfg.GetUserConfig().ConfirmOnQuit = true + }, + SetupRepo: func(shell *Shell) {}, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + // presses a key that is expected to move the selection away from the first + // item, and one that is expected to bring it back + navigates := func(forward string, back string) { + t.GlobalPress(config.Keybinding{forward}) + t.Views().Menu().SelectedLineIdxAtLeast(2) + t.GlobalPress(config.Keybinding{back}) + t.Views().Menu().SelectedLineIdx(1) + } + + t.Views().Files().IsFocused().Press(keys.Universal.OptionMenu) + t.Views().Menu().IsFocused().SelectedLineIdx(1) + + // Until there is a filter, the configured navigation keys drive the menu, + // printable or not + navigates("", "") + navigates("j", "k") + navigates(".", ",") + navigates(">", "<") + t.Views().MenuFilter().IsInvisible() + + // A menu item's own key is filter text; it doesn't execute the item. 'c' + // commits when the files view has the focus. + t.ExpectPopup().Menu().Filter("c") + t.Views().Menu().IsFocused() + t.Views().MenuFilter().Content(Equals("c")) + + // So is the key that filters other lists + t.GlobalPress(keys.Universal.StartSearch) + t.Views().MenuFilter().Content(Equals("c/")) + t.Views().Search().IsInvisible() + + // And so are the printable navigation keys, now that there is somewhere for + // them to go + t.GlobalPress(config.Keybinding{""}) + t.GlobalPress(config.Keybinding{"j"}) + t.GlobalPress(config.Keybinding{"."}) + t.GlobalPress(config.Keybinding{">"}) + t.Views().MenuFilter().Content(Equals("j.>")) + + // The keys that can't be typed keep driving the menu + t.GlobalPress(config.Keybinding{""}) + navigates("", "") + navigates("", "") + navigates("", "") + + // Keys that the filter doesn't take and the menu doesn't handle reach the + // global keybindings + t.GlobalPress(config.Keybinding{""}) + t.ExpectPopup().Confirmation(). + Title(Equals("")). + Content(Contains("Are you sure you want to quit?")). + Confirm() + }, +}) diff --git a/pkg/integration/tests/filter_and_search/filter_menu_with_printable_keybindings.go b/pkg/integration/tests/filter_and_search/filter_menu_with_printable_keybindings.go new file mode 100644 index 000000000..991aa12e2 --- /dev/null +++ b/pkg/integration/tests/filter_and_search/filter_menu_with_printable_keybindings.go @@ -0,0 +1,66 @@ +package filter_and_search + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var FilterMenuWithPrintableKeybindings = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Driving a menu that filters as you type when every key configured for it is printable", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(cfg *config.AppConfig) { + cfg.GetUserConfig().Keybinding.Universal.ConfirmMenu = config.Keybinding{"x"} + cfg.GetUserConfig().Keybinding.Universal.Return = config.Keybinding{"q"} + cfg.GetUserConfig().Keybinding.Universal.PrevItem = config.Keybinding{"k"} + cfg.GetUserConfig().Keybinding.Universal.NextItem = config.Keybinding{"j"} + cfg.GetUserConfig().Keybinding.Universal.PrevPage = config.Keybinding{"u"} + cfg.GetUserConfig().Keybinding.Universal.NextPage = config.Keybinding{"d"} + cfg.GetUserConfig().Keybinding.Universal.GotoTop = config.Keybinding{"g"} + cfg.GetUserConfig().Keybinding.Universal.GotoBottom = config.Keybinding{"G"} + }, + SetupRepo: func(shell *Shell) {}, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + navigates := func(forward string, back string) { + t.GlobalPress(config.Keybinding{forward}) + t.Views().Menu().SelectedLineIdxAtLeast(2) + t.GlobalPress(config.Keybinding{back}) + t.Views().Menu().SelectedLineIdx(1) + } + + t.Views().Files().IsFocused().Press(keys.Universal.OptionMenu) + t.Views().Menu().IsFocused().SelectedLineIdx(1) + + // Until there is a filter, the configured keys drive the menu + navigates("j", "k") + navigates("d", "u") + navigates("G", "g") + + // Once there is one, they are all filter text. It takes a key that isn't a + // navigation key to get there. + t.ExpectPopup().Menu().Filter("a") + t.GlobalPress(config.Keybinding{"j"}) + t.GlobalPress(config.Keybinding{"k"}) + t.GlobalPress(config.Keybinding{"d"}) + t.GlobalPress(config.Keybinding{"u"}) + t.Views().MenuFilter().Content(Equals("ajkdu")) + t.GlobalPress(config.Keybinding{""}) + + // The menu is still navigable, because the physical keys drive it whatever + // the configuration says + navigates("", "") + navigates("", "") + navigates("", "") + + // And so are confirming and cancelling. Escape gives up the filter first. + t.ExpectPopup().Menu().Filter("Toggle whitespace") + t.GlobalPress(config.Keybinding{""}) + t.Views().MenuFilter().IsInvisible() + t.Views().Menu().IsFocused().SelectedLine(Contains("Toggle whitespace")) + + t.ExpectPopup().Menu().Filter("Toggle whitespace") + t.Views().Menu().SelectedLine(Contains("Toggle whitespace")) + t.GlobalPress(config.Keybinding{""}) + t.Views().Files().IsFocused() + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index bf4cfd5df..7bc50fe18 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -262,9 +262,12 @@ var tests = []*components.IntegrationTest{ filter_and_search.FilterFilesStageDirectory, filter_and_search.FilterFuzzy, filter_and_search.FilterMenu, + filter_and_search.FilterMenuAsYouType, filter_and_search.FilterMenuByKeybinding, filter_and_search.FilterMenuCancelFilterWithEscape, + filter_and_search.FilterMenuKeyHandling, filter_and_search.FilterMenuWithNoKeybindings, + filter_and_search.FilterMenuWithPrintableKeybindings, filter_and_search.FilterPreservesSelectionOnModelChange, filter_and_search.FilterRemoteBranches, filter_and_search.FilterRemotes, diff --git a/pkg/integration/tests/ui/empty_menu.go b/pkg/integration/tests/ui/empty_menu.go index 35c3d4560..971bcb3c8 100644 --- a/pkg/integration/tests/ui/empty_menu.go +++ b/pkg/integration/tests/ui/empty_menu.go @@ -17,16 +17,19 @@ var EmptyMenu = NewIntegrationTest(NewIntegrationTestArgs{ IsFocused(). Press(keys.Universal.OptionMenu) + t.ExpectPopup().Menu(). + // a string that filters everything out + Filter("ljasldkjaslkdjalskdjalsdjaslkd") + t.Views().Menu(). IsFocused(). - // a string that filters everything out - FilterOrSearch("ljasldkjaslkdjalskdjalsdjaslkd"). IsEmpty(). - Press(keys.Universal.Select). + // space is filter text in this menu, so we confirm with enter + Press(keys.Universal.ConfirmMenu). Tap(func() { t.ExpectToast(Equals("Disabled: No item selected")) }). - // escape the search + // escape the filter PressEscape(). // escape the view PressEscape()