jesseduffield.lazygit/pkg/gui/context/menu_context.go
Stefan Haller f113736aac Truncate the repo and branch names in the recent repos menu
Each column of a menu is padded to the width of its widest entry, so a
single long name in the first two columns of the recent repos menu
pushes the path column off the right edge for every entry. Anyone whose
worktree directories are named after their branches hits this: with a
90 column menu and one 42 character name, the path column starts at
column 86 of 88.

Truncate both names to 30 characters, and put the ones that got
truncated into the item's tooltip, so that the full text is still on
screen for the selected entry. Filtering keeps matching the full names
and the full path, which the columns no longer show in their entirety.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 18:38:57 +02:00

329 lines
9.6 KiB
Go

package context
import (
"errors"
"strings"
"github.com/jesseduffield/lazygit/pkg/config"
"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/utils"
"github.com/samber/lo"
)
type MenuContext struct {
c *ContextCommon
*MenuViewModel
*ListContextTrait
}
var _ types.IListContext = (*MenuContext)(nil)
func NewMenuContext(
c *ContextCommon,
) *MenuContext {
viewModel := NewMenuViewModel(c)
return &MenuContext{
c: c,
MenuViewModel: viewModel,
ListContextTrait: &ListContextTrait{
Context: NewSimpleContext(NewBaseContext(NewBaseContextOpts{
View: c.Views().Menu,
WindowName: "menu",
Key: "menu",
Kind: types.TEMPORARY_POPUP,
Focusable: true,
HasUncontrolledBounds: true,
})),
ListRenderer: ListRenderer{
list: viewModel,
getDisplayStrings: viewModel.GetDisplayStrings,
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,
},
}
}
type MenuViewModel struct {
c *ContextCommon
menuItems []*types.MenuItem
prompt string
promptLines []string
columnAlignment []utils.Alignment
allowFilteringKeybindings bool
keybindingsTakePrecedence bool
filterAsYouType bool
filterStarted bool
onCancel func() error
*FilteredListViewModel[*types.MenuItem]
}
func NewMenuViewModel(c *ContextCommon) *MenuViewModel {
self := &MenuViewModel{
menuItems: nil,
c: c,
}
filterKeybindings := false
self.FilteredListViewModel = NewFilteredListViewModel(
func() []*types.MenuItem { return self.menuItems },
func(item *types.MenuItem) []string {
if filterKeybindings {
// Allow searching all configured keybindings of each item, even though only the
// first one is shown in the menu.
return lo.Map(item.Keys, func(k gocui.Key, _ int) string {
return config.LabelForKey(k)
})
}
if item.FilterColumns != nil {
return item.FilterColumns
}
return item.LabelColumns
},
)
self.FilteredListViewModel.SetPreprocessFilterFunc(func(filter string) string {
if self.allowFilteringKeybindings && strings.HasPrefix(filter, "@") {
filterKeybindings = true
return filter[1:]
}
filterKeybindings = false
return filter
})
return self
}
func (self *MenuViewModel) SetMenuItems(items []*types.MenuItem, columnAlignment []utils.Alignment) {
self.menuItems = items
self.columnAlignment = columnAlignment
}
func (self *MenuViewModel) SetOnCancel(onCancel func() error) {
self.onCancel = onCancel
}
func (self *MenuViewModel) GetPrompt() string {
return self.prompt
}
func (self *MenuViewModel) SetPrompt(prompt string) {
self.prompt = prompt
self.promptLines = nil
}
func (self *MenuViewModel) GetPromptLines() []string {
return self.promptLines
}
func (self *MenuViewModel) SetPromptLines(promptLines []string) {
self.promptLines = promptLines
}
func (self *MenuViewModel) SetAllowFilteringKeybindings(allow bool) {
self.allowFilteringKeybindings = allow
}
func (self *MenuViewModel) AllowFilteringKeybindings() bool {
return self.allowFilteringKeybindings
}
func (self *MenuViewModel) SetKeybindingsTakePrecedence(value bool) {
self.keybindingsTakePrecedence = value
}
// Whether this menu has a filter row that filters the items as the user types,
// instead of being filtered through the search prompt.
func (self *MenuViewModel) SetFilterAsYouType(value bool) {
self.filterAsYouType = value
self.SetFilterStarted(false)
}
func (self *MenuViewModel) FilterAsYouType() bool {
return self.filterAsYouType
}
// Whether the user has started to filter, which is when the filter row appears.
func (self *MenuViewModel) SetFilterStarted(value bool) {
self.filterStarted = value
// As long as there is nothing to type into, printable keys keep driving the
// menu, so that the configured navigation keys work like in any other menu.
self.c.Views().MenuFilter.KeybindOnEdit = !value
}
func (self *MenuViewModel) FilterStarted() bool {
return self.filterStarted
}
// TODO: move into presentation package
func (self *MenuViewModel) GetDisplayStrings(_ int, _ int) [][]string {
menuItems := self.FilteredListViewModel.GetItems()
return lo.Map(menuItems, func(item *types.MenuItem, _ int) []string {
displayStrings := item.LabelColumns
if item.DisabledReason != nil {
displayStrings[0] = style.FgDefault.SetStrikethrough().Sprint(displayStrings[0])
}
keyLabel := ""
if len(item.Keys) > 0 {
keyLabel = style.FgCyan.Sprint(config.LabelForKey(item.Keys[0]))
}
checkMark := ""
switch item.Widget {
case types.MenuWidgetNone:
// do nothing
case types.MenuWidgetRadioButtonSelected:
checkMark = "(•)"
case types.MenuWidgetRadioButtonUnselected:
checkMark = "( )"
case types.MenuWidgetCheckboxSelected:
checkMark = "[✓]"
case types.MenuWidgetCheckboxUnselected:
checkMark = "[ ]"
}
displayStrings = utils.Prepend(displayStrings, keyLabel, checkMark)
return displayStrings
})
}
func (self *MenuViewModel) GetNonModelItems() []*NonModelItem {
result := []*NonModelItem{}
result = append(result, lo.Map(self.promptLines, func(line string, _ int) *NonModelItem {
return &NonModelItem{
Index: 0,
Column: 0,
Content: line,
}
})...)
// Don't display section headers when we are filtering, and the filter mode
// is fuzzy. The reason is that filtering changes the order of the items
// (they are sorted by best match), so all the sections would be messed up.
if self.FilteredListViewModel.IsFiltering() && self.c.UserConfig().Gui.UseFuzzySearch() {
return result
}
menuItems := self.FilteredListViewModel.GetItems()
var prevSection *types.MenuSection
for i, menuItem := range menuItems {
if menuItem.Section != nil && menuItem.Section != prevSection {
if prevSection != nil {
result = append(result, &NonModelItem{
Index: i,
Column: 1,
Content: "",
})
}
result = append(result, &NonModelItem{
Index: i,
Column: 1,
Content: style.FgGreen.SetBold().Sprint(formatListSectionHeader(menuItem.Section.Title)),
})
prevSection = menuItem.Section
}
}
return result
}
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
})
menuItemBindings := lo.Map(menuItemsWithKeys, func(item *types.MenuItem, _ int) *types.Binding {
return &types.Binding{
Keys: item.Keys,
Handler: func() error { return self.OnMenuPress(item) },
}
})
if self.keybindingsTakePrecedence {
// This is used for all normal menus except the keybindings menu. In this case we want the
// bindings of the menu items to have higher precedence than the builtin bindings; this
// allows assigning a keybinding to a menu item that overrides a non-essential binding such
// as 'j', 'k', 'H', 'L', etc. This is safe to do because the essential bindings such as
// confirm and return have already been removed from the menu items in this case.
return append(menuItemBindings, basicBindings...)
}
// For the keybindings menu we didn't remove the essential bindings from the menu items, because
// it is important to see all bindings (as a cheat sheet for what the keys are when the menu is
// not open). Therefore we want the essential bindings to have higher precedence than the menu
// item bindings.
return append(basicBindings, menuItemBindings...)
}
func (self *MenuContext) OnMenuPress(selectedItem *types.MenuItem) error {
if selectedItem != nil && selectedItem.DisabledReason != nil {
if selectedItem.DisabledReason.ShowErrorInPanel {
return errors.New(selectedItem.DisabledReason.Text)
}
self.c.ErrorToast(self.c.Tr.DisabledMenuItemPrefix + selectedItem.DisabledReason.Text)
return nil
}
self.c.Context().Pop()
if selectedItem == nil {
if self.onCancel != nil {
return self.onCancel()
}
return nil
}
if err := selectedItem.OnPress(); err != nil {
return err
}
return nil
}
// There is currently no need to use range-select in a menu so we're disabling it.
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()
}