diff --git a/pkg/commands/git_commands/git_command_builder.go b/pkg/commands/git_commands/git_command_builder.go index f1a7c87b4..43bcd7206 100644 --- a/pkg/commands/git_commands/git_command_builder.go +++ b/pkg/commands/git_commands/git_command_builder.go @@ -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 // 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) } diff --git a/pkg/commands/git_commands/repo_paths.go b/pkg/commands/git_commands/repo_paths.go index 0473f8f8e..088dd86b6 100644 --- a/pkg/commands/git_commands/repo_paths.go +++ b/pkg/commands/git_commands/repo_paths.go @@ -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( diff --git a/pkg/commands/git_commands/submodule.go b/pkg/commands/git_commands/submodule.go index 7400f0514..0fa5de9df 100644 --- a/pkg/commands/git_commands/submodule.go +++ b/pkg/commands/git_commands/submodule.go @@ -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() } diff --git a/pkg/commands/git_commands/worktree.go b/pkg/commands/git_commands/worktree.go index 64748b878..938287270 100644 --- a/pkg/commands/git_commands/worktree.go +++ b/pkg/commands/git_commands/worktree.go @@ -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) { diff --git a/pkg/gui/context/menu_context.go b/pkg/gui/context/menu_context.go index 55a3e5bfa..aa678f263 100644 --- a/pkg/gui/context/menu_context.go +++ b/pkg/gui/context/menu_context.go @@ -89,6 +89,10 @@ func NewMenuViewModel(c *ContextCommon) *MenuViewModel { }) } + if item.FilterColumns != nil { + return item.FilterColumns + } + return item.LabelColumns }, ) diff --git a/pkg/gui/controllers/helpers/confirmation_helper.go b/pkg/gui/controllers/helpers/confirmation_helper.go index f525c1d8a..6bfd7ca4e 100644 --- a/pkg/gui/controllers/helpers/confirmation_helper.go +++ b/pkg/gui/controllers/helpers/confirmation_helper.go @@ -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 diff --git a/pkg/gui/controllers/helpers/repos_helper.go b/pkg/gui/controllers/helpers/repos_helper.go index c8a33bcbe..53018a2e1 100644 --- a/pkg/gui/controllers/helpers/repos_helper.go +++ b/pkg/gui/controllers/helpers/repos_helper.go @@ -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{ diff --git a/pkg/gui/controllers/helpers/repos_helper_test.go b/pkg/gui/controllers/helpers/repos_helper_test.go new file mode 100644 index 000000000..5e6b2e8c7 --- /dev/null +++ b/pkg/gui/controllers/helpers/repos_helper_test.go @@ -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) + }) + } +} diff --git a/pkg/gui/presentation/worktrees.go b/pkg/gui/presentation/worktrees.go index b2d99cb95..d0a506690 100644 --- a/pkg/gui/presentation/worktrees.go +++ b/pkg/gui/presentation/worktrees.go @@ -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 diff --git a/pkg/gui/presentation/worktrees_test.go b/pkg/gui/presentation/worktrees_test.go index 73abe100d..a7b1ab4a5 100644 --- a/pkg/gui/presentation/worktrees_test.go +++ b/pkg/gui/presentation/worktrees_test.go @@ -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 { diff --git a/pkg/gui/types/common.go b/pkg/gui/types/common.go index 58ccf6d60..1603df9bb 100644 --- a/pkg/gui/types/common.go +++ b/pkg/gui/types/common.go @@ -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 diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index 9c72b53df..7a39cd716 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -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", diff --git a/pkg/integration/tests/misc/recent_repos_branch_column.go b/pkg/integration/tests/misc/recent_repos_branch_column.go new file mode 100644 index 000000000..3460d9bea --- /dev/null +++ b/pkg/integration/tests/misc/recent_repos_branch_column.go @@ -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() + }, +}) diff --git a/pkg/integration/tests/misc/recent_repos_column_widths.go b/pkg/integration/tests/misc/recent_repos_column_widths.go new file mode 100644 index 000000000..587e5d9b6 --- /dev/null +++ b/pkg/integration/tests/misc/recent_repos_column_widths.go @@ -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")) + }, +}) diff --git a/pkg/integration/tests/misc/recent_repos_reftable_repo.go b/pkg/integration/tests/misc/recent_repos_reftable_repo.go new file mode 100644 index 000000000..797976bae --- /dev/null +++ b/pkg/integration/tests/misc/recent_repos_reftable_repo.go @@ -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() + }, +}) diff --git a/pkg/integration/tests/misc/recent_repos_with_long_names.go b/pkg/integration/tests/misc/recent_repos_with_long_names.go new file mode 100644 index 000000000..7bb18593c --- /dev/null +++ b/pkg/integration/tests/misc/recent_repos_with_long_names.go @@ -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")) + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index a4e732cf0..ef73e79b1 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -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, diff --git a/pkg/utils/formatting.go b/pkg/utils/formatting.go index f058f4f40..080f58f87 100644 --- a/pkg/utils/formatting.go +++ b/pkg/utils/formatting.go @@ -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] diff --git a/pkg/utils/formatting_test.go b/pkg/utils/formatting_test.go index bc30fcf25..7de0492ae 100644 --- a/pkg/utils/formatting_test.go +++ b/pkg/utils/formatting_test.go @@ -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 diff --git a/pkg/utils/utils.go b/pkg/utils/utils.go index 0494bb035..d706fe719 100644 --- a/pkg/utils/utils.go +++ b/pkg/utils/utils.go @@ -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 +} diff --git a/pkg/utils/utils_test.go b/pkg/utils/utils_test.go index 8304e7ba4..4d51575fc 100644 --- a/pkg/utils/utils_test.go +++ b/pkg/utils/utils_test.go @@ -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)) + }) + } +}