Allow filtering the keybindings and recent repos menus more directly (simply by typing) (#5985)
Some checks failed
Continuous Integration / ci - ${{matrix.os}} (~/.cache/go-build, ubuntu-latest) (push) Has been cancelled
Continuous Integration / ci - ${{matrix.os}} (~\AppData\Local\go-build, windows-latest) (push) Has been cancelled
Continuous Integration / Integration Tests - git ${{matrix.git-version}}${{ matrix.race && ' (race)' || '' }} (2.32.0, false) (push) Has been cancelled
Continuous Integration / Integration Tests - git ${{matrix.git-version}}${{ matrix.race && ' (race)' || '' }} (2.38.2, false) (push) Has been cancelled
Continuous Integration / Integration Tests - git ${{matrix.git-version}}${{ matrix.race && ' (race)' || '' }} (2.44.0, false) (push) Has been cancelled
Continuous Integration / Integration Tests - git ${{matrix.git-version}}${{ matrix.race && ' (race)' || '' }} (latest, false) (push) Has been cancelled
Continuous Integration / Integration Tests - git ${{matrix.git-version}}${{ matrix.race && ' (race)' || '' }} (latest, true) (push) Has been cancelled
Continuous Integration / build (push) Has been cancelled
Continuous Integration / check-codebase (push) Has been cancelled
Continuous Integration / lint (push) Has been cancelled
Continuous Integration / check-for-fixups (push) Has been cancelled
Codespell / Check for spelling errors (push) Has been cancelled
Generate Sponsors README / deploy (push) Has been cancelled
Continuous Integration / upload-coverage (push) Has been cancelled

All menus in lazygit can be filtered by pressing the `/` key; for most
menus which only show a handful of choices this is not really needed,
but with the two cases where it's useful, it was unnecessarily
inconvenient: you first have to press `/` to open the filter prompt, and
then press enter to confirm the filter before you could press enter
again to trigger the chosen item. It's much easier to simply type to
filter, and still use the arrow keys to select one of the filtered
items, or press enter to trigger it while the filter prompt is showing.

The consequence of this is that while the keybindings menu is open you
can no longer use the displayed key bindings to trigger the commands; I
think that's fine, that menu is more for looking up those keybindings
rather than for using them from within the menu.

Also: since `j`/`k` are bound to move the list selection by default, it
is not possible to filter for something that begins with `j`/`k`. I
didn't want to change this because I'm concerned that die-hard vim users
would perceive it as a regression if they can no longer type `j` to
select the next menu item. The workaround is to type some other letter
and backspace; this keeps the filter prompt open, so you can now type
`j` or `k`.
This commit is contained in:
Stefan Haller 2026-08-31 21:25:23 +02:00 committed by GitHub
commit 3f6be3b3ee
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
55 changed files with 1017 additions and 128 deletions

View file

@ -4,10 +4,14 @@
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.
In the commits view we don't filter, but search; this is deliberate 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.
## Menu filtering
The keybindings (`?`) and recent repositories menus can be filtered simply by typing. The filter field appears at the bottom of the menu while you type; there is no need to press `/` or confirm the filter before navigating the results.
## Filtering files by status
You can filter the files view to only show staged/unstaged files by pressing `<c-b>` in the files view.

View file

@ -13,14 +13,14 @@ func TestMouseReleaseDoesNotBreakDoubleClickDetection(t *testing.T) {
g := newTestGui(t)
view, _ := g.SetView("list", 0, 0, 20, 10, 0)
doubleClicks := []bool{}
assert.NoError(t, g.SetViewClickBinding(&ViewMouseBinding{
g.SetViewClickBinding(&ViewMouseBinding{
ViewName: "list",
Key: MouseLeft,
Handler: func(opts ViewMouseBindingOpts) error {
doubleClicks = append(doubleClicks, opts.IsDoubleClick)
return nil
},
}))
})
for _, event := range []GocuiEvent{
gocuiEventFromTcellEvent(tcell.NewEventMouse(view.x0+1, view.y0+1, tcell.ButtonPrimary, tcell.ModNone)),

View file

@ -78,7 +78,7 @@ func SimpleEditor(v *View, key Key) bool {
v.TextArea.GoToEndOfLine()
case key.Equals(NewKeyStrMod("y", ModCtrl)):
v.TextArea.Yank()
case key.Str() != "" && key.Mod() == 0:
case key.IsPrintable():
v.TextArea.TypeCharacter(key.Str())
default:
return false

View file

@ -675,19 +675,15 @@ func (g *Gui) DeleteViewKeybindings(viewname string) {
}
// SetTabClickBinding sets a binding for a tab click event
func (g *Gui) SetTabClickBinding(viewName string, handler tabClickHandler) error {
func (g *Gui) SetTabClickBinding(viewName string, handler tabClickHandler) {
g.tabClickBindings = append(g.tabClickBindings, &tabClickBinding{
viewName: viewName,
handler: handler,
})
return nil
}
func (g *Gui) SetViewClickBinding(binding *ViewMouseBinding) error {
func (g *Gui) SetViewClickBinding(binding *ViewMouseBinding) {
g.viewMouseBindings = append(g.viewMouseBindings, binding)
return nil
}
// captureMouse routes subsequent mouse events to view until the mouse button is
@ -1619,6 +1615,20 @@ func (g *Gui) ForceFlushViewsContentOnly(views []*View) error {
return g.flushContentOnly(views)
}
// hasFocus reports whether a view is drawn as focused. Views that are embedded
// in one another (see View.ParentView) form a single unit, so they are all drawn
// as focused while any one of them is the current view.
func (g *Gui) hasFocus(v *View) bool {
return g.currentView != nil && outermostView(v) == outermostView(g.currentView)
}
func outermostView(v *View) *View {
for v.ParentView != nil {
v = v.ParentView
}
return v
}
// draw manages the cursor and calls the draw function of a view.
func (g *Gui) draw(v *View) error {
if !v.Visible || v.y1 < v.y0 || v.x1 < v.x0 {
@ -1643,7 +1653,7 @@ func (g *Gui) draw(v *View) error {
if v.Frame {
var fgColor, bgColor, frameColor Attribute
if g.Highlight && v == g.currentView && g.IsFocused() {
if g.Highlight && g.hasFocus(v) && g.IsFocused() {
fgColor = g.SelFgColor
bgColor = g.SelBgColor
frameColor = g.SelFrameColor
@ -1983,7 +1993,7 @@ func (g *Gui) execKeybindings(v *View, ev *GocuiEvent) error {
matchingParentViewKb = nil
break
}
if v != nil && g.matchView(v.ParentView, kb) {
if matchingParentViewKb == nil && v != nil && g.matchView(v.ParentView, kb) {
matchingParentViewKb = kb
}
if globalKb == nil && kb.viewName == "" {
@ -2095,13 +2105,15 @@ func (g *Gui) isSuspended() bool {
return g.suspended
}
// matchView returns if the keybinding matches the current view (and the view's context)
// matchView returns if the keybinding matches the given view (and the view's context)
func (g *Gui) matchView(v *View, kb *keybinding) bool {
// if the user is typing in a field, ignore char keys
if v == nil {
return false
}
if v.Editable && kb.key.Str() != "" && kb.key.Mod() == 0 {
// If the user is typing in a field, printable keys are theirs to type, so no
// keybinding gets a look at them: not the field's own, and not those of the
// view it is embedded in either.
if field := g.currentView; field != nil && field.Editable && !field.KeybindOnEdit && kb.key.IsPrintable() {
return false
}
if kb.viewName != v.name {

View file

@ -61,6 +61,12 @@ func (k Key) IsSet() bool {
return k.keyName != 0
}
// IsPrintable reports whether the key stands for a character that can be typed
// into a text field.
func (k Key) IsPrintable() bool {
return k.keyName == KeyName(tcell.KeyRune) && k.str != "" && k.mod == ModNone
}
func (k Key) Equals(otherKey Key) bool {
return k.keyName == otherKey.keyName && k.str == otherKey.str && k.mod == otherKey.mod
}

16
pkg/gocui/key_test.go Normal file
View file

@ -0,0 +1,16 @@
package gocui
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestKeyIsPrintable(t *testing.T) {
assert.True(t, NewKeyRune('x').IsPrintable())
assert.True(t, NewKeyRune('界').IsPrintable())
assert.True(t, NewKeyRune(' ').IsPrintable())
assert.False(t, NewKeyStrMod("x", ModCtrl).IsPrintable())
assert.False(t, NewKeyName(KeyEnter).IsPrintable())
assert.False(t, Key{}.IsPrintable())
}

View file

@ -36,7 +36,7 @@ func TestMouseCaptureRoutesMotionAndReleaseOutsideView(t *testing.T) {
},
},
} {
assert.NoError(t, g.SetViewClickBinding(binding))
g.SetViewClickBinding(binding)
}
g.captureMouse(view)
@ -69,7 +69,7 @@ func TestPrimaryMouseDragStaysWithPressedView(t *testing.T) {
receivedBy := ""
for _, viewName := range []string{"left", "right"} {
assert.NoError(t, g.SetViewClickBinding(&ViewMouseBinding{
g.SetViewClickBinding(&ViewMouseBinding{
ViewName: viewName,
Key: MouseLeft,
Modifier: ModMotion,
@ -77,7 +77,7 @@ func TestPrimaryMouseDragStaysWithPressedView(t *testing.T) {
receivedBy = viewName
return nil
},
}))
})
}
assert.NoError(t, g.onKey(&GocuiEvent{
@ -102,10 +102,10 @@ func TestPrimaryMouseDragDoesNotActivateTabs(t *testing.T) {
view.Tabs = []string{"first", "second"}
clickedTabs := []int{}
assert.NoError(t, g.SetTabClickBinding("tabs", func(tabIndex int) error {
g.SetTabClickBinding("tabs", func(tabIndex int) error {
clickedTabs = append(clickedTabs, tabIndex)
return nil
}))
})
assert.NoError(t, g.onKey(&GocuiEvent{
Type: eventMouse,
@ -172,7 +172,7 @@ func TestCancelMouseCaptureSuppressesRemainingGesture(t *testing.T) {
_, _ = g.SetView("right", 21, 0, 41, 10, 0)
receivedBy := ""
for _, viewName := range []string{"left", "right"} {
assert.NoError(t, g.SetViewClickBinding(&ViewMouseBinding{
g.SetViewClickBinding(&ViewMouseBinding{
ViewName: viewName,
Key: MouseLeft,
Modifier: ModMotion,
@ -180,7 +180,7 @@ func TestCancelMouseCaptureSuppressesRemainingGesture(t *testing.T) {
receivedBy = viewName
return nil
},
}))
})
}
g.captureMouse(left)

View file

@ -0,0 +1,142 @@
package gocui
import (
"testing"
"github.com/stretchr/testify/assert"
)
// A view and its parent view, with the child holding the focus.
func setupParentAndChildView(t *testing.T, g *Gui) (*View, *View) {
t.Helper()
parent, _ := g.SetView("parent", 0, 0, 20, 10, 0)
child, _ := g.SetView("child", 0, 10, 20, 12, 0)
child.ParentView = parent
_, err := g.SetCurrentView(child.Name())
assert.NoError(t, err)
return parent, child
}
func TestKeybindingOfParentViewIsUsedWhenChildHasNone(t *testing.T) {
g := newTestGui(t)
parent, child := setupParentAndChildView(t, g)
pressed := []string{}
g.SetKeybinding(parent.Name(), NewKeyName(KeyArrowDown), func(*Gui, *View) error {
pressed = append(pressed, "parent")
return nil
})
g.SetKeybinding(child.Name(), NewKeyName(KeyEnter), func(*Gui, *View) error {
pressed = append(pressed, "child")
return nil
})
assert.NoError(t, g.onKey(&GocuiEvent{Type: eventKey, Key: NewKeyName(KeyArrowDown)}))
assert.NoError(t, g.onKey(&GocuiEvent{Type: eventKey, Key: NewKeyName(KeyEnter)}))
assert.Equal(t, []string{"parent", "child"}, pressed)
}
func TestFirstMatchingKeybindingOfParentViewWins(t *testing.T) {
g := newTestGui(t)
parent, _ := setupParentAndChildView(t, g)
pressed := []string{}
for _, name := range []string{"first", "second"} {
g.SetKeybinding(parent.Name(), NewKeyName(KeyArrowDown), func(*Gui, *View) error {
pressed = append(pressed, name)
return nil
})
}
assert.NoError(t, g.onKey(&GocuiEvent{Type: eventKey, Key: NewKeyName(KeyArrowDown)}))
assert.Equal(t, []string{"first"}, pressed)
}
func TestEmbeddedViewsAreFocusedTogether(t *testing.T) {
g := newTestGui(t)
parent, child := setupParentAndChildView(t, g)
sibling, _ := g.SetView("sibling", 0, 12, 20, 14, 0)
sibling.ParentView = parent
unrelated, _ := g.SetView("unrelated", 30, 0, 50, 10, 0)
assert.True(t, g.hasFocus(child))
assert.True(t, g.hasFocus(parent))
assert.True(t, g.hasFocus(sibling))
assert.False(t, g.hasFocus(unrelated))
_, err := g.SetCurrentView(unrelated.Name())
assert.NoError(t, err)
assert.True(t, g.hasFocus(unrelated))
assert.False(t, g.hasFocus(parent))
assert.False(t, g.hasFocus(child))
}
func TestPrintableKeysGoToTheFieldBeingTypedIn(t *testing.T) {
for _, test := range []struct {
name string
keybindOnEdit bool
declineKeybinding bool
expectedPresses int
expectedEdits int
}{
{name: "the field gets the key", expectedEdits: 1},
{name: "the parent view gets the key", keybindOnEdit: true, expectedPresses: 1},
{
name: "the field gets the key the parent view declined",
keybindOnEdit: true,
declineKeybinding: true,
expectedPresses: 1,
expectedEdits: 1,
},
} {
t.Run(test.name, func(t *testing.T) {
g := newTestGui(t)
parent, child := setupParentAndChildView(t, g)
child.Editable = true
child.KeybindOnEdit = test.keybindOnEdit
edits := 0
child.Editor = EditorFunc(func(*View, Key) bool {
edits++
return true
})
presses := 0
g.SetKeybinding(parent.Name(), NewKeyRune('j'), func(*Gui, *View) error {
presses++
if test.declineKeybinding {
return ErrKeybindingNotHandled
}
return nil
})
assert.NoError(t, g.onKey(&GocuiEvent{Type: eventKey, Key: NewKeyRune('j')}))
assert.Equal(t, test.expectedPresses, presses)
assert.Equal(t, test.expectedEdits, edits)
})
}
}
func TestUnhandledKeybindingOfParentViewFallsThroughToEditor(t *testing.T) {
g := newTestGui(t)
parent, child := setupParentAndChildView(t, g)
edited := []Key{}
child.Editable = true
child.Editor = EditorFunc(func(_ *View, key Key) bool {
edited = append(edited, key)
return true
})
g.SetKeybinding(parent.Name(), NewKeyName(KeyArrowDown), func(*Gui, *View) error {
return ErrKeybindingNotHandled
})
assert.NoError(t, g.onKey(&GocuiEvent{Type: eventKey, Key: NewKeyName(KeyArrowDown)}))
assert.Equal(t, []Key{NewKeyName(KeyArrowDown)}, edited)
}

View file

@ -210,7 +210,9 @@ type View struct {
// Overlaps describes which edges are overlapping with another view's edges
Overlaps byte
// ParentView is the view which catches events bubbled up from the given view if there's no matching handler
// ParentView is the view which catches events bubbled up from the given view if there's no matching handler.
// Views related this way are also drawn as a single focused unit: while one of
// them is the current view, they all get the focused frame and title colors.
ParentView *View
searcher *searcher

View file

@ -179,11 +179,12 @@ func (self *ContextMgr) Activate(c types.Context, opts types.OnFocusOpts) {
self.gui.helpers.Window.SetWindowContext(c)
self.gui.helpers.Window.MoveToTopOfWindow(c)
inputViewName := c.GetInputViewName()
oldView := self.gui.c.GocuiGui().CurrentView()
if oldView != nil && oldView.Name() != viewName {
if oldView != nil && oldView.Name() != inputViewName {
oldView.HighlightInactive = true
}
if _, err := self.gui.c.GocuiGui().SetCurrentView(viewName); err != nil {
if _, err := self.gui.c.GocuiGui().SetCurrentView(inputViewName); err != nil {
panic(err)
}

View file

@ -102,6 +102,10 @@ func (self *BaseContext) GetViewName() string {
return self.view.Name()
}
func (self *BaseContext) GetInputViewName() string {
return self.GetViewName()
}
func (self *BaseContext) GetView() *gocui.View {
return self.view
}

View file

@ -1,7 +1,5 @@
package context
import "github.com/jesseduffield/lazygit/pkg/i18n"
type FilteredListViewModel[T HasID] struct {
*FilteredList[T]
*ListViewModel[T]
@ -35,8 +33,3 @@ 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
}

View file

@ -28,6 +28,11 @@ type ListContextTrait struct {
// true if we're inside the OnSearchSelect call; in that case we don't want to update the search
// result index.
inOnSearchSelect bool
// If set, this renders the "x of y" footer instead of the default, which puts
// it on the bottom border of the list's own view. A list that is part of a
// composite panel can use this to put it somewhere else; see MenuContext.
renderFooter func(footer string)
}
func (self *ListContextTrait) IsListContext() {}
@ -81,7 +86,13 @@ func (self *ListContextTrait) refreshViewport() {
}
func (self *ListContextTrait) setFooter() {
self.GetViewTrait().SetFooter(formatListFooter(self.list.GetSelectedLineIdx(), self.list.Len()))
footer := formatListFooter(self.list.GetSelectedLineIdx(), self.list.Len())
if self.renderFooter != nil {
self.renderFooter(footer)
return
}
self.GetViewTrait().SetFooter(footer)
}
func formatListFooter(selectedLineIdx int, length int) string {

View file

@ -8,7 +8,6 @@ import (
"github.com/jesseduffield/lazygit/pkg/gocui"
"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"
)
@ -45,6 +44,13 @@ func NewMenuContext(
getColumnAlignments: func() []utils.Alignment { return viewModel.columnAlignment },
getNonModelItems: viewModel.GetNonModelItems,
},
// While the filter row is showing, its top border covers the menu's bottom
// border, so the footer has to be rendered on the row instead.
renderFooter: func(footer string) {
onFilterRow := viewModel.FilterStarted()
c.Views().Menu.Footer = lo.Ternary(onFilterRow, "", footer)
c.Views().MenuFilterFrame.Footer = lo.Ternary(onFilterRow, footer, "")
},
c: c,
},
}
@ -58,6 +64,8 @@ type MenuViewModel struct {
columnAlignment []utils.Alignment
allowFilteringKeybindings bool
keybindingsTakePrecedence bool
filterAsYouType bool
filterStarted bool
onCancel func() error
*FilteredListViewModel[*types.MenuItem]
}
@ -128,10 +136,37 @@ func (self *MenuViewModel) SetAllowFilteringKeybindings(allow bool) {
self.allowFilteringKeybindings = allow
}
func (self *MenuViewModel) AllowFilteringKeybindings() bool {
return self.allowFilteringKeybindings
}
func (self *MenuViewModel) SetKeybindingsTakePrecedence(value bool) {
self.keybindingsTakePrecedence = value
}
// Whether this menu has a filter row that filters the items as the user types,
// instead of being filtered through the search prompt.
func (self *MenuViewModel) SetFilterAsYouType(value bool) {
self.filterAsYouType = value
self.SetFilterStarted(false)
}
func (self *MenuViewModel) FilterAsYouType() bool {
return self.filterAsYouType
}
// Whether the user has started to filter, which is when the filter row appears.
func (self *MenuViewModel) SetFilterStarted(value bool) {
self.filterStarted = value
// As long as there is nothing to type into, printable keys keep driving the
// menu, so that the configured navigation keys work like in any other menu.
self.c.Views().MenuFilter.KeybindOnEdit = !value
}
func (self *MenuViewModel) FilterStarted() bool {
return self.filterStarted
}
// TODO: move into presentation package
func (self *MenuViewModel) GetDisplayStrings(_ int, _ int) [][]string {
menuItems := self.FilteredListViewModel.GetItems()
@ -209,6 +244,16 @@ func (self *MenuViewModel) GetNonModelItems() []*NonModelItem {
func (self *MenuContext) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
basicBindings := self.ListContextTrait.GetKeybindings(opts)
if self.filterAsYouType {
// A menu item's keys are shown as a reminder of what they do outside the
// menu, but pressing one types it into the filter rather than executing the
// item, so we don't bind them at all. That leaves the bindings that drive
// the menu itself, and the printable ones among those give way to the filter
// as soon as there is something to type into (see View.KeybindOnEdit).
return basicBindings
}
menuItemsWithKeys := lo.Filter(self.menuItems, func(item *types.MenuItem, _ int) bool {
return len(item.Keys) > 0
})
@ -267,10 +312,13 @@ func (self *MenuContext) RangeSelectEnabled() bool {
return false
}
func (self *MenuContext) FilterPrefix(tr *i18n.TranslationSet) string {
if self.allowFilteringKeybindings {
return tr.FilterPrefixMenu
// A menu that filters as you type points the keyboard at its filter input, so
// that whatever the user types ends up there. Keys that the input doesn't take
// still reach the menu, because the input view is embedded in the menu view.
func (self *MenuContext) GetInputViewName() string {
if self.filterAsYouType {
return self.c.Views().MenuFilter.Name()
}
return self.FilteredListViewModel.FilterPrefix(tr)
return self.GetViewName()
}

View file

@ -138,6 +138,9 @@ func (gui *Gui) resetHelpersAndControllers() {
common := controllers.NewControllerCommon(helperCommon, gui)
listControllerFactory := controllers.NewListControllerFactory(common)
menuListController := listControllerFactory.Create(gui.State.Contexts.Menu)
syncController := controllers.NewSyncController(
common,
)
@ -156,7 +159,7 @@ func (gui *Gui) resetHelpersAndControllers() {
remoteBranchesController := controllers.NewRemoteBranchesController(common)
menuController := controllers.NewMenuController(common)
menuController := controllers.NewMenuController(common, menuListController)
localCommitsController := controllers.NewLocalCommitsController(common, syncController.HandlePull)
tagsController := controllers.NewTagsController(common)
filesController := controllers.NewFilesController(
@ -359,6 +362,7 @@ func (gui *Gui) resetHelpersAndControllers() {
controllers.AttachControllers(gui.State.Contexts.Menu,
menuController,
menuListController,
)
controllers.AttachControllers(gui.State.Contexts.CommitMessage,
@ -412,8 +416,11 @@ func (gui *Gui) resetHelpersAndControllers() {
)
// this must come last so that we've got our click handlers defined against the context
listControllerFactory := controllers.NewListControllerFactory(common)
for _, context := range gui.c.Context().AllList() {
if context == gui.State.Contexts.Menu {
// already attached above, next to the menu controller that delegates to it
continue
}
controllers.AttachControllers(context, listControllerFactory.Create(context))
}
}

View file

@ -33,7 +33,17 @@ func (self *FilterController) Context() types.Context {
return self.context
}
// A context that filters as the user types has an input field of its own, so it
// has no use for the filter prompt.
type contextThatFiltersAsYouType interface {
FilterAsYouType() bool
}
func (self *FilterController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
if context, ok := self.context.(contextThatFiltersAsYouType); ok && context.FilterAsYouType() {
return nil
}
return []*types.Binding{
{
Keys: opts.GetKeys(opts.Config.Universal.StartSearch),
@ -44,5 +54,6 @@ func (self *FilterController) GetKeybindings(opts types.KeybindingsOpts) []*type
}
func (self *FilterController) OpenFilterPrompt() error {
return self.c.Helpers().Search.OpenFilterPrompt(self.context)
self.c.Helpers().Search.OpenFilterPrompt(self.context)
return nil
}

View file

@ -9,6 +9,7 @@ import (
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/theme"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/samber/lo"
)
type ConfirmationHelper struct {
@ -323,19 +324,70 @@ func (self *ConfirmationHelper) ResizeCurrentPopupPanels() {
}
}
// The rows that a filter row adds to a menu popup: one for the input, and one
// for its bottom border. Its top border is the menu's bottom border.
const menuFilterRowHeight = 2
// The prompts for the filter row, from the most to the least informative. The
// keybindings menu can also filter by keybinding, which is worth spelling out
// when there is room for it.
func (self *ConfirmationHelper) menuFilterPromptCandidates() []string {
if self.c.Contexts().Menu.AllowFilteringKeybindings() {
return []string{self.c.Tr.FilterPrefixMenu, self.c.Tr.FilterPrefix}
}
return []string{self.c.Tr.FilterPrefix}
}
// Returns the first prompt that still leaves room to type in, or no prompt at
// all if the row is too narrow even for the shortest one.
func menuFilterPrompt(candidates []string, contentWidth int) string {
const minimumInputWidth = 4
for _, candidate := range candidates {
if utils.StringWidth(candidate)+minimumInputWidth <= contentWidth {
return candidate
}
}
return ""
}
func (self *ConfirmationHelper) resizeMenu(parentPopupContext types.Context) {
menuContext := self.c.Contexts().Menu
// we want the unfiltered length here so that if we're filtering we don't
// resize the window
itemCount := self.c.Contexts().Menu.UnfilteredLen()
itemCount := menuContext.UnfilteredLen()
offset := 3
panelWidth := self.getPopupPanelWidth(90)
contentWidth := panelWidth - 2 // minus 2 for the frame
promptLinesCount := self.layoutMenuPrompt(contentWidth)
x0, y0, x1, y1 := self.getPopupPanelDimensionsForContentHeight(contentWidth, itemCount+offset+promptLinesCount, parentPopupContext)
menuBottom := y1 - offset
// The row is reserved for the whole time the menu is open, even though it only
// becomes visible once the user starts typing, so that revealing it doesn't
// move the menu.
filterRowHeight := lo.Ternary(menuContext.FilterAsYouType(), menuFilterRowHeight, 0)
x0, y0, x1, y1 := self.getPopupPanelDimensionsForContentHeight(
contentWidth, itemCount+offset+promptLinesCount+filterRowHeight, parentPopupContext)
menuBottom := y1 - offset - filterRowHeight
_, _ = self.c.GocuiGui().SetView(self.c.Views().Menu.Name(), x0, y0, x1, menuBottom, 0)
tooltipTop := menuBottom + 1
if menuContext.FilterAsYouType() {
filterRowBottom := menuBottom + filterRowHeight
// The row hangs off the bottom of the menu, sharing its bottom border.
_, _ = self.c.GocuiGui().SetView(self.c.Views().MenuFilterFrame.Name(), x0, menuBottom, x1, filterRowBottom, 0)
prompt := menuFilterPrompt(self.menuFilterPromptCandidates(), contentWidth)
self.c.Views().MenuFilterFrame.SetContent(prompt)
// A view's content starts one column inside its bounds, so the input field
// starts one column to the left of where its text is to appear.
inputLeft := x0 + utils.StringWidth(prompt)
_, _ = self.c.GocuiGui().SetView(self.c.Views().MenuFilter.Name(), inputLeft, menuBottom, x1, filterRowBottom, 0)
if menuContext.FilterStarted() {
tooltipTop = filterRowBottom + 1
}
}
tooltip := ""
selectedItem := self.c.Contexts().Menu.GetSelected()
if selectedItem != nil {

View file

@ -0,0 +1,31 @@
package helpers
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestMenuFilterPrompt(t *testing.T) {
longPrompt := "Filter ('@' for keybindings): "
shortPrompt := "Filter: "
tests := []struct {
name string
candidates []string
contentWidth int
expected string
}{
{name: "room for four characters", candidates: []string{shortPrompt}, contentWidth: 12, expected: shortPrompt},
{name: "room for three characters", candidates: []string{shortPrompt}, contentWidth: 11, expected: ""},
{name: "prefers the first candidate", candidates: []string{longPrompt, shortPrompt}, contentWidth: 34, expected: longPrompt},
{name: "falls back to the next one", candidates: []string{longPrompt, shortPrompt}, contentWidth: 33, expected: shortPrompt},
{name: "measures display width", candidates: []string{"篩選: "}, contentWidth: 9, expected: ""},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
assert.Equal(t, test.expected, menuFilterPrompt(test.candidates, test.contentWidth))
})
}
}

View file

@ -153,7 +153,11 @@ func (self *ReposHelper) CreateRecentReposMenu() error {
}
})
return self.c.Menu(types.CreateMenuOptions{Title: self.c.Tr.RecentRepos, Items: menuItems})
return self.c.Menu(types.CreateMenuOptions{
Title: self.c.Tr.RecentRepos,
Items: menuItems,
FilterAsYouType: true,
})
}
// SwitchToParentRepo switches back to the repo the current submodule was

View file

@ -29,14 +29,14 @@ func NewSearchHelper(
}
}
func (self *SearchHelper) OpenFilterPrompt(context types.IFilterableContext) error {
func (self *SearchHelper) OpenFilterPrompt(context types.IFilterableContext) {
state := self.searchState()
state.PrevSearchIndex = -1
state.Context = context
self.searchPrefixView().SetContent(context.FilterPrefix(self.c.Tr))
self.searchPrefixView().SetContent(self.c.Tr.FilterPrefix)
promptView := self.promptView()
promptView.ClearTextArea()
self.OnPromptContentChanged("")
@ -44,10 +44,10 @@ func (self *SearchHelper) OpenFilterPrompt(context types.IFilterableContext) err
self.c.Context().Push(self.c.Contexts().Search, types.OnFocusOpts{})
return self.c.ResetKeybindings()
self.c.ResetKeybindings()
}
func (self *SearchHelper) OpenSearchPrompt(context types.ISearchableContext) error {
func (self *SearchHelper) OpenSearchPrompt(context types.ISearchableContext) {
state := self.searchState()
state.PrevSearchIndex = -1
@ -61,7 +61,7 @@ func (self *SearchHelper) OpenSearchPrompt(context types.ISearchableContext) err
self.c.Context().Push(self.c.Contexts().Search, types.OnFocusOpts{})
return self.c.ResetKeybindings()
self.c.ResetKeybindings()
}
func (self *SearchHelper) DisplayFilterStatus(context types.IFilterableContext) {
@ -70,7 +70,7 @@ func (self *SearchHelper) DisplayFilterStatus(context types.IFilterableContext)
state.Context = context
searchString := context.GetFilter()
self.searchPrefixView().SetContent(context.FilterPrefix(self.c.Tr))
self.searchPrefixView().SetContent(self.c.Tr.FilterPrefix)
promptView := self.promptView()
keybindingConfig := self.c.UserConfig().Keybinding
@ -103,10 +103,11 @@ func (self *SearchHelper) promptContent() string {
return self.c.Contexts().Search.GetView().TextArea.GetContent()
}
func (self *SearchHelper) Confirm() error {
func (self *SearchHelper) Confirm() {
state := self.searchState()
if self.promptContent() == "" {
return self.CancelPrompt()
self.CancelPrompt()
return
}
switch state.SearchType() {
@ -118,7 +119,7 @@ func (self *SearchHelper) Confirm() error {
self.c.Context().Pop()
}
return self.c.ResetKeybindings()
self.c.ResetKeybindings()
}
func (self *SearchHelper) ConfirmFilter() {
@ -175,12 +176,12 @@ func modelSearchResults(context types.ISearchableContext) []gocui.SearchPosition
return context.ModelSearchResults(normalizedSearchStr, caseSensitive)
}
func (self *SearchHelper) CancelPrompt() error {
func (self *SearchHelper) CancelPrompt() {
self.Cancel()
self.c.Context().Pop()
return self.c.ResetKeybindings()
self.c.ResetKeybindings()
}
func (self *SearchHelper) ScrollHistory(scrollIncrement int) {
@ -224,9 +225,7 @@ func (self *SearchHelper) OnPromptContentChanged(searchString string) {
state := self.searchState()
switch context := state.Context.(type) {
case types.IFilterableContext:
context.SetSelection(0)
context.SetFilter(searchString, self.c.UserConfig().Gui.UseFuzzySearch())
self.c.PostRefreshUpdate(context)
self.ApplyFilter(context, searchString)
case types.ISearchableContext:
// do nothing
default:
@ -234,6 +233,12 @@ func (self *SearchHelper) OnPromptContentChanged(searchString string) {
}
}
func (self *SearchHelper) ApplyFilter(context types.IFilterableContext, filter string) {
context.SetSelection(0)
context.SetFilter(filter, self.c.UserConfig().Gui.UseFuzzySearch())
self.c.PostRefreshUpdate(context)
}
func (self *SearchHelper) ReApplyFilter(context types.Context) {
filterableContext, ok := context.(types.IFilterableContext)
if ok {

View file

@ -87,8 +87,8 @@ func (self *WindowArrangementHelper) GetWindowDimensions(informationStr string,
repoState := self.c.State().GetRepoState()
var searchPrefix string
if filterableContext, ok := repoState.GetSearchState().Context.(types.IFilterableContext); ok {
searchPrefix = filterableContext.FilterPrefix(self.c.Tr)
if _, ok := repoState.GetSearchState().Context.(types.IFilterableContext); ok {
searchPrefix = self.c.Tr.FilterPrefix
} else {
searchPrefix = self.c.Tr.SearchPrefix
}

View file

@ -1684,7 +1684,8 @@ func (self *LocalCommitsController) openSearch() error {
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.COMMITS}})
}
return self.c.Helpers().Search.OpenSearchPrompt(self.context())
self.c.Helpers().Search.OpenSearchPrompt(self.context())
return nil
}
func (self *LocalCommitsController) handleOpenLogMenu() error {

View file

@ -109,7 +109,8 @@ func (self *MainViewController) openSearch() error {
if manager := self.c.GetViewBufferManagerForView(self.context.GetView()); manager != nil {
manager.ReadToEnd(func() {
self.c.OnUIThread(func() error {
return self.c.Helpers().Search.OpenSearchPrompt(self.context)
self.c.Helpers().Search.OpenSearchPrompt(self.context)
return nil
})
})
}

View file

@ -1,20 +1,26 @@
package controllers
import (
"github.com/jesseduffield/lazygit/pkg/config"
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/context"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/samber/lo"
)
type MenuController struct {
baseController
*ListControllerTrait[*types.MenuItem]
c *ControllerCommon
// for delegating navigation to, see physicalKeyBindings
listController *ListController
}
var _ types.IController = &MenuController{}
func NewMenuController(
c *ControllerCommon,
listController *ListController,
) *MenuController {
return &MenuController{
baseController: baseController{},
@ -24,12 +30,13 @@ func NewMenuController(
c.Contexts().Menu.GetSelected,
c.Contexts().Menu.GetSelectedItems,
),
c: c,
c: c,
listController: listController,
}
}
// NOTE: if you add a new keybinding here, you'll also need to add it to
// `reservedKeys` in `pkg/gui/context/menu_context.go`
// `essentialKeys` in `pkg/gui/menu_panel.go`, so that menu items can't shadow it
func (self *MenuController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
bindings := []*types.Binding{
{
@ -52,6 +59,51 @@ func (self *MenuController) GetKeybindings(opts types.KeybindingsOpts) []*types.
},
}
if self.context().FilterAsYouType() {
bindings = append(bindings, self.physicalKeyBindings(opts)...)
}
return bindings
}
// In a menu that filters as you type, the keys configured for driving the menu
// may all be printable, and printable keys become filter text once the user
// starts typing. These keys can't, so binding them on top guarantees that the
// menu stays usable no matter how the keybindings are configured.
func (self *MenuController) physicalKeyBindings(opts types.KeybindingsOpts) []*types.Binding {
candidates := []struct {
key gocui.Key
configured config.Keybinding
binding *types.Binding
}{
{
key: gocui.NewKeyName(gocui.KeyEnter),
configured: opts.Config.Universal.ConfirmMenu,
binding: &types.Binding{
Handler: self.withItem(self.press),
GetDisabledReason: self.require(self.singleItemSelected()),
},
},
{gocui.NewKeyName(gocui.KeyEsc), opts.Config.Universal.Return, &types.Binding{Handler: self.close}},
{gocui.NewKeyName(gocui.KeyArrowUp), opts.Config.Universal.PrevItem, &types.Binding{Handler: self.listController.HandlePrevLine}},
{gocui.NewKeyName(gocui.KeyArrowDown), opts.Config.Universal.NextItem, &types.Binding{Handler: self.listController.HandleNextLine}},
{gocui.NewKeyName(gocui.KeyPgup), opts.Config.Universal.PrevPage, &types.Binding{Handler: self.listController.HandlePrevPage}},
{gocui.NewKeyName(gocui.KeyPgdn), opts.Config.Universal.NextPage, &types.Binding{Handler: self.listController.HandleNextPage}},
{gocui.NewKeyName(gocui.KeyHome), opts.Config.Universal.GotoTop, &types.Binding{Handler: self.listController.HandleGotoTop}},
{gocui.NewKeyName(gocui.KeyEnd), opts.Config.Universal.GotoBottom, &types.Binding{Handler: self.listController.HandleGotoBottom}},
}
bindings := []*types.Binding{}
for _, candidate := range candidates {
if lo.Contains(opts.GetKeys(candidate.configured), candidate.key) {
// this key is the configured one, so it drives the menu already
continue
}
candidate.binding.Keys = []gocui.Key{candidate.key}
bindings = append(bindings, candidate.binding)
}
return bindings
}
@ -73,6 +125,11 @@ func (self *MenuController) press(selectedItem *types.MenuItem) error {
}
func (self *MenuController) close() error {
if self.context().FilterStarted() {
self.stopFiltering()
return nil
}
if self.context().IsFiltering() {
self.c.Helpers().Search.Cancel()
return nil
@ -81,6 +138,17 @@ func (self *MenuController) close() error {
return self.context().OnMenuPress(nil)
}
// Hides the filter row again and puts the menu back the way it was, keeping the
// item that was selected. It takes another escape to close the menu.
func (self *MenuController) stopFiltering() {
self.c.Views().MenuFilter.ClearTextArea()
self.c.Views().MenuFilter.RenderTextArea()
self.context().SetFilterStarted(false)
self.context().ClearFilter()
self.c.PostRefreshUpdate(self.context())
}
func (self *MenuController) context() *context.MenuContext {
return self.c.Contexts().Menu
}

View file

@ -64,6 +64,7 @@ func (self *OptionsMenuAction) Call() error {
ColumnAlignment: []utils.Alignment{utils.AlignRight, utils.AlignLeft},
AllowFilteringKeybindings: true,
KeepConflictingKeybindings: true,
FilterAsYouType: true,
})
}

View file

@ -37,12 +37,13 @@ func (self *SearchController) GetKeybindings(opts types.KeybindingsOpts) []*type
return []*types.Binding{
{
Keys: opts.GetKeys(opts.Config.Universal.StartSearch),
Handler: self.OpenSearchPrompt,
Handler: self.openSearchPrompt,
Description: self.c.Tr.StartSearch,
},
}
}
func (self *SearchController) OpenSearchPrompt() error {
return self.c.Helpers().Search.OpenSearchPrompt(self.context)
func (self *SearchController) openSearchPrompt() error {
self.c.Helpers().Search.OpenSearchPrompt(self.context)
return nil
}

View file

@ -51,11 +51,13 @@ func (self *SearchPromptController) context() types.Context {
}
func (self *SearchPromptController) confirm() error {
return self.c.Helpers().Search.Confirm()
self.c.Helpers().Search.Confirm()
return nil
}
func (self *SearchPromptController) cancel() error {
return self.c.Helpers().Search.CancelPrompt()
self.c.Helpers().Search.CancelPrompt()
return nil
}
func (self *SearchPromptController) prevHistory() error {

View file

@ -49,6 +49,32 @@ func (gui *Gui) promptEditor(v *gocui.View, key gocui.Key) bool {
return matched
}
func (gui *Gui) menuFilterEditor(v *gocui.View, key gocui.Key) bool {
contentBefore := v.TextArea.GetContent()
matched := gui.handleEditorKeypress(v, key, false)
if !matched {
// Give the global keybindings a chance at the key, e.g. so that ctrl-c
// still quits while a menu is open.
return false
}
v.RenderTextArea()
content := v.TextArea.GetContent()
if content == contentBefore {
// The key just moved the cursor around within the filter; refiltering would
// throw away the menu's selection for nothing.
return true
}
menuContext := gui.State.Contexts.Menu
menuContext.SetFilterStarted(true)
gui.helpers.Search.ApplyFilter(menuContext, content)
return true
}
func (gui *Gui) searchEditor(v *gocui.View, key gocui.Key) bool {
matched := gui.handleEditorKeypress(v, key, false)
v.RenderTextArea()

View file

@ -7,7 +7,6 @@ import (
"github.com/jesseduffield/lazygit/pkg/common"
"github.com/jesseduffield/lazygit/pkg/gui/context/traits"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/i18n"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/samber/lo"
)
@ -263,10 +262,6 @@ func (self *CommitFileTreeViewModel) IsFiltering() bool {
// used for type switch
func (self *CommitFileTreeViewModel) IsFilterableContext() {}
func (self *CommitFileTreeViewModel) FilterPrefix(tr *i18n.TranslationSet) string {
return tr.FilterPrefix
}
func (self *CommitFileTreeViewModel) GetSearchHistory() *utils.HistoryBuffer[string] {
return self.searchHistory
}

View file

@ -7,7 +7,6 @@ import (
"github.com/jesseduffield/lazygit/pkg/common"
"github.com/jesseduffield/lazygit/pkg/gui/context/traits"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/i18n"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/samber/lo"
)
@ -274,10 +273,6 @@ func (self *FileTreeViewModel) IsFiltering() bool {
// used for type switch
func (self *FileTreeViewModel) IsFilterableContext() {}
func (self *FileTreeViewModel) FilterPrefix(tr *i18n.TranslationSet) string {
return tr.FilterPrefix
}
func (self *FileTreeViewModel) GetSearchHistory() *utils.HistoryBuffer[string] {
return self.searchHistory
}

View file

@ -368,10 +368,7 @@ func (gui *Gui) onNewRepo(startArgs appTypes.StartArgs, contextKey types.Context
contextToPush := gui.resetState(startArgs)
gui.resetHelpersAndControllers()
if err := gui.resetKeybindings(); err != nil {
return err
}
gui.resetKeybindings()
gui.g.SetFocusHandler(func(Focused bool) error {
if Focused {
@ -383,9 +380,7 @@ func (gui *Gui) onNewRepo(startArgs appTypes.StartArgs, contextKey types.Context
gui.c.Log.Info("User config changed - reloading")
reloadErr = gui.onUserConfigLoaded()
gui.reloadSidePanels()
if err := gui.resetKeybindings(); err != nil {
return err
}
gui.resetKeybindings()
if err := gui.checkForChangedConfigsThatDontAutoReload(oldConfig, gui.Config.GetUserConfig()); err != nil {
return err
@ -927,6 +922,21 @@ func (gui *Gui) viewTabMap() map[string][]context.TabView {
return result
}
// The views that each popup panel is made up of. A panel's views share the
// keyboard focus, so clicking from one of them to another stays within the
// panel.
var popupPanelViewGroups = [][]string{
{"commitMessage", "commitDescription"},
{"prompt", "suggestions"},
{"menu", "menuFilterFrame", "menuFilter"},
}
func viewsBelongToSamePopupPanel(viewName string, otherViewName string) bool {
return lo.SomeBy(popupPanelViewGroups, func(group []string) bool {
return lo.Contains(group, viewName) && lo.Contains(group, otherViewName)
})
}
// Run: setup the gui with keybindings and start the mainloop
func (gui *Gui) Run(startArgs appTypes.StartArgs) error {
g, err := gui.initGocui(Headless(), startArgs.IntegrationTest)
@ -942,15 +952,11 @@ func (gui *Gui) Run(startArgs appTypes.StartArgs) error {
gui.g.ShouldHandleMouseEvent = func(view *gocui.View, key gocui.KeyName) bool {
if gui.helpers.Confirmation.IsPopupPanelFocused() && gui.currentViewName() != view.Name() &&
!gocui.IsMouseScrollKey(key) {
// we ignore click events on views that aren't popup panels, when a popup panel is focused.
// Unless both the current view and the clicked-on view are either commit message or commit
// description, or a prompt and the suggestions view, because we want to allow switching
// between those two views by clicking.
isCommitMessageOrSuggestionsView := func(viewName string) bool {
return viewName == "commitMessage" || viewName == "commitDescription" ||
viewName == "prompt" || viewName == "suggestions"
}
if !isCommitMessageOrSuggestionsView(gui.currentViewName()) || !isCommitMessageOrSuggestionsView(view.Name()) {
// we ignore click events on views that aren't popup panels, when a popup
// panel is focused. Unless the clicked-on view is part of the same popup
// panel as the current one, because we want to allow switching between the
// views of a panel by clicking.
if !viewsBelongToSamePopupPanel(gui.currentViewName(), view.Name()) {
return false
}
}

View file

@ -201,8 +201,8 @@ func (self *guiCommon) CallKeybindingHandler(binding *types.Binding) error {
return self.gui.callKeybindingHandler(binding)
}
func (self *guiCommon) ResetKeybindings() error {
return self.gui.resetKeybindings()
func (self *guiCommon) ResetKeybindings() {
self.gui.resetKeybindings()
}
func (self *guiCommon) IsAnyModeActive() bool {

View file

@ -181,6 +181,10 @@ func (self *GuiDriver) CurrentContext() types.Context {
return self.gui.State.ContextMgr.Current()
}
func (self *GuiDriver) CursorVisible() bool {
return self.gui.g.Cursor
}
func (self *GuiDriver) ContextForView(viewName string) types.Context {
context, ok := self.gui.helpers.View.ContextForView(viewName)
if !ok {

View file

@ -351,7 +351,7 @@ func (gui *Gui) GetInitialKeybindingsWithCustomCommands() ([]*types.Binding, []*
return bindings, mouseBindings
}
func (gui *Gui) resetKeybindings() error {
func (gui *Gui) resetKeybindings() {
gui.g.DeleteAllKeybindings()
bindings, mouseBindings := gui.GetInitialKeybindingsWithCustomCommands()
@ -361,9 +361,7 @@ func (gui *Gui) resetKeybindings() error {
}
for _, binding := range mouseBindings {
if err := gui.SetMouseKeybinding(binding); err != nil {
return err
}
gui.SetMouseKeybinding(binding)
}
for _, values := range gui.viewTabMap() {
@ -373,13 +371,9 @@ func (gui *Gui) resetKeybindings() error {
return gui.onViewTabClick(gui.helpers.Window.WindowForView(viewName), tabIndex)
}
if err := gui.g.SetTabClickBinding(viewName, tabClickCallback); err != nil {
return err
}
gui.g.SetTabClickBinding(viewName, tabClickCallback)
}
}
return nil
}
func (gui *Gui) SetKeybinding(binding *types.Binding) {
@ -392,8 +386,8 @@ func (gui *Gui) SetKeybinding(binding *types.Binding) {
}
}
func (gui *Gui) SetMouseKeybinding(binding *gocui.ViewMouseBinding) error {
return gui.g.SetViewClickBinding(binding)
func (gui *Gui) SetMouseKeybinding(binding *gocui.ViewMouseBinding) {
gui.g.SetViewClickBinding(binding)
}
func (gui *Gui) callKeybindingHandler(binding *types.Binding) error {

View file

@ -144,15 +144,25 @@ func (gui *Gui) layout(g *gocui.Gui) error {
}
}
// When the screen is too short the side panels are squashed, with the
// unfocused ones taking one row each and the focused one taking the rest. The
// more panels there are, the more rows the unfocused ones reserve, so the
// floor below which there's no room left for the focused panel grows with the
// panel count. Keep the historical floor of 9 for the default five panels.
minimumHeight := max(9, len(gui.helpers.Window.SideWindows())+4)
menuWithFilterRowVisible := gui.Views.Menu.Visible && gui.State.Contexts.Menu.FilterAsYouType()
minimumHeight := minimumScreenHeight(len(gui.helpers.Window.SideWindows()), menuWithFilterRowVisible)
minimumWidth := 10
gui.Views.Limit.Visible = height < minimumHeight || width < minimumWidth
filterRowVisible := gui.Views.Menu.Visible && gui.State.Contexts.Menu.FilterStarted()
gui.Views.MenuFilterFrame.Visible = filterRowVisible
gui.Views.MenuFilter.Visible = filterRowVisible
if gui.Views.Menu.Visible {
// Until the user types something there is no filter row to advertise the
// filter, so the menu says that typing is a thing.
gui.Views.Menu.Subtitle = lo.Ternary(menuWithFilterRowVisible && !filterRowVisible, gui.c.Tr.MenuFilterHint, "")
}
if menuWithFilterRowVisible {
// The filter input is the current view for as long as such a menu is open,
// so without this the cursor would sit on the menu's bottom border, where
// the filter row is yet to appear.
gui.g.Cursor = filterRowVisible
}
gui.Views.Tooltip.Visible = gui.Views.Menu.Visible && gui.Views.Tooltip.Buffer() != ""
for _, context := range gui.transientContexts() {
@ -229,6 +239,26 @@ outer:
return nil
}
// The height below which we show the "not enough space" view instead of the
// layout.
func minimumScreenHeight(sideWindowCount int, menuWithFilterRowVisible bool) int {
// When the screen is too short the side panels are squashed, with the
// unfocused ones taking one row each and the focused one taking the rest. The
// more panels there are, the more rows the unfocused ones reserve, so the
// floor below which there's no room left for the focused panel grows with the
// panel count. Keep the historical floor of 9 for the default five panels.
minimumHeight := max(9, sideWindowCount+4)
// A menu popup gets three quarters of the screen, of which its frame, the
// tooltip gap below it and a reserved filter row take seven rows, so below 11
// rows there is no room left for even one menu item.
if menuWithFilterRowVisible {
minimumHeight = max(minimumHeight, 11)
}
return minimumHeight
}
func (gui *Gui) prepareView(viewName string) (*gocui.View, error) {
// arbitrarily giving the view enough size so that we don't get an error, but
// it's expected that the view will be given the correct size before being shown

14
pkg/gui/layout_test.go Normal file
View file

@ -0,0 +1,14 @@
package gui
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestMinimumScreenHeight(t *testing.T) {
assert.Equal(t, 9, minimumScreenHeight(5, false))
assert.Equal(t, 12, minimumScreenHeight(8, false))
assert.Equal(t, 11, minimumScreenHeight(5, true))
assert.Equal(t, 12, minimumScreenHeight(8, true))
}

View file

@ -69,9 +69,13 @@ func (gui *Gui) createMenu(opts types.CreateMenuOptions) error {
gui.State.Contexts.Menu.SetPrompt(opts.Prompt)
gui.State.Contexts.Menu.SetAllowFilteringKeybindings(opts.AllowFilteringKeybindings)
gui.State.Contexts.Menu.SetKeybindingsTakePrecedence(!opts.KeepConflictingKeybindings)
gui.State.Contexts.Menu.SetFilterAsYouType(opts.FilterAsYouType)
gui.State.Contexts.Menu.SetOnCancel(opts.OnCancel)
gui.State.Contexts.Menu.SetSelection(0)
gui.Views.MenuFilter.ClearTextArea()
gui.Views.MenuFilter.RenderTextArea()
gui.Views.Menu.Title = opts.Title
gui.Views.Menu.FgColor = theme.GocuiDefaultTextColor
@ -80,9 +84,7 @@ func (gui *Gui) createMenu(opts types.CreateMenuOptions) error {
gui.Views.Tooltip.Visible = true
// resetting keybindings so that the menu-specific keybindings are registered
if err := gui.resetKeybindings(); err != nil {
return err
}
gui.resetKeybindings()
gui.c.PostRefreshUpdate(gui.State.Contexts.Menu)

View file

@ -145,7 +145,7 @@ type IGuiCommon interface {
KeybindingsOpts() KeybindingsOpts
CallKeybindingHandler(binding *Binding) error
ResetKeybindings() error
ResetKeybindings()
// hopefully we can remove this once we've moved all our keybinding stuff out of the gui god struct.
GetInitialKeybindingsWithCustomCommands() ([]*Binding, []*gocui.ViewMouseBinding)
@ -213,6 +213,11 @@ type CreateMenuOptions struct {
ColumnAlignment []utils.Alignment
AllowFilteringKeybindings bool
KeepConflictingKeybindings bool // if true, the keybindings that match essential bindings such as confirm or return will not be removed from menu items
// if true, the menu has a filter row of its own and filters its items as the
// user types, instead of being filtered through the search prompt. Only for
// menus whose items don't have keybindings of their own, because those keys
// would clash with typing.
FilterAsYouType bool
}
type CreatePopupPanelOpts struct {

View file

@ -4,7 +4,6 @@ import (
"github.com/jesseduffield/lazygit/pkg/config"
"github.com/jesseduffield/lazygit/pkg/gocui"
"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"
)
@ -57,6 +56,10 @@ type IBaseContext interface {
GetKind() ContextKind
GetViewName() string
// The view that keyboard input goes to while this context is focused. That is
// the context's own view, unless the context has an editable view embedded in
// it which takes the keyboard instead, like the menu's filter input.
GetInputViewName() string
GetView() *gocui.View
GetViewTrait() IViewTrait
GetWindowName() string
@ -136,7 +139,6 @@ type IFilterableContext interface {
ReApplyFilter(bool)
IsFiltering() bool
IsFilterableContext()
FilterPrefix(tr *i18n.TranslationSet) string
}
type ISearchableContext interface {

View file

@ -27,6 +27,8 @@ type Views struct {
Confirmation *gocui.View
Prompt *gocui.View
Menu *gocui.View
MenuFilterFrame *gocui.View
MenuFilter *gocui.View
CommitMessage *gocui.View
CommitDescription *gocui.View
CommitFiles *gocui.View

View file

@ -140,7 +140,7 @@ func (gui *Gui) postRefreshUpdate(c types.Context, opts types.OnFocusOpts) {
c.HandleRender()
if gui.currentViewName() == c.GetViewName() {
if gui.currentViewName() == c.GetInputViewName() {
c.HandleFocus(opts)
} else {
// The FocusLine call is included in the HandleFocus method which we

View file

@ -66,6 +66,12 @@ func (gui *Gui) orderedViewNameMappings() []viewNameMapping {
{viewPtr: &gui.Views.CommitMessage, name: "commitMessage"},
{viewPtr: &gui.Views.CommitDescription, name: "commitDescription"},
{viewPtr: &gui.Views.Menu, name: "menu"},
// the filter row of a menu that filters as you type: a frame that hangs off
// the bottom of the menu and shows the "Filter:" prompt, plus the input
// field that sits inside it. Both must come after the menu so that the row's
// top border is drawn over the menu's bottom border.
{viewPtr: &gui.Views.MenuFilterFrame, name: "menuFilterFrame"},
{viewPtr: &gui.Views.MenuFilter, name: "menuFilter"},
{viewPtr: &gui.Views.Suggestions, name: "suggestions"},
{viewPtr: &gui.Views.Confirmation, name: "confirmation"},
{viewPtr: &gui.Views.Prompt, name: "prompt"},
@ -139,6 +145,16 @@ func (gui *Gui) createAllViews() error {
gui.Views.Menu.Visible = false
gui.Views.MenuFilterFrame.Visible = false
gui.Views.MenuFilter.Visible = false
gui.Views.MenuFilter.Frame = false
gui.Views.MenuFilter.Editable = true
gui.Views.MenuFilter.Editor = gocui.EditorFunc(gui.menuFilterEditor)
// The filter row belongs to the menu: it shares the menu's focus, and keys
// that the input field doesn't take are the menu's to handle.
gui.Views.MenuFilterFrame.ParentView = gui.Views.Menu
gui.Views.MenuFilter.ParentView = gui.Views.Menu
gui.Views.Tooltip.Visible = false
gui.Views.Tooltip.AutoRenderHyperLinks = true
@ -155,17 +171,30 @@ func (gui *Gui) createAllViews() error {
return nil
}
// gocui expects a view's frame runes in this order: the horizontal and the
// vertical edge, then the top left, top right, bottom left and bottom right
// corner.
func frameRunesWithTopCorners(frameRunes []rune, topLeft rune, topRight rune) []rune {
return []rune{frameRunes[0], frameRunes[1], topLeft, topRight, frameRunes[4], frameRunes[5]}
}
func (gui *Gui) configureViewProperties() {
frameRunes := []rune{'─', '│', '┌', '┐', '└', '┘'}
// The corners for a view that hangs off the bottom of another one, so that the
// border they share reads as a divider rather than as two frames touching.
teeLeft, teeRight := '├', '┤'
switch gui.c.UserConfig().Gui.Border {
case "double":
frameRunes = []rune{'═', '║', '╔', '╗', '╚', '╝'}
teeLeft, teeRight = '╠', '╣'
case "rounded":
frameRunes = []rune{'─', '│', '╭', '╮', '╰', '╯'}
case "hidden":
frameRunes = []rune{' ', ' ', ' ', ' ', ' ', ' '}
teeLeft, teeRight = ' ', ' '
case "bold":
frameRunes = []rune{'━', '┃', '┏', '┓', '┗', '┛'}
teeLeft, teeRight = '┣', '┫'
}
for _, mapping := range gui.orderedViewNameMappings() {
@ -177,6 +206,8 @@ func (gui *Gui) configureViewProperties() {
(*mapping.viewPtr).InactiveViewSelBgColor = theme.GocuiInactiveViewSelectedLineBgColor
}
gui.Views.MenuFilterFrame.FrameRunes = frameRunesWithTopCorners(frameRunes, teeLeft, teeRight)
gui.c.SetViewContent(gui.Views.SearchPrefix, gui.c.Tr.SearchPrefix)
gui.Views.Stash.Title = gui.c.Tr.StashTitle

View file

@ -879,6 +879,7 @@ type TranslationSet struct {
SearchPrefix string
FilterPrefix string
FilterPrefixMenu string
MenuFilterHint string
ExitSearchMode string
ExitTextFilterMode string
Switch string
@ -2039,7 +2040,8 @@ 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",
Switch: "Switch",

View file

@ -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
}

View file

@ -97,6 +97,24 @@ func (self *TestDriver) GlobalPress(key config.Keybinding) {
self.press(key[0])
}
// asserts that the terminal's text cursor is shown, i.e. that there is a text
// field to type into
func (self *TestDriver) CursorIsVisible() *TestDriver {
self.assertWithRetries(func() (bool, string) {
return self.gui.CursorVisible(), "Expected the cursor to be visible"
})
return self
}
func (self *TestDriver) CursorIsHidden() *TestDriver {
self.assertWithRetries(func() (bool, string) {
return !self.gui.CursorVisible(), "Expected the cursor to be hidden"
})
return self
}
// FocusIn simulates the terminal window regaining focus, which causes lazygit
// to reload any config files that changed while it was in the background.
func (self *TestDriver) FocusIn() {

View file

@ -88,6 +88,10 @@ func (self *fakeGuiDriver) CurrentContext() types.Context {
return nil
}
func (self *fakeGuiDriver) CursorVisible() bool {
return false
}
func (self *fakeGuiDriver) ContextForView(viewName string) types.Context {
return nil
}

View file

@ -41,6 +41,53 @@ func (self *ViewDriver) Title(expected *TextMatcher) *ViewDriver {
return self
}
// asserts that the view has the expected footer, i.e. the "x of y" text on its
// bottom border
func (self *ViewDriver) Footer(expected *TextMatcher) *ViewDriver {
self.t.assertWithRetries(func() (bool, string) {
actual := self.getView().Footer
return expected.context(fmt.Sprintf("%s footer", self.context)).test(actual)
})
return self
}
// asserts that the view has the expected subtitle
func (self *ViewDriver) Subtitle(expected *TextMatcher) *ViewDriver {
self.t.assertWithRetries(func() (bool, string) {
actual := self.getView().Subtitle
return expected.context(fmt.Sprintf("%s subtitle", self.context)).test(actual)
})
return self
}
// asserts that the view hangs off the bottom of the given one, sharing a border
// with it
func (self *ViewDriver) SharesTopBorderWithBottomOf(upper *ViewDriver) *ViewDriver {
self.t.assertWithRetries(func() (bool, string) {
_, _, _, upperY1 := upper.getView().Dimensions()
_, y0, _, _ := self.getView().Dimensions()
return y0 == upperY1, fmt.Sprintf(
"%s: Expected view to start on row %d, where the view above it ends, but it starts on row %d",
self.context, upperY1, y0)
})
return self
}
// asserts that the view starts on the row below the given one
func (self *ViewDriver) IsImmediatelyBelow(upper *ViewDriver) *ViewDriver {
self.t.assertWithRetries(func() (bool, string) {
_, _, _, upperY1 := upper.getView().Dimensions()
_, y0, _, _ := self.getView().Dimensions()
return y0 == upperY1+1, fmt.Sprintf(
"%s: Expected view to start on row %d, but it starts on row %d", self.context, upperY1+1, y0)
})
return self
}
func (self *ViewDriver) Clear() *ViewDriver {
// clearing multiple times in case there's multiple lines
// (the clear button only clears a single line at a time)

View file

@ -124,6 +124,14 @@ func (self *Views) Menu() *ViewDriver {
return self.regularView("menu")
}
func (self *Views) MenuFilter() *ViewDriver {
return self.regularView("menuFilter")
}
func (self *Views) MenuFilterFrame() *ViewDriver {
return self.regularView("menuFilterFrame")
}
func (self *Views) Confirmation() *ViewDriver {
return self.regularView("confirmation")
}

View file

@ -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{"<c-u>"})
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{"<down>"})
t.Views().Menu().SelectedLineIdxAtLeast(2)
t.GlobalPress(config.Keybinding{"<left>"})
t.Views().Menu().SelectedLineIdxAtLeast(2)
t.GlobalPress(config.Keybinding{"<right>"})
// 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{"<c-u>"})
// 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()
},
})

View file

@ -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("<down>", "<up>")
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{"<c-u>"})
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{"<c-u>"})
navigates("<down>", "<up>")
navigates("<pgdown>", "<pgup>")
navigates("<end>", "<home>")
// Keys that the filter doesn't take and the menu doesn't handle reach the
// global keybindings
t.GlobalPress(config.Keybinding{"<c-c>"})
t.ExpectPopup().Confirmation().
Title(Equals("")).
Content(Contains("Are you sure you want to quit?")).
Confirm()
},
})

View file

@ -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{"<c-u>"})
// The menu is still navigable, because the physical keys drive it whatever
// the configuration says
navigates("<down>", "<up>")
navigates("<pgdown>", "<pgup>")
navigates("<end>", "<home>")
// And so are confirming and cancelling. Escape gives up the filter first.
t.ExpectPopup().Menu().Filter("Toggle whitespace")
t.GlobalPress(config.Keybinding{"<esc>"})
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{"<enter>"})
t.Views().Files().IsFocused()
},
})

View file

@ -0,0 +1,37 @@
package misc
import (
"path/filepath"
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var FilterRecentRepos = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Switching to a recent repository by typing part of its name",
ExtraCmdArgs: []string{},
ExtraEnvVars: map[string]string{
"SHOW_RECENT_REPOS": "true",
},
Skip: false,
SetupConfig: func(cfg *config.AppConfig) {
// the first entry is the repo we're in, so it isn't offered
current, _ := filepath.Abs(".")
other, _ := filepath.Abs("../other")
target, _ := filepath.Abs("../target")
cfg.GetAppState().RecentRepos = []string{current, other, target}
},
SetupRepo: func(shell *Shell) {
shell.CloneNonBare("other")
shell.CloneNonBare("target")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.ExpectPopup().Menu().
Title(Equals("Recent repositories")).
Filter("target").
Lines(Contains("target").IsSelected()).
Confirm()
t.Views().Status().Content(Contains("target → master"))
},
})

View file

@ -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,
@ -357,6 +360,7 @@ var tests = []*components.IntegrationTest{
misc.DirenvApprovesEnvrc,
misc.DirenvLoadedOnRepoSwitch,
misc.DirenvUnloadsOnBlockedEnvrc,
misc.FilterRecentRepos,
misc.InitialOpen,
misc.RecentReposOnLaunch,
misc.StartInGitDir,

View file

@ -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()

View file

@ -45,6 +45,8 @@ type GuiDriver interface {
FocusInAndClick(int, int)
Keys() config.KeybindingConfig
CurrentContext() types.Context
// Whether the terminal's text cursor is currently shown
CursorVisible() bool
ContextForView(viewName string) types.Context
Fail(message string)
// These two log methods are for the sake of debugging while testing. There's no need to actually