From c840013ca3d80a4a587437a6ee7916b279e228bf Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 24 Aug 2026 09:43:43 +0200 Subject: [PATCH 01/19] Remove error return value from functions that always return nil Originally I thought we'd benefit from this change in this branch; turns out that we didn't after all, because we changed the approach, but it's a nice cleanup anyway, so we include it here. --- pkg/gocui/double_click_test.go | 4 ++-- pkg/gocui/gui.go | 8 ++------ pkg/gocui/mouse_capture_test.go | 14 +++++++------- pkg/gui/controllers/filter_controller.go | 3 ++- pkg/gui/controllers/helpers/search_helper.go | 19 ++++++++++--------- .../controllers/local_commits_controller.go | 3 ++- pkg/gui/controllers/main_view_controller.go | 3 ++- pkg/gui/controllers/search_controller.go | 7 ++++--- .../controllers/search_prompt_controller.go | 6 ++++-- pkg/gui/gui.go | 9 ++------- pkg/gui/gui_common.go | 4 ++-- pkg/gui/keybindings.go | 16 +++++----------- pkg/gui/menu_panel.go | 4 +--- pkg/gui/types/common.go | 2 +- 14 files changed, 46 insertions(+), 56 deletions(-) diff --git a/pkg/gocui/double_click_test.go b/pkg/gocui/double_click_test.go index b8d9f5f9c..9c73da1e9 100644 --- a/pkg/gocui/double_click_test.go +++ b/pkg/gocui/double_click_test.go @@ -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)), diff --git a/pkg/gocui/gui.go b/pkg/gocui/gui.go index b4cf107de..9d290c8ad 100644 --- a/pkg/gocui/gui.go +++ b/pkg/gocui/gui.go @@ -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 diff --git a/pkg/gocui/mouse_capture_test.go b/pkg/gocui/mouse_capture_test.go index eea1e3f9f..f335e65c4 100644 --- a/pkg/gocui/mouse_capture_test.go +++ b/pkg/gocui/mouse_capture_test.go @@ -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) diff --git a/pkg/gui/controllers/filter_controller.go b/pkg/gui/controllers/filter_controller.go index 358fb8ed5..b428c178a 100644 --- a/pkg/gui/controllers/filter_controller.go +++ b/pkg/gui/controllers/filter_controller.go @@ -44,5 +44,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 } diff --git a/pkg/gui/controllers/helpers/search_helper.go b/pkg/gui/controllers/helpers/search_helper.go index 51f510792..b0b948292 100644 --- a/pkg/gui/controllers/helpers/search_helper.go +++ b/pkg/gui/controllers/helpers/search_helper.go @@ -29,7 +29,7 @@ func NewSearchHelper( } } -func (self *SearchHelper) OpenFilterPrompt(context types.IFilterableContext) error { +func (self *SearchHelper) OpenFilterPrompt(context types.IFilterableContext) { state := self.searchState() state.PrevSearchIndex = -1 @@ -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) { @@ -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) { diff --git a/pkg/gui/controllers/local_commits_controller.go b/pkg/gui/controllers/local_commits_controller.go index 884cbc941..1e1a01427 100644 --- a/pkg/gui/controllers/local_commits_controller.go +++ b/pkg/gui/controllers/local_commits_controller.go @@ -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 { diff --git a/pkg/gui/controllers/main_view_controller.go b/pkg/gui/controllers/main_view_controller.go index 6eb6c86e3..5bde8c5ff 100644 --- a/pkg/gui/controllers/main_view_controller.go +++ b/pkg/gui/controllers/main_view_controller.go @@ -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 }) }) } diff --git a/pkg/gui/controllers/search_controller.go b/pkg/gui/controllers/search_controller.go index f1d5efe2a..f84539646 100644 --- a/pkg/gui/controllers/search_controller.go +++ b/pkg/gui/controllers/search_controller.go @@ -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 } diff --git a/pkg/gui/controllers/search_prompt_controller.go b/pkg/gui/controllers/search_prompt_controller.go index 1ce02abf0..4e6de85dc 100644 --- a/pkg/gui/controllers/search_prompt_controller.go +++ b/pkg/gui/controllers/search_prompt_controller.go @@ -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 { diff --git a/pkg/gui/gui.go b/pkg/gui/gui.go index bde383caf..1a3a1273e 100644 --- a/pkg/gui/gui.go +++ b/pkg/gui/gui.go @@ -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 diff --git a/pkg/gui/gui_common.go b/pkg/gui/gui_common.go index 692df5142..d693fd77f 100644 --- a/pkg/gui/gui_common.go +++ b/pkg/gui/gui_common.go @@ -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 { diff --git a/pkg/gui/keybindings.go b/pkg/gui/keybindings.go index c6ac2533e..5d03f6ba5 100644 --- a/pkg/gui/keybindings.go +++ b/pkg/gui/keybindings.go @@ -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 { diff --git a/pkg/gui/menu_panel.go b/pkg/gui/menu_panel.go index 0ddefdbee..1fb755c46 100644 --- a/pkg/gui/menu_panel.go +++ b/pkg/gui/menu_panel.go @@ -80,9 +80,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) diff --git a/pkg/gui/types/common.go b/pkg/gui/types/common.go index 92cc141cf..7ff43ff28 100644 --- a/pkg/gui/types/common.go +++ b/pkg/gui/types/common.go @@ -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) From b2a684bec1adb8e7fbf0bc9262ccf23741cea923 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 25 Aug 2026 08:52:05 +0200 Subject: [PATCH 02/19] Add a helper for recognizing printable keys Two places test for "a character the user typed" by hand, and a third one is about to be needed. Give the test a name. Co-authored-by: Claude Opus 5 (1M context) --- pkg/gocui/edit.go | 2 +- pkg/gocui/gui.go | 2 +- pkg/gocui/key.go | 6 ++++++ pkg/gocui/key_test.go | 16 ++++++++++++++++ 4 files changed, 24 insertions(+), 2 deletions(-) create mode 100644 pkg/gocui/key_test.go diff --git a/pkg/gocui/edit.go b/pkg/gocui/edit.go index 649379fb6..4263e0b5c 100644 --- a/pkg/gocui/edit.go +++ b/pkg/gocui/edit.go @@ -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 diff --git a/pkg/gocui/gui.go b/pkg/gocui/gui.go index 9d290c8ad..ba4c7e74e 100644 --- a/pkg/gocui/gui.go +++ b/pkg/gocui/gui.go @@ -2097,7 +2097,7 @@ func (g *Gui) matchView(v *View, kb *keybinding) bool { if v == nil { return false } - if v.Editable && kb.key.Str() != "" && kb.key.Mod() == 0 { + if v.Editable && kb.key.IsPrintable() { return false } if kb.viewName != v.name { diff --git a/pkg/gocui/key.go b/pkg/gocui/key.go index dd0a912a0..0eaa29b38 100644 --- a/pkg/gocui/key.go +++ b/pkg/gocui/key.go @@ -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 } diff --git a/pkg/gocui/key_test.go b/pkg/gocui/key_test.go new file mode 100644 index 000000000..8fce3fe53 --- /dev/null +++ b/pkg/gocui/key_test.go @@ -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()) +} From 28c5f5748ce68dac246cfb21bf720a68a07a42a9 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 25 Aug 2026 09:16:12 +0200 Subject: [PATCH 03/19] Give a parent view's keybindings the same precedence as a view's own When a key matches several bindings of the same view, the first one wins; when it matches several of the view's parent, the last one did. Co-authored-by: Claude Opus 5 (1M context) --- pkg/gocui/gui.go | 2 +- pkg/gocui/parent_view_test.go | 76 +++++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 pkg/gocui/parent_view_test.go diff --git a/pkg/gocui/gui.go b/pkg/gocui/gui.go index ba4c7e74e..15c454c88 100644 --- a/pkg/gocui/gui.go +++ b/pkg/gocui/gui.go @@ -1979,7 +1979,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 == "" { diff --git a/pkg/gocui/parent_view_test.go b/pkg/gocui/parent_view_test.go new file mode 100644 index 000000000..993eb08c1 --- /dev/null +++ b/pkg/gocui/parent_view_test.go @@ -0,0 +1,76 @@ +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 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) +} From 9e98b3d2f3316c96a18d4d949e319448d048be3a Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 25 Aug 2026 09:17:00 +0200 Subject: [PATCH 04/19] Let an editable view opt into receiving printable keys as keybindings Printable keys are withheld from keybindings while the user is typing in a field, so that they end up as text. Decide that from the field that has the focus rather than from the view a binding happens to be registered for: a field can be embedded in another view, and that view's keys must be withheld too, or its bindings would swallow the characters. That makes it worth honouring KeybindOnEdit, which has been documented but ignored ever since it was introduced. A field that sets it sees printable keys offered to the keybindings first, and still gets them if no binding handles them, which is what lets a view keep its keys until the field has something to type into. Co-authored-by: Claude Opus 5 (1M context) --- pkg/gocui/gui.go | 8 +++--- pkg/gocui/parent_view_test.go | 46 +++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/pkg/gocui/gui.go b/pkg/gocui/gui.go index 15c454c88..1bc184625 100644 --- a/pkg/gocui/gui.go +++ b/pkg/gocui/gui.go @@ -2091,13 +2091,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.IsPrintable() { + // 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 { diff --git a/pkg/gocui/parent_view_test.go b/pkg/gocui/parent_view_test.go index 993eb08c1..510acdbbb 100644 --- a/pkg/gocui/parent_view_test.go +++ b/pkg/gocui/parent_view_test.go @@ -56,6 +56,52 @@ func TestFirstMatchingKeybindingOfParentViewWins(t *testing.T) { assert.Equal(t, []string{"first"}, pressed) } +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) From f9ec7adb61c149a3fc9a90b9512e8d6f3664d8a7 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 25 Aug 2026 09:17:54 +0200 Subject: [PATCH 05/19] Draw embedded views as one focused unit A view can only be drawn with the focused frame and title colors while it is the current view, but a panel made of an outer view and an editable field embedded in it has to look focused as a whole, whichever of the two the keyboard is pointed at. Co-authored-by: Claude Opus 5 (1M context) --- pkg/gocui/gui.go | 16 +++++++++++++++- pkg/gocui/parent_view_test.go | 20 ++++++++++++++++++++ pkg/gocui/view.go | 4 +++- 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/pkg/gocui/gui.go b/pkg/gocui/gui.go index 1bc184625..67fbd1aa2 100644 --- a/pkg/gocui/gui.go +++ b/pkg/gocui/gui.go @@ -1615,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 { @@ -1639,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 diff --git a/pkg/gocui/parent_view_test.go b/pkg/gocui/parent_view_test.go index 510acdbbb..9d56a1c46 100644 --- a/pkg/gocui/parent_view_test.go +++ b/pkg/gocui/parent_view_test.go @@ -56,6 +56,26 @@ func TestFirstMatchingKeybindingOfParentViewWins(t *testing.T) { 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 diff --git a/pkg/gocui/view.go b/pkg/gocui/view.go index dba71ab52..6e7b35520 100644 --- a/pkg/gocui/view.go +++ b/pkg/gocui/view.go @@ -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 From 45d68bccc21621f9ec0c4caf74737c1aaf398e2b Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 25 Aug 2026 09:18:38 +0200 Subject: [PATCH 06/19] Treat the views of a popup panel as a group when clicking The check was a single set of view names, so it also let a click move between two different panels, e.g. from the prompt to the commit message. List the panels instead, and require both views to be in the same one. Co-authored-by: Claude Opus 5 (1M context) --- pkg/gui/gui.go | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/pkg/gui/gui.go b/pkg/gui/gui.go index 1a3a1273e..e10705748 100644 --- a/pkg/gui/gui.go +++ b/pkg/gui/gui.go @@ -922,6 +922,20 @@ 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"}, +} + +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) @@ -937,15 +951,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 } } From a9fe055af4e7b502de3b05a1fee57b1e5f11a4a8 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 25 Aug 2026 09:19:02 +0200 Subject: [PATCH 07/19] Extract applying a filter to a context A filter can come from somewhere other than the search prompt: a menu that filters as you type has its own input field, and needs to apply what is typed there without going through the prompt's state. Co-authored-by: Claude Opus 5 (1M context) --- pkg/gui/controllers/helpers/search_helper.go | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/pkg/gui/controllers/helpers/search_helper.go b/pkg/gui/controllers/helpers/search_helper.go index b0b948292..5de08df6d 100644 --- a/pkg/gui/controllers/helpers/search_helper.go +++ b/pkg/gui/controllers/helpers/search_helper.go @@ -225,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: @@ -235,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 { From f1b03471114dae90613bf3d830b34e837cc1cf7d Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 25 Aug 2026 09:19:34 +0200 Subject: [PATCH 08/19] Allow a list to render its footer elsewhere The footer is drawn on the bottom border of the list's view, which is not always a free row: a panel that puts something else below the list shares that border with it, and has to render the footer there instead. Co-authored-by: Claude Opus 5 (1M context) --- pkg/gui/context/list_context_trait.go | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/pkg/gui/context/list_context_trait.go b/pkg/gui/context/list_context_trait.go index 9ab24cf9b..77e071991 100644 --- a/pkg/gui/context/list_context_trait.go +++ b/pkg/gui/context/list_context_trait.go @@ -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 { From c80035d7ee3e94855c87fdcf396030f699bb7ea5 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 25 Aug 2026 09:21:06 +0200 Subject: [PATCH 09/19] Add the views for a menu's filter row Nothing shows or positions them yet. The row is two views because the input field has to start after the "Filter:" prompt, and a gocui view is a rectangle: the frame view draws the row and the prompt, the field sits inside it. Co-authored-by: Claude Opus 5 (1M context) --- pkg/gui/types/views.go | 2 ++ pkg/gui/views.go | 29 +++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/pkg/gui/types/views.go b/pkg/gui/types/views.go index c740ccb2e..1a48d170a 100644 --- a/pkg/gui/types/views.go +++ b/pkg/gui/types/views.go @@ -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 diff --git a/pkg/gui/views.go b/pkg/gui/views.go index b47e76c5a..895260fd9 100644 --- a/pkg/gui/views.go +++ b/pkg/gui/views.go @@ -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,14 @@ 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 + // 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 +169,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 +204,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 From 1fb5f87d05b65c25d853cd5b3939359a445f8919 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 25 Aug 2026 09:24:21 +0200 Subject: [PATCH 10/19] Lay out the filter row of a menu that filters as you type The row is reserved for as long as such a menu is open, even while it is still hidden, so that it can appear without moving the menu. That costs two rows of the popup, which is why the screen has to be a little taller before a menu is worth showing at all. The prompt in front of the input field is dropped when the row gets too narrow to type in, and the keybindings menu says what '@' does when it still fits. Co-authored-by: Claude Opus 5 (1M context) --- pkg/gui/context/menu_context.go | 33 +++++++++++ .../helpers/confirmation_helper.go | 58 ++++++++++++++++++- .../helpers/confirmation_helper_test.go | 31 ++++++++++ pkg/gui/layout.go | 31 ++++++++-- pkg/gui/layout_test.go | 14 +++++ pkg/gui/menu_panel.go | 4 ++ pkg/gui/types/common.go | 5 ++ 7 files changed, 167 insertions(+), 9 deletions(-) create mode 100644 pkg/gui/controllers/helpers/confirmation_helper_test.go create mode 100644 pkg/gui/layout_test.go diff --git a/pkg/gui/context/menu_context.go b/pkg/gui/context/menu_context.go index 8129aa420..4b393169d 100644 --- a/pkg/gui/context/menu_context.go +++ b/pkg/gui/context/menu_context.go @@ -45,6 +45,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 +65,8 @@ type MenuViewModel struct { columnAlignment []utils.Alignment allowFilteringKeybindings bool keybindingsTakePrecedence bool + filterAsYouType bool + filterStarted bool onCancel func() error *FilteredListViewModel[*types.MenuItem] } @@ -128,10 +137,34 @@ 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 +} + +func (self *MenuViewModel) FilterStarted() bool { + return self.filterStarted +} + // TODO: move into presentation package func (self *MenuViewModel) GetDisplayStrings(_ int, _ int) [][]string { menuItems := self.FilteredListViewModel.GetItems() diff --git a/pkg/gui/controllers/helpers/confirmation_helper.go b/pkg/gui/controllers/helpers/confirmation_helper.go index beffeb5e2..f525c1d8a 100644 --- a/pkg/gui/controllers/helpers/confirmation_helper.go +++ b/pkg/gui/controllers/helpers/confirmation_helper.go @@ -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 { diff --git a/pkg/gui/controllers/helpers/confirmation_helper_test.go b/pkg/gui/controllers/helpers/confirmation_helper_test.go new file mode 100644 index 000000000..fc62722d0 --- /dev/null +++ b/pkg/gui/controllers/helpers/confirmation_helper_test.go @@ -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)) + }) + } +} diff --git a/pkg/gui/layout.go b/pkg/gui/layout.go index 67e695f2b..c845d8074 100644 --- a/pkg/gui/layout.go +++ b/pkg/gui/layout.go @@ -144,15 +144,14 @@ 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 gui.Views.Tooltip.Visible = gui.Views.Menu.Visible && gui.Views.Tooltip.Buffer() != "" for _, context := range gui.transientContexts() { @@ -229,6 +228,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 diff --git a/pkg/gui/layout_test.go b/pkg/gui/layout_test.go new file mode 100644 index 000000000..1495bcd49 --- /dev/null +++ b/pkg/gui/layout_test.go @@ -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)) +} diff --git a/pkg/gui/menu_panel.go b/pkg/gui/menu_panel.go index 1fb755c46..1bfdb4581 100644 --- a/pkg/gui/menu_panel.go +++ b/pkg/gui/menu_panel.go @@ -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 diff --git a/pkg/gui/types/common.go b/pkg/gui/types/common.go index 7ff43ff28..58ccf6d60 100644 --- a/pkg/gui/types/common.go +++ b/pkg/gui/types/common.go @@ -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 { From fe9b990c4c7c307a9c7d780ecb9f43d2aeb79f72 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 25 Aug 2026 09:32:43 +0200 Subject: [PATCH 11/19] Filter a menu by typing into it The filter input is where the keyboard points for as long as such a menu is open, so that the first printable key can go straight into it. The menu still gets every key the input doesn't take, because the input view is embedded in the menu view, and the two are drawn as one focused panel. Which keys the input takes changes once there is a filter: until then printable keys still drive the menu, so that the configured navigation keys work as usual, and afterwards they are all filter text. A menu item's own keys are never bound in such a menu, because typing one has to reach the filter rather than execute the item. Escape gives up the filter and leaves the menu open; the next one closes it. The filter prompt behind '/' is gone from these menus: the row already does that job, and a second filter would only be confusing. Co-authored-by: Claude Opus 5 (1M context) --- pkg/gui/context.go | 5 +++-- pkg/gui/context/base_context.go | 4 ++++ pkg/gui/context/menu_context.go | 24 ++++++++++++++++++++++ pkg/gui/controllers/filter_controller.go | 10 +++++++++ pkg/gui/controllers/menu_controller.go | 16 +++++++++++++++ pkg/gui/editors.go | 26 ++++++++++++++++++++++++ pkg/gui/gui.go | 1 + pkg/gui/layout.go | 11 ++++++++++ pkg/gui/types/context.go | 4 ++++ pkg/gui/view_helpers.go | 2 +- pkg/gui/views.go | 2 ++ pkg/i18n/english.go | 2 ++ 12 files changed, 104 insertions(+), 3 deletions(-) diff --git a/pkg/gui/context.go b/pkg/gui/context.go index 1adfec35c..cd959274c 100644 --- a/pkg/gui/context.go +++ b/pkg/gui/context.go @@ -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) } diff --git a/pkg/gui/context/base_context.go b/pkg/gui/context/base_context.go index 67b4654a6..51d2473c3 100644 --- a/pkg/gui/context/base_context.go +++ b/pkg/gui/context/base_context.go @@ -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 } diff --git a/pkg/gui/context/menu_context.go b/pkg/gui/context/menu_context.go index 4b393169d..63439bc67 100644 --- a/pkg/gui/context/menu_context.go +++ b/pkg/gui/context/menu_context.go @@ -159,6 +159,9 @@ func (self *MenuViewModel) FilterAsYouType() bool { // 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 { @@ -242,6 +245,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 }) @@ -300,6 +313,17 @@ func (self *MenuContext) RangeSelectEnabled() bool { return false } +// 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.GetViewName() +} + func (self *MenuContext) FilterPrefix(tr *i18n.TranslationSet) string { if self.allowFilteringKeybindings { return tr.FilterPrefixMenu diff --git a/pkg/gui/controllers/filter_controller.go b/pkg/gui/controllers/filter_controller.go index b428c178a..830a5bbc6 100644 --- a/pkg/gui/controllers/filter_controller.go +++ b/pkg/gui/controllers/filter_controller.go @@ -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), diff --git a/pkg/gui/controllers/menu_controller.go b/pkg/gui/controllers/menu_controller.go index 283c2bbdf..9966d6c68 100644 --- a/pkg/gui/controllers/menu_controller.go +++ b/pkg/gui/controllers/menu_controller.go @@ -73,6 +73,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 +86,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 } diff --git a/pkg/gui/editors.go b/pkg/gui/editors.go index 37eacf416..7c658265b 100644 --- a/pkg/gui/editors.go +++ b/pkg/gui/editors.go @@ -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() diff --git a/pkg/gui/gui.go b/pkg/gui/gui.go index e10705748..4de37fec2 100644 --- a/pkg/gui/gui.go +++ b/pkg/gui/gui.go @@ -928,6 +928,7 @@ func (gui *Gui) viewTabMap() map[string][]context.TabView { var popupPanelViewGroups = [][]string{ {"commitMessage", "commitDescription"}, {"prompt", "suggestions"}, + {"menu", "menuFilterFrame", "menuFilter"}, } func viewsBelongToSamePopupPanel(viewName string, otherViewName string) bool { diff --git a/pkg/gui/layout.go b/pkg/gui/layout.go index c845d8074..ccc83b1e9 100644 --- a/pkg/gui/layout.go +++ b/pkg/gui/layout.go @@ -152,6 +152,17 @@ func (gui *Gui) layout(g *gocui.Gui) error { 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() { diff --git a/pkg/gui/types/context.go b/pkg/gui/types/context.go index 128c4f08f..e8b33a7d8 100644 --- a/pkg/gui/types/context.go +++ b/pkg/gui/types/context.go @@ -57,6 +57,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 diff --git a/pkg/gui/view_helpers.go b/pkg/gui/view_helpers.go index 4e14e6c48..2151a5692 100644 --- a/pkg/gui/view_helpers.go +++ b/pkg/gui/view_helpers.go @@ -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 diff --git a/pkg/gui/views.go b/pkg/gui/views.go index 895260fd9..7b6fa93eb 100644 --- a/pkg/gui/views.go +++ b/pkg/gui/views.go @@ -148,6 +148,8 @@ func (gui *Gui) createAllViews() error { 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 diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index 6d41d31d6..896c40670 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -879,6 +879,7 @@ type TranslationSet struct { SearchPrefix string FilterPrefix string FilterPrefixMenu string + MenuFilterHint string ExitSearchMode string ExitTextFilterMode string Switch string @@ -2040,6 +2041,7 @@ func EnglishTranslationSet() *TranslationSet { SearchPrefix: "Search: ", FilterPrefix: "Filter: ", FilterPrefixMenu: "Filter (prepend '@' to filter keybindings): ", + MenuFilterHint: "(Type to filter)", WorktreesTitle: "Worktrees", WorktreeTitle: "Worktree", Switch: "Switch", From 2048c7f0a69620853b43c292a290ad291e6b8f8a Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 25 Aug 2026 09:34:24 +0200 Subject: [PATCH 12/19] Keep a filtering menu navigable whatever the keybindings are The keys for paging through a menu are ',' and '.' by default, and there is no non-printable alternative for them, so a menu that filters as you type would lose paging altogether as soon as the user typed anything. The same goes for confirming and cancelling if those keys are configured as printable ones. So bind the physical keys for all of it, on top of whatever is configured, and only where they aren't the configured keys anyway. Co-authored-by: Claude Opus 5 (1M context) --- pkg/gui/controllers.go | 11 +++++- pkg/gui/controllers/menu_controller.go | 54 +++++++++++++++++++++++++- 2 files changed, 62 insertions(+), 3 deletions(-) diff --git a/pkg/gui/controllers.go b/pkg/gui/controllers.go index a7eb35919..f21fb607f 100644 --- a/pkg/gui/controllers.go +++ b/pkg/gui/controllers.go @@ -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)) } } diff --git a/pkg/gui/controllers/menu_controller.go b/pkg/gui/controllers/menu_controller.go index 9966d6c68..57df08dac 100644 --- a/pkg/gui/controllers/menu_controller.go +++ b/pkg/gui/controllers/menu_controller.go @@ -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,7 +30,8 @@ func NewMenuController( c.Contexts().Menu.GetSelected, c.Contexts().Menu.GetSelectedItems, ), - c: c, + c: c, + listController: listController, } } @@ -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 } From 68355862c1162f386aede13b676099c9b9b6bc73 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 25 Aug 2026 09:36:03 +0200 Subject: [PATCH 13/19] Add test helpers for a menu's filter row Its footer, the hint in the menu's subtitle, where the row sits in relation to the menu and the tooltip, and whether the text cursor is showing are all things the tests for it need to look at. Co-authored-by: Claude Opus 5 (1M context) --- pkg/gui/gui_driver.go | 4 ++ pkg/integration/components/test_driver.go | 18 +++++++++ pkg/integration/components/test_test.go | 4 ++ pkg/integration/components/view_driver.go | 47 +++++++++++++++++++++++ pkg/integration/components/views.go | 8 ++++ pkg/integration/types/types.go | 2 + 6 files changed, 83 insertions(+) diff --git a/pkg/gui/gui_driver.go b/pkg/gui/gui_driver.go index b922f0705..07a0b7d4c 100644 --- a/pkg/gui/gui_driver.go +++ b/pkg/gui/gui_driver.go @@ -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 { diff --git a/pkg/integration/components/test_driver.go b/pkg/integration/components/test_driver.go index afd55a845..bd5bbfc24 100644 --- a/pkg/integration/components/test_driver.go +++ b/pkg/integration/components/test_driver.go @@ -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() { diff --git a/pkg/integration/components/test_test.go b/pkg/integration/components/test_test.go index 2495a07c5..cce96105a 100644 --- a/pkg/integration/components/test_test.go +++ b/pkg/integration/components/test_test.go @@ -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 } diff --git a/pkg/integration/components/view_driver.go b/pkg/integration/components/view_driver.go index b743afde7..102a8562a 100644 --- a/pkg/integration/components/view_driver.go +++ b/pkg/integration/components/view_driver.go @@ -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) diff --git a/pkg/integration/components/views.go b/pkg/integration/components/views.go index 90795d942..5c91b6937 100644 --- a/pkg/integration/components/views.go +++ b/pkg/integration/components/views.go @@ -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") } diff --git a/pkg/integration/types/types.go b/pkg/integration/types/types.go index db9068ad9..325f4ea38 100644 --- a/pkg/integration/types/types.go +++ b/pkg/integration/types/types.go @@ -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 From fb761892aa7a2b57cc2906589c8ab7aefc481786 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 25 Aug 2026 09:48:36 +0200 Subject: [PATCH 14/19] Filter the keybindings menu as you type Looking up a keybinding is a search, so the menu that lists them is the one that most wants this. Its items do have keys, but only as a reminder of what they do outside the menu, so nothing is lost by not binding them. The prompt in front of the input field says what '@' does. It only ever showed up while the user was typing in the search prompt, so it could afford to be wordy; on a row that is on screen for as long as the menu is, it can't. Co-authored-by: Claude Opus 5 (1M context) --- pkg/gui/controllers/options_menu_action.go | 1 + pkg/i18n/english.go | 2 +- pkg/integration/components/menu_driver.go | 6 +- .../filter_menu_as_you_type.go | 94 +++++++++++++++++++ .../filter_menu_key_handling.go | 71 ++++++++++++++ .../filter_menu_with_printable_keybindings.go | 66 +++++++++++++ pkg/integration/tests/test_list.go | 3 + pkg/integration/tests/ui/empty_menu.go | 11 ++- 8 files changed, 248 insertions(+), 6 deletions(-) create mode 100644 pkg/integration/tests/filter_and_search/filter_menu_as_you_type.go create mode 100644 pkg/integration/tests/filter_and_search/filter_menu_key_handling.go create mode 100644 pkg/integration/tests/filter_and_search/filter_menu_with_printable_keybindings.go 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() From bb7e74968bc45c02b1ca646dfab2916513591017 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 25 Aug 2026 09:49:39 +0200 Subject: [PATCH 15/19] Drop the menu-specific wording for the filter prompt The only menu that ever asked for it was the keybindings menu, which now filters as you type and doesn't use the prompt at all. That leaves every filterable context with the same prompt, so the whole hook can go, and with it the two implementations that only existed to satisfy it. Co-authored-by: Claude Opus 5 (1M context) --- pkg/gui/context/filtered_list_view_model.go | 7 ------- pkg/gui/context/menu_context.go | 9 --------- pkg/gui/controllers/helpers/search_helper.go | 4 ++-- pkg/gui/controllers/helpers/window_arrangement_helper.go | 4 ++-- pkg/gui/filetree/commit_file_tree_view_model.go | 5 ----- pkg/gui/filetree/file_tree_view_model.go | 5 ----- pkg/gui/types/context.go | 2 -- 7 files changed, 4 insertions(+), 32 deletions(-) diff --git a/pkg/gui/context/filtered_list_view_model.go b/pkg/gui/context/filtered_list_view_model.go index ce2f8ac36..2c2841964 100644 --- a/pkg/gui/context/filtered_list_view_model.go +++ b/pkg/gui/context/filtered_list_view_model.go @@ -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 -} diff --git a/pkg/gui/context/menu_context.go b/pkg/gui/context/menu_context.go index 63439bc67..55a3e5bfa 100644 --- a/pkg/gui/context/menu_context.go +++ b/pkg/gui/context/menu_context.go @@ -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" ) @@ -323,11 +322,3 @@ func (self *MenuContext) GetInputViewName() string { return self.GetViewName() } - -func (self *MenuContext) FilterPrefix(tr *i18n.TranslationSet) string { - if self.allowFilteringKeybindings { - return tr.FilterPrefixMenu - } - - return self.FilteredListViewModel.FilterPrefix(tr) -} diff --git a/pkg/gui/controllers/helpers/search_helper.go b/pkg/gui/controllers/helpers/search_helper.go index 5de08df6d..96c4c35ba 100644 --- a/pkg/gui/controllers/helpers/search_helper.go +++ b/pkg/gui/controllers/helpers/search_helper.go @@ -36,7 +36,7 @@ func (self *SearchHelper) OpenFilterPrompt(context types.IFilterableContext) { 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("") @@ -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 diff --git a/pkg/gui/controllers/helpers/window_arrangement_helper.go b/pkg/gui/controllers/helpers/window_arrangement_helper.go index 90a651809..5379e9c09 100644 --- a/pkg/gui/controllers/helpers/window_arrangement_helper.go +++ b/pkg/gui/controllers/helpers/window_arrangement_helper.go @@ -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 } diff --git a/pkg/gui/filetree/commit_file_tree_view_model.go b/pkg/gui/filetree/commit_file_tree_view_model.go index a58f7d93e..a59bcb01a 100644 --- a/pkg/gui/filetree/commit_file_tree_view_model.go +++ b/pkg/gui/filetree/commit_file_tree_view_model.go @@ -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 } diff --git a/pkg/gui/filetree/file_tree_view_model.go b/pkg/gui/filetree/file_tree_view_model.go index a5971f592..68829b444 100644 --- a/pkg/gui/filetree/file_tree_view_model.go +++ b/pkg/gui/filetree/file_tree_view_model.go @@ -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 } diff --git a/pkg/gui/types/context.go b/pkg/gui/types/context.go index e8b33a7d8..2ef798a34 100644 --- a/pkg/gui/types/context.go +++ b/pkg/gui/types/context.go @@ -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" ) @@ -140,7 +139,6 @@ type IFilterableContext interface { ReApplyFilter(bool) IsFiltering() bool IsFilterableContext() - FilterPrefix(tr *i18n.TranslationSet) string } type ISearchableContext interface { From 4116a15dae1e9da71254ff6b5b0cc5bff5b80ca5 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 25 Aug 2026 09:51:16 +0200 Subject: [PATCH 16/19] Filter the recent repositories menu as you type Picking a repository out of that list is the other place where the menu is a list to search rather than a set of commands, and its items have no keys that typing could clash with. Co-authored-by: Claude Opus 5 (1M context) --- pkg/gui/controllers/helpers/repos_helper.go | 6 ++- .../tests/misc/filter_recent_repos.go | 37 +++++++++++++++++++ pkg/integration/tests/test_list.go | 1 + 3 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 pkg/integration/tests/misc/filter_recent_repos.go diff --git a/pkg/gui/controllers/helpers/repos_helper.go b/pkg/gui/controllers/helpers/repos_helper.go index d3b6bfe29..c8a33bcbe 100644 --- a/pkg/gui/controllers/helpers/repos_helper.go +++ b/pkg/gui/controllers/helpers/repos_helper.go @@ -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 diff --git a/pkg/integration/tests/misc/filter_recent_repos.go b/pkg/integration/tests/misc/filter_recent_repos.go new file mode 100644 index 000000000..a54ca51db --- /dev/null +++ b/pkg/integration/tests/misc/filter_recent_repos.go @@ -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")) + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 7bc50fe18..5ac4bdcba 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -360,6 +360,7 @@ var tests = []*components.IntegrationTest{ misc.DirenvApprovesEnvrc, misc.DirenvLoadedOnRepoSwitch, misc.DirenvUnloadsOnBlockedEnvrc, + misc.FilterRecentRepos, misc.InitialOpen, misc.RecentReposOnLaunch, misc.StartInGitDir, From 8aa57264fa846bdbccf3c6668e1bcdb1705250d8 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 25 Aug 2026 09:51:34 +0200 Subject: [PATCH 17/19] Point the note about the menu's essential keys at what it means There is no `reservedKeys` any more; the list of keys that menu items must not shadow is `essentialKeys` in the function that creates the menu. Co-authored-by: Claude Opus 5 (1M context) --- pkg/gui/controllers/menu_controller.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/gui/controllers/menu_controller.go b/pkg/gui/controllers/menu_controller.go index 57df08dac..1bbb7b27f 100644 --- a/pkg/gui/controllers/menu_controller.go +++ b/pkg/gui/controllers/menu_controller.go @@ -36,7 +36,7 @@ func NewMenuController( } // 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{ { From 4f78a5576a4fd11da39a5934755d4accb2ce73f0 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 31 Aug 2026 20:58:25 +0200 Subject: [PATCH 18/19] Reword stale comment about filtering not being available in the files view This was implemented quite a while ago. --- docs-master/Searching.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs-master/Searching.md b/docs-master/Searching.md index 589831c55..dbd23612f 100644 --- a/docs-master/Searching.md +++ b/docs-master/Searching.md @@ -4,7 +4,7 @@ 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. From 90f5348371e6e4bee66c144f1b3b0e3b7d0c28e2 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 31 Aug 2026 20:58:40 +0200 Subject: [PATCH 19/19] Add a hint about filtering menus to the docs --- docs-master/Searching.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs-master/Searching.md b/docs-master/Searching.md index dbd23612f..4cba775df 100644 --- a/docs-master/Searching.md +++ b/docs-master/Searching.md @@ -8,6 +8,10 @@ In the commits view we don't filter, but search; this is deliberate because you 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 `` in the files view.