Improve the Recent Repos menu (#6016)
Some checks are pending
Continuous Integration / ci - ${{matrix.os}} (~/.cache/go-build, ubuntu-latest) (push) Waiting to run
Continuous Integration / ci - ${{matrix.os}} (~\AppData\Local\go-build, windows-latest) (push) Waiting to run
Continuous Integration / Integration Tests - git ${{matrix.git-version}}${{ matrix.race && ' (race)' || '' }} (2.32.0, false) (push) Waiting to run
Continuous Integration / Integration Tests - git ${{matrix.git-version}}${{ matrix.race && ' (race)' || '' }} (2.38.2, false) (push) Waiting to run
Continuous Integration / Integration Tests - git ${{matrix.git-version}}${{ matrix.race && ' (race)' || '' }} (2.44.0, false) (push) Waiting to run
Continuous Integration / Integration Tests - git ${{matrix.git-version}}${{ matrix.race && ' (race)' || '' }} (latest, false) (push) Waiting to run
Continuous Integration / Integration Tests - git ${{matrix.git-version}}${{ matrix.race && ' (race)' || '' }} (latest, true) (push) Waiting to run
Continuous Integration / build (push) Waiting to run
Continuous Integration / check-codebase (push) Waiting to run
Continuous Integration / lint (push) Waiting to run
Continuous Integration / upload-coverage (push) Blocked by required conditions
Continuous Integration / check-for-fixups (push) Waiting to run
Codespell / Check for spelling errors (push) Waiting to run
Generate Sponsors README / deploy (push) Waiting to run

Several improvements to the Recent Repos menu:

- use the full available width for a menu (previously we would use a
ratio of the window width, which would often be less than the 90
characters that are the default window width)
- truncate long repo/worktree names and long branch names to at most 30
characters, so that all three columns of the menu fit
- abbreviate the home directory in paths to `~`; this is easier to read,
and saves some space
- for detached heads, show "HEAD detached at <sha>" rather than just the
sha, like we do in the worktrees panel
- make detached heads show properly for submodules; these would
previously show "Branch unknown"
- show correct branch name for reftable repos (these would show
".invalid")
This commit is contained in:
Stefan Haller 2026-09-14 13:47:49 +02:00 committed by GitHub
commit 71d3e7dfa5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 738 additions and 92 deletions

View file

@ -19,12 +19,12 @@ import (
// that opts back in is the foreground files refresh; see FileLoader.gitStatus.
const OptionalLocksEnvVar = "GIT_OPTIONAL_LOCKS"
// forOtherRepo prepares a command that operates on a repo other than the one
// ForOtherRepo prepares a command that operates on a repo other than the one
// we have open — a submodule, or another worktree. GIT_DIR and GIT_WORK_TREE
// say where our repo is, and every command we run inherits them, so a command
// pointed at a different repo would be resolved against ours instead: `git -C
// <submodule> log` would silently log the superproject's commits.
func forOtherRepo(cmdObj *oscommands.CmdObj) *oscommands.CmdObj {
func ForOtherRepo(cmdObj *oscommands.CmdObj) *oscommands.CmdObj {
return cmdObj.RemoveEnvVar(env.GitDirEnvVar).RemoveEnvVar(env.GitWorkTreeEnvVar)
}

View file

@ -271,13 +271,13 @@ func callGitRevParseWithDir(
return runGitRevParse(newGitRevParseCmd(cmd, dir, gitRevArgs...))
}
// Asks git about a repo that isn't the one we have open; see forOtherRepo.
// Asks git about a repo that isn't the one we have open; see ForOtherRepo.
func callGitRevParseInOtherRepo(
cmd oscommands.ICmdObjBuilder,
dir string,
gitRevArgs ...string,
) (string, error) {
return runGitRevParse(forOtherRepo(newGitRevParseCmd(cmd, dir, gitRevArgs...)))
return runGitRevParse(ForOtherRepo(newGitRevParseCmd(cmd, dir, gitRevArgs...)))
}
func newGitRevParseCmd(

View file

@ -157,7 +157,7 @@ func (self *SubmoduleCommands) GetCommitSummary(path string, sha string) (string
Config("log.showsignature=false").
ToArgv()
summary, err := forOtherRepo(self.cmd.New(cmdArgs)).DontLog().RunWithOutput()
summary, err := ForOtherRepo(self.cmd.New(cmdArgs)).DontLog().RunWithOutput()
return strings.TrimSpace(summary), err
}
@ -167,7 +167,7 @@ func (self *SubmoduleCommands) GetCommitSummary(path string, sha string) (string
// caller then stages the submodule to record the resolution.
func (self *SubmoduleCommands) CheckoutConflictCommit(path string, sha string) error {
cmdArgs := NewGitCmd("checkout").Dir(path).Arg(sha).ToArgv()
return forOtherRepo(self.cmd.New(cmdArgs)).Run()
return ForOtherRepo(self.cmd.New(cmdArgs)).Run()
}
// ConflictSideLog returns a oneline log, run inside the submodule, of the commits
@ -179,7 +179,7 @@ func (self *SubmoduleCommands) ConflictSideLog(path string, side string, otherSi
Arg("--oneline", "--color=always", otherSide+".."+side).
ToArgv()
return forOtherRepo(self.cmd.New(cmdArgs)).DontLog().RunWithOutput()
return ForOtherRepo(self.cmd.New(cmdArgs)).DontLog().RunWithOutput()
}
func (self *SubmoduleCommands) Stash(submodule *models.SubmoduleConfig) error {
@ -195,7 +195,7 @@ func (self *SubmoduleCommands) Stash(submodule *models.SubmoduleConfig) error {
Arg("--include-untracked").
ToArgv()
return forOtherRepo(self.cmd.New(cmdArgs)).Run()
return ForOtherRepo(self.cmd.New(cmdArgs)).Run()
}
func (self *SubmoduleCommands) Reset(submodule *models.SubmoduleConfig) error {
@ -229,7 +229,7 @@ func (self *SubmoduleCommands) UpdateAll() error {
// need not be.
func (self *SubmoduleCommands) runInParentModule(submodule *models.SubmoduleConfig, cmdObj *oscommands.CmdObj) error {
if submodule.ParentModule != nil {
forOtherRepo(cmdObj.SetWd(submodule.ParentModule.FullPath()))
ForOtherRepo(cmdObj.SetWd(submodule.ParentModule.FullPath()))
}
return cmdObj.Run()
}

View file

@ -51,7 +51,7 @@ func (self *WorktreeCommands) Delete(worktreePath string, force bool) error {
func (self *WorktreeCommands) Detach(worktreePath string) error {
cmdArgs := NewGitCmd("checkout").Arg("--detach").GitDir(filepath.Join(worktreePath, ".git")).ToArgv()
return forOtherRepo(self.cmd.New(cmdArgs)).Run()
return ForOtherRepo(self.cmd.New(cmdArgs)).Run()
}
func WorktreeForBranch(branch *models.Branch, worktrees []*models.Worktree) (*models.Worktree, bool) {

View file

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

View file

@ -118,27 +118,36 @@ func (self *ConfirmationHelper) getPopupPanelDimensionsAux(contentWidth int, con
y0 += 1
return x0, y0, x0 + panelWidth - 1, y0 + panelHeight - 1
}
return width/2 - panelWidth/2,
height/2 - panelHeight/2 - panelHeight%2,
// Currently, X1/Y1 of a gocui view is one less than you would expect based on its
// width/height, so we need to subtract 1 here. See
// https://github.com/jesseduffield/lazygit/commit/f6f2a52dee8bba3ebd7e3b34b4b7c7d3e3795f3e
width/2 + panelWidth/2 - 1,
height/2 + panelHeight/2 - 1
x0 := (width - panelWidth) / 2
y0 := height/2 - panelHeight/2 - panelHeight%2
// Currently, X1/Y1 of a gocui view is one less than you would expect based on its
// width/height, so we need to subtract 1 here. See
// https://github.com/jesseduffield/lazygit/commit/f6f2a52dee8bba3ebd7e3b34b4b7c7d3e3795f3e
return x0, y0, x0 + panelWidth - 1, y0 + panelHeight - 1
}
const (
// The width a popup panel keeps as long as it fits into the window at all,
// even when the panel asks for less.
popupPanelMinWidth = 80
// The margin we try to leave between a popup panel and the sides of the
// window, so that the panel doesn't sit flush against them as soon as the
// window gets a little narrow.
popupPanelMargin = 3
)
// Returns the outer width of the view, including its frame. To decide how to wrap text, subtract 2.
// Also, note that X1-X0 of the view is one less than this.
func (self *ConfirmationHelper) getPopupPanelWidth(maxWidth int) int {
width, _ := self.c.GocuiGui().Size()
// we want a minimum width up to a point, then we do it based on ratio, but only up to the given max width
panelWidth := min(4*width/7, maxWidth)
minWidth := 80
if panelWidth < minWidth {
panelWidth = min(width-2, minWidth)
}
func (self *ConfirmationHelper) getPopupPanelWidth(requestedWidth int) int {
windowWidth, _ := self.c.GocuiGui().Size()
// A panel gets the width it asks for as long as the margin fits beside it.
// It gives the margin up before it goes below the minimum width, and a
// column on either side is all it leaves in the end.
widthWithMargin := windowWidth - 2*popupPanelMargin
widthAtMinWidth := min(popupPanelMinWidth, windowWidth-2)
return panelWidth
return min(requestedWidth, max(widthWithMargin, widthAtMinWidth))
}
func (self *ConfirmationHelper) prepareConfirmationPanel(
@ -324,6 +333,10 @@ func (self *ConfirmationHelper) ResizeCurrentPopupPanels() {
}
}
// The width a menu grows to when the window is wide enough for it. Its content
// is two columns narrower than this, for the frame.
const menuMaxWidth = 90
// 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
@ -359,7 +372,7 @@ func (self *ConfirmationHelper) resizeMenu(parentPopupContext types.Context) {
// resize the window
itemCount := menuContext.UnfilteredLen()
offset := 3
panelWidth := self.getPopupPanelWidth(90)
panelWidth := self.getPopupPanelWidth(menuMaxWidth)
contentWidth := panelWidth - 2 // minus 2 for the frame
promptLinesCount := self.layoutMenuPrompt(contentWidth)
// The row is reserved for the whole time the menu is open, even though it only

View file

@ -2,7 +2,6 @@ package helpers
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
@ -11,6 +10,7 @@ import (
appTypes "github.com/jesseduffield/lazygit/pkg/app/types"
"github.com/jesseduffield/lazygit/pkg/commands"
"github.com/jesseduffield/lazygit/pkg/commands/direnv"
"github.com/jesseduffield/lazygit/pkg/commands/git_commands"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/env"
"github.com/jesseduffield/lazygit/pkg/gui/context"
@ -62,46 +62,252 @@ func (self *ReposHelper) EnterSubmodule(submodule *models.SubmoduleConfig) error
return self.switchTo(submodule.FullPath(), self.c.Tr.ErrRepositoryMovedOrDeleted, context.NO_CONTEXT)
}
// What a repo has checked out. Exactly one of the two fields is set.
type headInfo struct {
// The name of the checked-out branch.
branch string
// The commit HEAD is detached at.
hash string
}
// gitDirOfRepo returns the directory that holds the git data of the repo at
// repoPath, and whether it could be found. An ordinary repo keeps that data in
// a .git directory; a worktree and a submodule have a .git file naming the
// directory instead.
func gitDirOfRepo(repoPath string) (string, bool) {
gitDirPath := filepath.Join(repoPath, ".git")
stat, err := os.Stat(gitDirPath)
if err != nil {
return "", false
}
if stat.IsDir() {
return gitDirPath, true
}
content, err := os.ReadFile(gitDirPath)
if err != nil {
return "", false
}
gitDir, ok := strings.CutPrefix(strings.TrimSpace(string(content)), "gitdir: ")
if !ok {
return "", false
}
// A relative name is relative to the repo. Git writes one for a submodule,
// and for a worktree created with --relative-paths.
if !filepath.IsAbs(gitDir) {
gitDir = filepath.Join(repoPath, gitDir)
}
return gitDir, true
}
// The branch git names in the HEAD file of a repo that keeps its refs in a
// reftable. The refs live in a binary table there, and the name in HEAD
// resolves nowhere, so that a reader of the file gets an error instead of a
// stale answer.
const reftablePlaceholderBranch = ".invalid"
// readHeadInfo reads the HEAD file of the repo at repoPath to find out what it
// has checked out, and reports whether that worked.
func readHeadInfo(repoPath string) (headInfo, bool) {
gitDir, ok := gitDirOfRepo(repoPath)
if !ok {
return headInfo{}, false
}
content, err := os.ReadFile(filepath.Join(gitDir, "HEAD"))
if err != nil {
return headInfo{}, false
}
head := strings.TrimSpace(string(content))
if branch, ok := strings.CutPrefix(head, "ref: refs/heads/"); ok {
if branch == reftablePlaceholderBranch {
return headInfo{}, false
}
return headInfo{branch: branch}, true
}
return headInfo{hash: head}, true
}
// askGitForHeadInfo asks git what the repo at repoPath has checked out. This
// costs a process, so only the repos that readHeadInfo can't answer for go
// through here.
func (self *ReposHelper) askGitForHeadInfo(repoPath string) (headInfo, bool) {
// symbolic-ref names the branch even when it has no commit yet, and
// rev-parse resolves HEAD when it is detached. Neither can do the other's
// job, so ask for the branch first and only then for the commit.
if branch, ok := self.askGit(repoPath, "symbolic-ref", "--short", "--quiet", "HEAD"); ok {
return headInfo{branch: branch}, true
}
if hash, ok := self.askGit(repoPath, "rev-parse", "HEAD"); ok {
return headInfo{hash: hash}, true
}
return headInfo{}, false
}
// askGit runs a git command against the repo at repoPath and returns the one
// line it writes, or false if it fails or writes nothing.
func (self *ReposHelper) askGit(repoPath string, subcommand string, args ...string) (string, bool) {
cmdObj := self.c.OS().Cmd.New(git_commands.NewGitCmd(subcommand).
Dir(repoPath).
Arg(args...).
ToArgv()).DontLog()
stdout, _, err := git_commands.ForOtherRepo(cmdObj).RunWithOutputs()
if err != nil {
return "", false
}
output := strings.TrimSpace(stdout)
return output, output != ""
}
func (self *ReposHelper) getCurrentBranch(path string) string {
readHeadFile := func(path string) (string, error) {
headFile, err := os.ReadFile(filepath.Join(path, "HEAD"))
if err == nil {
content := strings.TrimSpace(string(headFile))
refsPrefix := "ref: refs/heads/"
var branchDisplay string
if bareName, ok := strings.CutPrefix(content, refsPrefix); ok {
// is a branch
branchDisplay = bareName
} else {
// detached HEAD state, displaying short hash
branchDisplay = utils.ShortHash(content)
}
return branchDisplay, nil
}
return "", err
head, ok := readHeadInfo(path)
if !ok {
head, ok = self.askGitForHeadInfo(path)
}
if !ok {
return self.c.Tr.BranchUnknown
}
if head.branch != "" {
return head.branch
}
return utils.ResolvePlaceholderString(self.c.Tr.HeadDetachedAt,
map[string]string{"hash": utils.ShortHash(head.hash)})
}
// The most that the name and the branch column of the recent repos menu are
// allowed to take up. Each column is padded to the width of its widest entry,
// so without a limit one long entry pushes the columns after it off the right
// edge of the menu for every entry.
const (
recentReposNameMaxWidth = 30
recentReposBranchMaxWidth = 30
)
// One entry of the recent repos menu.
type recentRepoEntry struct {
// What the entry stands for
path string
branchName string
// The text of its three columns
nameColumn string
branchColumn string
dirColumn string
}
func newRecentRepoEntry(path string, branchName string) recentRepoEntry {
// The icon is part of the column, so it counts towards the column's width.
branchColumn := branchName
if icons.IsIconEnabled() {
branchColumn = icons.BRANCH_ICON + " " + branchName
}
gitDirPath := filepath.Join(path, ".git")
return recentRepoEntry{
path: path,
branchName: branchName,
nameColumn: filepath.Base(path),
branchColumn: branchColumn,
// The last segment of the path is already in the first column, so the
// directory that contains the repo is enough to tell repos with the
// same name apart.
dirColumn: utils.ContractTilde(filepath.Dir(path)),
}
}
if gitDir, err := os.Stat(gitDirPath); err == nil {
if gitDir.IsDir() {
// ordinary repo
if branch, err := readHeadFile(gitDirPath); err == nil {
return branch
}
} else {
// worktree
if worktreeGitDir, err := os.ReadFile(gitDirPath); err == nil {
content := strings.TrimSpace(string(worktreeGitDir))
worktreePath := strings.TrimPrefix(content, "gitdir: ")
if branch, err := readHeadFile(worktreePath); err == nil {
return branch
}
}
}
// How wide the columns of the recent repos menu are allowed to get.
type recentRepoColumnWidths struct {
name int
branch int
dir int
}
// Gives the name and the branch column as much as their entries need, up to
// their respective maximum, and the rest of the row to the directory column.
// Measuring the entries first matters because most users don't have names that
// long; truncating the directories as if they did would cut them short for no
// reason.
func (self *ReposHelper) fitRecentRepoColumns(entries []recentRepoEntry) recentRepoColumnWidths {
// The menu appends a Cancel entry, whose label sits in the first column.
nameWidth := utils.StringWidth(self.c.Tr.Cancel)
branchWidth := 0
for _, entry := range entries {
nameWidth = max(nameWidth, utils.StringWidth(entry.nameColumn))
branchWidth = max(branchWidth, utils.StringWidth(entry.branchColumn))
}
return self.c.Tr.BranchUnknown
nameWidth = min(nameWidth, recentReposNameMaxWidth)
branchWidth = min(branchWidth, recentReposBranchMaxWidth)
return recentRepoColumnWidths{
name: nameWidth,
branch: branchWidth,
// The menu's frame takes up two columns, and two more separate the
// three columns from each other. What's left is the room a whole row
// has in a menu that is as wide as it gets.
dir: menuMaxWidth - 2 - 2 - nameWidth - branchWidth,
}
}
func (self *ReposHelper) recentRepoMenuItem(entry recentRepoEntry, widths recentRepoColumnWidths) *types.MenuItem {
displayedName := utils.TruncateWithEllipsis(entry.nameColumn, widths.name)
displayedBranch := utils.TruncateWithEllipsis(entry.branchColumn, widths.branch)
// The beginning and the end of a directory are both worth seeing, so it
// loses its middle rather than its end when it doesn't fit.
displayedDir := utils.TruncateWithEllipsisInMiddle(entry.dirColumn, widths.dir)
// 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 displayedName != entry.nameColumn {
addTooltipField(self.c.Tr.RecentReposRepoLabel, entry.nameColumn)
}
if displayedBranch != entry.branchColumn {
addTooltipField(self.c.Tr.RecentReposBranchLabel, entry.branchName)
}
if displayedDir != entry.dirColumn {
addTooltipField(self.c.Tr.RecentReposPathLabel, entry.dirColumn)
}
// 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{
displayedName,
style.FgCyan.Sprint(displayedBranch),
style.FgMagenta.Sprint(displayedDir),
},
// Filtering matches the full text, including the parts that the columns
// above truncate or leave out.
FilterColumns: []string{entry.nameColumn, entry.branchName, entry.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(entry.path, self.c.Tr.ErrRepositoryMovedOrDeleted, context.NO_CONTEXT)
},
}
}
func (self *ReposHelper) CreateRecentReposMenu() error {
@ -112,45 +318,27 @@ func (self *ReposHelper) CreateRecentReposMenu() error {
recentRepoPaths = self.c.GetAppState().RecentRepos[1:]
}
currentBranches := sync.Map{}
currentBranches := make([]string, len(recentRepoPaths))
wg := sync.WaitGroup{}
wg.Add(len(recentRepoPaths))
for _, path := range recentRepoPaths {
go func(path string) {
for i, path := range recentRepoPaths {
go func() {
defer wg.Done()
currentBranches.Store(path, self.getCurrentBranch(path))
}(path)
currentBranches[i] = self.getCurrentBranch(path)
}()
}
wg.Wait()
menuItems := lo.Map(recentRepoPaths, func(path string, _ int) *types.MenuItem {
branchName, _ := currentBranches.Load(path)
if icons.IsIconEnabled() {
branchName = icons.BRANCH_ICON + " " + fmt.Sprintf("%v", branchName)
}
entries := lo.Map(recentRepoPaths, func(path string, i int) recentRepoEntry {
return newRecentRepoEntry(path, currentBranches[i])
})
return &types.MenuItem{
LabelColumns: []string{
filepath.Base(path),
style.FgCyan.Sprint(branchName),
style.FgMagenta.Sprint(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)
},
}
columnWidths := self.fitRecentRepoColumns(entries)
menuItems := lo.Map(entries, func(entry recentRepoEntry, _ int) *types.MenuItem {
return self.recentRepoMenuItem(entry, columnWidths)
})
return self.c.Menu(types.CreateMenuOptions{

View file

@ -0,0 +1,108 @@
package helpers
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
func TestReadHeadInfo(t *testing.T) {
scenarios := []struct {
name string
// The files to lay out below a temporary root directory, by their path
// relative to it. "$root" in a file's content is replaced with the
// root's path, so that a scenario can write an absolute path.
files map[string]string
// The repo to read, relative to the root. The directory is created
// whether or not the scenario puts any files in it.
repoPath string
expected headInfo
expectedOk bool
}{
{
name: "ordinary repo on a branch",
files: map[string]string{"repo/.git/HEAD": "ref: refs/heads/mybranch\n"},
repoPath: "repo",
expected: headInfo{branch: "mybranch"},
expectedOk: true,
},
{
name: "ordinary repo at a detached head",
files: map[string]string{"repo/.git/HEAD": "d85cc9d2f5d0dc0b8f0e4d8e5b2ba0d1e7c8a3f6\n"},
repoPath: "repo",
expected: headInfo{hash: "d85cc9d2f5d0dc0b8f0e4d8e5b2ba0d1e7c8a3f6"},
expectedOk: true,
},
{
name: "worktree whose .git file names the git dir absolutely",
files: map[string]string{
"repo/.git/worktrees/wt/HEAD": "ref: refs/heads/mybranch\n",
"wt/.git": "gitdir: $root/repo/.git/worktrees/wt\n",
},
repoPath: "wt",
expected: headInfo{branch: "mybranch"},
expectedOk: true,
},
{
name: "worktree whose .git file names the git dir relatively",
files: map[string]string{
"repo/.git/worktrees/wt/HEAD": "ref: refs/heads/mybranch\n",
"wt/.git": "gitdir: ../repo/.git/worktrees/wt\n",
},
repoPath: "wt",
expected: headInfo{branch: "mybranch"},
expectedOk: true,
},
{
name: "submodule, whose .git file always names the git dir relatively",
files: map[string]string{
"repo/.git/modules/sub/HEAD": "ref: refs/heads/mybranch\n",
"repo/sub/.git": "gitdir: ../.git/modules/sub\n",
},
repoPath: "repo/sub",
expected: headInfo{branch: "mybranch"},
expectedOk: true,
},
{
name: "repo that keeps its refs in a reftable, so HEAD holds a placeholder",
files: map[string]string{
"repo/.git/HEAD": "ref: refs/heads/.invalid\n",
},
repoPath: "repo",
expectedOk: false,
},
{
name: "directory without a .git entry",
repoPath: "notarepo",
expectedOk: false,
},
{
name: ".git file that doesn't name a git dir",
files: map[string]string{"repo/.git": "not what git writes\n"},
repoPath: "repo",
expectedOk: false,
},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
root := t.TempDir()
for path, content := range s.files {
fullPath := filepath.Join(root, filepath.FromSlash(path))
assert.NoError(t, os.MkdirAll(filepath.Dir(fullPath), 0o700))
content = strings.ReplaceAll(content, "$root", root)
assert.NoError(t, os.WriteFile(fullPath, []byte(content), 0o600))
}
repoPath := filepath.Join(root, filepath.FromSlash(s.repoPath))
assert.NoError(t, os.MkdirAll(repoPath, 0o700))
head, ok := readHeadInfo(repoPath)
assert.Equal(t, s.expectedOk, ok)
assert.Equal(t, s.expected, head)
})
}
}

View file

@ -49,7 +49,8 @@ func GetWorktreeDisplayString(tr *i18n.TranslationSet, worktree *models.Worktree
if worktree.Branch != "" {
branch = style.FgCyan.Sprint(worktree.Branch)
} else if worktree.Head != "" {
branch = style.FgYellow.Sprint("HEAD detached at " + utils.ShortHash(worktree.Head))
branch = style.FgYellow.Sprint(utils.ResolvePlaceholderString(
tr.HeadDetachedAt, map[string]string{"hash": utils.ShortHash(worktree.Head)}))
}
res = append(res, branch+mainWorktreeLabel(tr, worktree))
return res

View file

@ -14,6 +14,7 @@ func Test_GetWorktreeDisplayString(t *testing.T) {
tr := &i18n.TranslationSet{
MainWorktree: "(main worktree)",
MissingWorktree: "(missing)",
HeadDetachedAt: "HEAD detached at {{.hash}}",
}
scenarios := []struct {

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,9 @@ type TranslationSet struct {
NotMidRebase string
MustSelectFixupCommit string
RecentRepos string
RecentReposRepoLabel string
RecentReposBranchLabel string
RecentReposPathLabel string
MergeOptionsTitle string
RebaseOptionsTitle string
CherryPickOptionsTitle string
@ -715,6 +718,7 @@ type TranslationSet struct {
BranchNotFoundTitle string
BranchNotFoundPrompt string
BranchUnknown string
HeadDetachedAt string
DiscardChangeTitle string
DiscardChangePrompt string
DiscardLinesFromCommitTitle string
@ -1490,6 +1494,9 @@ 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:",
RecentReposPathLabel: "Path:",
MergeOptionsTitle: "Merge options",
RebaseOptionsTitle: "Rebase options",
CherryPickOptionsTitle: "Cherry-pick options",
@ -1878,6 +1885,7 @@ func EnglishTranslationSet() *TranslationSet {
BranchNotFoundTitle: "Branch not found",
BranchNotFoundPrompt: "Branch not found. Create a new branch named",
BranchUnknown: "Branch unknown",
HeadDetachedAt: "HEAD detached at {{.hash}}",
DiscardChangeTitle: "Discard change",
DiscardChangePrompt: "Are you sure you want to discard this change (git reset)? It is irreversible.\nTo disable this dialogue set the config key of 'gui.skipDiscardChangeWarning' to true",
DiscardLinesFromCommitTitle: "Discard lines from commit",

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 RecentReposBranchColumn = NewIntegrationTest(NewIntegrationTestArgs{
Description: "The branch column of the recent repositories menu shows what each repo has checked out",
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(".")
onBranch, _ := filepath.Abs("../on-branch")
detached, _ := filepath.Abs("../detached")
submodule, _ := filepath.Abs("sub")
cfg.GetAppState().RecentRepos = []string{current, onBranch, detached, submodule}
},
SetupRepo: func(shell *Shell) {
shell.EmptyCommit("one")
shell.CloneNonBare("on-branch")
shell.CloneNonBare("detached")
shell.RunCommand([]string{"git", "-C", "../detached", "checkout", "--detach"})
shell.CloneIntoSubmodule("submodule", "sub")
shell.GitAddAll()
shell.Commit("add submodule")
shell.RunCommand([]string{"git", "-C", "sub", "checkout", "--detach"})
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.ExpectPopup().Menu().
Title(Equals("Recent repositories")).
Lines(
Contains("on-branch").Contains("master").IsSelected(),
Contains("detached").MatchesRegexp(`HEAD detached at [0-9a-f]{8}`),
Contains("sub").MatchesRegexp(`HEAD detached at [0-9a-f]{8}`),
Contains("Cancel"),
).
Cancel()
},
})

View file

@ -0,0 +1,45 @@
package misc
import (
"path/filepath"
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var RecentReposColumnWidths = NewIntegrationTest(NewIntegrationTestArgs{
Description: "The directory column of the recent repositories menu gets the room that the short columns before it don't need",
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("../other")
cfg.GetAppState().RecentRepos = []string{current, target}
},
SetupRepo: func(shell *Shell) {
shell.EmptyCommit("one")
shell.CloneNonBare("other")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
// The name and the branch are short, so the directory is shown in full
// even though it is longer than the third of the row it would get if
// they each took their maximum width
t.ExpectPopup().Menu().
Title(Equals("Recent repositories")).
Lines(
Contains("other master ~/_results/misc/recent_repos_column_widths/actual").IsSelected(),
Contains("Cancel"),
).
Tap(func() {
// Nothing is truncated, so there is nothing to spell out
t.Views().Tooltip().IsInvisible()
}).
Confirm()
t.Views().Status().Content(Contains("other → master"))
},
})

View file

@ -0,0 +1,42 @@
package misc
import (
"path/filepath"
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var RecentReposReftableRepo = NewIntegrationTest(NewIntegrationTestArgs{
Description: "The recent repositories menu shows the branch of a repo that keeps its refs in a reftable",
ExtraCmdArgs: []string{},
ExtraEnvVars: map[string]string{
"SHOW_RECENT_REPOS": "true",
},
Skip: false,
GitVersion: AtLeast("2.45.0"),
SetupConfig: func(cfg *config.AppConfig) {
// the first entry is the repo we're in, so it isn't offered
current, _ := filepath.Abs(".")
reftable, _ := filepath.Abs("../reftable")
unborn, _ := filepath.Abs("../reftable-unborn")
cfg.GetAppState().RecentRepos = []string{current, reftable, unborn}
},
SetupRepo: func(shell *Shell) {
shell.EmptyCommit("one")
shell.RunCommand([]string{"git", "clone", "--ref-format=reftable", ".", "../reftable"})
// A branch without a commit is the one thing git can't answer for with
// rev-parse
shell.RunCommand([]string{"git", "init", "--ref-format=reftable", "../reftable-unborn"})
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.ExpectPopup().Menu().
Title(Equals("Recent repositories")).
Lines(
Contains("reftable").Contains("master").IsSelected(),
Contains("reftable-unborn").Contains("master"),
Contains("Cancel"),
).
Cancel()
},
})

View file

@ -0,0 +1,60 @@
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")
other, _ := filepath.Abs("../other")
cfg.GetAppState().RecentRepos = []string{current, target, other}
},
SetupRepo: func(shell *Shell) {
shell.EmptyCommit("one")
// this one is cloned while master is checked out, so only its path is
// long enough to be truncated
shell.CloneNonBare("other")
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(
// The repos live in the test's own directory, so their paths are
// long enough to lose their middle
Contains("repo-with-a-name-that-is-far-… branch-with-a-name-that-is-to… ~/_results/mi…names/actual").IsSelected(),
Contains("other master ~/_results/mi…names/actual"),
Contains("Cancel"),
).
Tooltip(Contains("Repo: repo-with-a-name-that-is-far-too-long\n" +
"Branch: branch-with-a-name-that-is-too-long\n" +
"Path: ~/_results/misc/recent_repos_with_long_names/actual")).
// The values are lined up behind the labels that are there, so a
// tooltip that only spells out the path doesn't indent it as if a
// "Branch:" label were in front of it
Select(Contains("other")).
Tooltip(Equals("Path: ~/_results/misc/recent_repos_with_long_names/actual")).
// 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

@ -365,7 +365,11 @@ var tests = []*components.IntegrationTest{
misc.DirenvUnloadsOnBlockedEnvrc,
misc.FilterRecentRepos,
misc.InitialOpen,
misc.RecentReposBranchColumn,
misc.RecentReposColumnWidths,
misc.RecentReposOnLaunch,
misc.RecentReposReftableRepo,
misc.RecentReposWithLongNames,
misc.StartInGitDir,
patch_building.Apply,
patch_building.ApplyInReverse,

View file

@ -199,6 +199,49 @@ func TruncateWithEllipsis(str string, limit int) string {
return truncatedStr + "…"
}
// TruncateWithEllipsisInMiddle returns a string, truncated to a certain width,
// with an ellipsis in the middle. Use it where the end of the string is as
// informative as its beginning, e.g. for paths.
func TruncateWithEllipsisInMiddle(str string, limit int) string {
if StringWidth(str) <= limit {
return str
}
if limit <= 2 {
return strings.Repeat(".", limit)
}
clusters := []string{}
widths := []int{}
graphemes := uniseg.NewGraphemes(str)
for graphemes.Next() {
clusters = append(clusters, graphemes.Str())
widths = append(widths, graphemes.Width())
}
// One column goes to the ellipsis; the rest is split between the two ends,
// with the odd one going to the front.
remaining := limit - 1
frontLimit := (remaining + 1) / 2
front := 0
frontWidth := 0
for front < len(clusters) && frontWidth+widths[front] <= frontLimit {
frontWidth += widths[front]
front++
}
// Whatever the front didn't use, e.g. because a wide grapheme didn't fit
// into it, is available to the back.
back := len(clusters)
backWidth := 0
for back > front && backWidth+widths[back-1] <= remaining-frontWidth {
backWidth += widths[back-1]
back--
}
return strings.Join(clusters[:front], "") + "…" + strings.Join(clusters[back:], "")
}
func SafeTruncate(str string, limit int) string {
if len(str) > limit {
return str[0:limit]

View file

@ -162,6 +162,38 @@ func TestTruncateWithEllipsis(t *testing.T) {
}
}
func TestTruncateWithEllipsisInMiddle(t *testing.T) {
type scenario struct {
str string
limit int
expected string
}
scenarios := []scenario{
{"hello world !", 0, ""},
{"hello world !", 1, "."},
{"hello world !", 2, ".."},
{"hello world !", 3, "h…!"},
{"hello world !", 4, "he…!"},
{"hello world !", 5, "he… !"},
{"hello world !", 12, "hello …rld !"},
{"hello world !", 13, "hello world !"},
{"hello world !", 14, "hello world !"},
// A wide grapheme that doesn't fit into the front leaves its column to
// the back
{"大大大大", 5, "大…大"},
{"大大大大", 7, "大…大大"},
{"大大大大", 8, "大大大大"},
{"大大大大", 2, ".."},
{"大大大大", 1, "."},
{"大大大大", 0, ""},
}
for _, s := range scenarios {
assert.EqualValues(t, s.expected, TruncateWithEllipsisInMiddle(s.str, s.limit))
}
}
func TestRenderDisplayStrings(t *testing.T) {
type scenario struct {
input [][]string

View file

@ -120,3 +120,24 @@ func ExpandTilde(path string) string {
}
return filepath.Join(home, path[2:])
}
// ContractTilde is the inverse of ExpandTilde: it replaces the current user's
// home directory at the start of a path with "~", so that paths can be shown
// in a shorter form. Paths outside the home directory are left untouched, as
// is the path if the home directory can't be determined.
func ContractTilde(path string) string {
home, err := os.UserHomeDir()
if err != nil {
return path
}
if path == home {
return "~"
}
if rest, found := strings.CutPrefix(path, home+string(filepath.Separator)); found {
return "~" + string(filepath.Separator) + rest
}
return path
}

View file

@ -125,3 +125,27 @@ func TestExpandTilde(t *testing.T) {
})
}
}
func TestContractTilde(t *testing.T) {
home, err := os.UserHomeDir()
assert.NoError(t, err)
scenarios := []struct {
name string
path string
expected string
}{
{"home directory", home, "~"},
{"path inside the home directory", filepath.Join(home, "worktrees"), filepath.Join("~", "worktrees")},
{"path outside the home directory is untouched", filepath.Join("/absolute", "path"), filepath.Join("/absolute", "path")},
{"path merely starting with the home directory's name is untouched", home + "-backup", home + "-backup"},
{"relative path is untouched", filepath.Join("relative", "path"), filepath.Join("relative", "path")},
{"empty string is untouched", "", ""},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
assert.Equal(t, s.expected, ContractTilde(s.path))
})
}
}