mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-09-14 09:36:24 -04:00
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
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:
commit
71d3e7dfa5
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -89,6 +89,10 @@ func NewMenuViewModel(c *ContextCommon) *MenuViewModel {
|
|||
})
|
||||
}
|
||||
|
||||
if item.FilterColumns != nil {
|
||||
return item.FilterColumns
|
||||
}
|
||||
|
||||
return item.LabelColumns
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
108
pkg/gui/controllers/helpers/repos_helper_test.go
Normal file
108
pkg/gui/controllers/helpers/repos_helper_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
46
pkg/integration/tests/misc/recent_repos_branch_column.go
Normal file
46
pkg/integration/tests/misc/recent_repos_branch_column.go
Normal 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()
|
||||
},
|
||||
})
|
||||
45
pkg/integration/tests/misc/recent_repos_column_widths.go
Normal file
45
pkg/integration/tests/misc/recent_repos_column_widths.go
Normal 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"))
|
||||
},
|
||||
})
|
||||
42
pkg/integration/tests/misc/recent_repos_reftable_repo.go
Normal file
42
pkg/integration/tests/misc/recent_repos_reftable_repo.go
Normal 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()
|
||||
},
|
||||
})
|
||||
60
pkg/integration/tests/misc/recent_repos_with_long_names.go
Normal file
60
pkg/integration/tests/misc/recent_repos_with_long_names.go
Normal 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"))
|
||||
},
|
||||
})
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue