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>
This commit is contained in:
Stefan Haller 2026-09-07 18:38:57 +02:00
parent b11651820a
commit fd5c703e1e
6 changed files with 132 additions and 27 deletions

View file

@ -89,6 +89,10 @@ func NewMenuViewModel(c *ContextCommon) *MenuViewModel {
})
}
if item.FilterColumns != nil {
return item.FilterColumns
}
return item.LabelColumns
},
)

View file

@ -103,6 +103,76 @@ func (self *ReposHelper) getCurrentBranch(path string) string {
return self.c.Tr.BranchUnknown
}
// The maximum width of the repo name and branch name columns of the recent
// repos menu. Without a limit, one long name pushes the path column off the
// right edge of the menu for every entry, because each column is padded to the
// width of its widest entry.
const recentReposColumnMaxWidth = 30
func (self *ReposHelper) recentRepoMenuItem(path string, branchName string) *types.MenuItem {
repoName := filepath.Base(path)
displayedRepoName := utils.TruncateWithEllipsis(repoName, recentReposColumnMaxWidth)
// The icon is part of the column, so it counts towards the maximum width.
branchColumn := branchName
if icons.IsIconEnabled() {
branchColumn = icons.BRANCH_ICON + " " + branchName
}
displayedBranchColumn := utils.TruncateWithEllipsis(branchColumn, recentReposColumnMaxWidth)
// Spell out whatever the columns show in truncated form
type tooltipField struct {
label string
value string
}
fields := []tooltipField{}
addTooltipField := func(label string, value string) {
fields = append(fields, tooltipField{label: label, value: value})
}
if displayedRepoName != repoName {
addTooltipField(self.c.Tr.RecentReposRepoLabel, repoName)
}
if displayedBranchColumn != branchColumn {
addTooltipField(self.c.Tr.RecentReposBranchLabel, branchName)
}
// Line the values up behind the widest of the labels that are there
labelWidth := utils.MaxFn(fields, func(field tooltipField) int {
return utils.StringWidth(field.label)
})
tooltipLines := lo.Map(fields, func(field tooltipField, _ int) string {
return utils.WithPadding(field.label, labelWidth, utils.AlignLeft) + " " + field.value
})
return &types.MenuItem{
LabelColumns: []string{
displayedRepoName,
style.FgCyan.Sprint(displayedBranchColumn),
// The last segment of the path is already in the first column, so
// showing the directory that contains the repo is enough to tell
// repos with the same name apart.
style.FgMagenta.Sprint(utils.ContractTilde(filepath.Dir(path))),
},
// Filtering matches the full text, including the parts that the columns
// above truncate or leave out.
FilterColumns: []string{repoName, branchName, path},
Tooltip: strings.Join(tooltipLines, "\n"),
OnPress: func() error {
// Check before clearing the stack, so a refused switch doesn't
// forget the submodule breadcrumb (which would leave escape
// unable to return to the parent repo).
if self.switchRefusedBecauseBusy() {
return nil
}
// if we were in a submodule, we want to forget about that stack of repos
// so that hitting escape in the new repo does nothing
self.c.State().GetRepoPathStack().Clear()
return self.switchTo(path, self.c.Tr.ErrRepositoryMovedOrDeleted, context.NO_CONTEXT)
},
}
}
func (self *ReposHelper) CreateRecentReposMenu() error {
// we'll show an empty panel if there are no recent repos
recentRepoPaths := []string{}
@ -126,33 +196,7 @@ func (self *ReposHelper) CreateRecentReposMenu() error {
wg.Wait()
menuItems := lo.Map(recentRepoPaths, func(path string, i int) *types.MenuItem {
branchName := currentBranches[i]
if icons.IsIconEnabled() {
branchName = icons.BRANCH_ICON + " " + branchName
}
return &types.MenuItem{
LabelColumns: []string{
filepath.Base(path),
style.FgCyan.Sprint(branchName),
// The last segment of the path is already in the first column,
// so showing the directory that contains the repo is enough to
// tell repos with the same name apart.
style.FgMagenta.Sprint(utils.ContractTilde(filepath.Dir(path))),
},
OnPress: func() error {
// Check before clearing the stack, so a refused switch doesn't
// forget the submodule breadcrumb (which would leave escape
// unable to return to the parent repo).
if self.switchRefusedBecauseBusy() {
return nil
}
// if we were in a submodule, we want to forget about that stack of repos
// so that hitting escape in the new repo does nothing
self.c.State().GetRepoPathStack().Clear()
return self.switchTo(path, self.c.Tr.ErrRepositoryMovedOrDeleted, context.NO_CONTEXT)
},
}
return self.recentRepoMenuItem(path, currentBranches[i])
})
return self.c.Menu(types.CreateMenuOptions{

View file

@ -310,6 +310,12 @@ type MenuItem struct {
// alternative to Label. Allows specifying columns which will be auto-aligned
LabelColumns []string
// The strings that filtering the menu matches against, for menus that
// abbreviate their columns to keep them narrow. If nil, LabelColumns are
// matched, so that a menu only needs to set this if what it displays is not
// the full text.
FilterColumns []string
OnPress func() error
// Only applies when Label is used

View file

@ -332,6 +332,8 @@ type TranslationSet struct {
NotMidRebase string
MustSelectFixupCommit string
RecentRepos string
RecentReposRepoLabel string
RecentReposBranchLabel string
MergeOptionsTitle string
RebaseOptionsTitle string
CherryPickOptionsTitle string
@ -1490,6 +1492,8 @@ func EnglishTranslationSet() *TranslationSet {
NotMidRebase: "This action only works during an interactive rebase",
MustSelectFixupCommit: "This action only works on fixup commits",
RecentRepos: "Recent repositories",
RecentReposRepoLabel: "Repo:",
RecentReposBranchLabel: "Branch:",
MergeOptionsTitle: "Merge options",
RebaseOptionsTitle: "Rebase options",
CherryPickOptionsTitle: "Cherry-pick options",

View file

@ -0,0 +1,46 @@
package misc
import (
"path/filepath"
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var RecentReposWithLongNames = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Long repo and branch names are truncated in the recent repositories menu, and shown in full in the tooltip",
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(".")
target, _ := filepath.Abs("../repo-with-a-name-that-is-far-too-long")
cfg.GetAppState().RecentRepos = []string{current, target}
},
SetupRepo: func(shell *Shell) {
shell.EmptyCommit("one")
shell.NewBranch("branch-with-a-name-that-is-too-long")
shell.CloneNonBare("repo-with-a-name-that-is-far-too-long")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.ExpectPopup().Menu().
Title(Equals("Recent repositories")).
Lines(
Contains("repo-with-a-name-that-is-far-… branch-with-a-name-that-is-to…").IsSelected(),
Contains("Cancel"),
).
Tooltip(Equals("Repo: repo-with-a-name-that-is-far-too-long\nBranch: branch-with-a-name-that-is-too-long")).
// Filtering matches the full names, including the part that the
// menu truncates
Filter("too-long").
Lines(
Contains("repo-with-a-name-that-is-far-…").IsSelected(),
).
Confirm()
t.Views().Status().Content(Contains("repo-with-a-name"))
},
})

View file

@ -366,6 +366,7 @@ var tests = []*components.IntegrationTest{
misc.FilterRecentRepos,
misc.InitialOpen,
misc.RecentReposOnLaunch,
misc.RecentReposWithLongNames,
misc.StartInGitDir,
patch_building.Apply,
patch_building.ApplyInReverse,