Compare commits

...
Author SHA1 Message Date
Jesse Duffield eaba9dd62d Land in the same panel when switching to a worktree 2023-07-16 14:37:49 +10:00
Jesse Duffield 3cb13f14bf Move current worktree to top of list 2023-07-16 14:23:31 +10:00
Jesse Duffield 5d52852df3 Prompt to switch to worktree when branch is checked out by other worktree 2023-07-16 14:14:09 +10:00
Jesse Duffield 7d4432c4b5 Use git lingo 2023-07-16 13:53:59 +10:00
Jesse Duffield a1235fa468 Improve name handling 2023-07-16 13:43:20 +10:00
Jesse Duffield 8b90811c65 Use sentence case 2023-07-16 12:23:35 +10:00
Jesse Duffield 2dd2b9f5e3 Refactor 2023-07-16 12:21:43 +10:00
Jesse Duffield e7484808e5 Update worktree model 2023-07-16 11:36:50 +10:00
Jesse Duffield 3245350bab Alert when attempting to enter the current worktree 2023-07-16 11:20:22 +10:00
Jesse Duffield 6e51dd1c85 Remove comment 2023-07-16 11:16:10 +10:00
Joel Baranick 77db982774 Address PR comments 2023-07-16 11:04:39 +10:00
Joel Baranick e16f56e492 Basic support for adding a worktree 2023-07-16 10:55:56 +10:00
Joel Baranick 3055944b5d Put all worktree i18n strings together
Use tabwriter to align worktree panel contents
2023-07-16 10:53:00 +10:00
Joel Baranick 6194b17ebb Improve worktree panel 2023-07-16 10:50:25 +10:00
Joel Baranick 15ffb34474 Style missing worktree as red and display better error when trying to switch to them
Use a broken link icon for missing worktrees
2023-07-16 10:48:22 +10:00
Joel Baranick 32409dbb2f Hide worktrees in the worktree panel if they point at a non-existing filesystem location.
Remove unneeded check when filtering out branches from non-current worktrees from the branch panel.
Add link icon for linked worktrees
2023-07-16 10:32:36 +10:00
Joel Baranick 4ec960f07d Update status to differentiate the main vs linked worktrees 2023-07-16 10:29:08 +10:00
Joel Baranick a7bdc6be01 Support for deleting a worktree 2023-07-16 10:23:17 +10:00
Joel Baranick 271f894106 Initial addition of support for worktrees 2023-07-16 10:20:36 +10:00
32 changed files with 924 additions and 29 deletions
+2
View File
@@ -39,3 +39,5 @@ test/results/**
oryxBuildBinary oryxBuildBinary
__debug_bin __debug_bin
.worktrees
+1
View File
@@ -116,6 +116,7 @@ func localisedTitle(tr *i18n.TranslationSet, str string) string {
"stash": tr.StashTitle, "stash": tr.StashTitle,
"suggestions": tr.SuggestionsCheatsheetTitle, "suggestions": tr.SuggestionsCheatsheetTitle,
"extras": tr.ExtrasTitle, "extras": tr.ExtrasTitle,
"worktrees": tr.WorktreesTitle,
} }
title, ok := contextTitleMap[str] title, ok := contextTitleMap[str]
+6
View File
@@ -37,6 +37,7 @@ type GitCommand struct {
Tag *git_commands.TagCommands Tag *git_commands.TagCommands
WorkingTree *git_commands.WorkingTreeCommands WorkingTree *git_commands.WorkingTreeCommands
Bisect *git_commands.BisectCommands Bisect *git_commands.BisectCommands
Worktree *git_commands.WorktreeCommands
Loaders Loaders Loaders Loaders
} }
@@ -50,6 +51,7 @@ type Loaders struct {
RemoteLoader *git_commands.RemoteLoader RemoteLoader *git_commands.RemoteLoader
StashLoader *git_commands.StashLoader StashLoader *git_commands.StashLoader
TagLoader *git_commands.TagLoader TagLoader *git_commands.TagLoader
Worktrees *git_commands.WorktreeLoader
} }
func NewGitCommand( func NewGitCommand(
@@ -127,12 +129,14 @@ func NewGitCommandAux(
}) })
patchCommands := git_commands.NewPatchCommands(gitCommon, rebaseCommands, commitCommands, statusCommands, stashCommands, patchBuilder) patchCommands := git_commands.NewPatchCommands(gitCommon, rebaseCommands, commitCommands, statusCommands, stashCommands, patchBuilder)
bisectCommands := git_commands.NewBisectCommands(gitCommon) bisectCommands := git_commands.NewBisectCommands(gitCommon)
worktreeCommands := git_commands.NewWorktreeCommands(gitCommon)
branchLoader := git_commands.NewBranchLoader(cmn, cmd, branchCommands.CurrentBranchInfo, configCommands) branchLoader := git_commands.NewBranchLoader(cmn, cmd, branchCommands.CurrentBranchInfo, configCommands)
commitFileLoader := git_commands.NewCommitFileLoader(cmn, cmd) commitFileLoader := git_commands.NewCommitFileLoader(cmn, cmd)
commitLoader := git_commands.NewCommitLoader(cmn, cmd, dotGitDir, statusCommands.RebaseMode, gitCommon) commitLoader := git_commands.NewCommitLoader(cmn, cmd, dotGitDir, statusCommands.RebaseMode, gitCommon)
reflogCommitLoader := git_commands.NewReflogCommitLoader(cmn, cmd) reflogCommitLoader := git_commands.NewReflogCommitLoader(cmn, cmd)
remoteLoader := git_commands.NewRemoteLoader(cmn, cmd, repo.Remotes) remoteLoader := git_commands.NewRemoteLoader(cmn, cmd, repo.Remotes)
worktreeLoader := git_commands.NewWorktreeLoader(cmn, cmd)
stashLoader := git_commands.NewStashLoader(cmn, cmd) stashLoader := git_commands.NewStashLoader(cmn, cmd)
tagLoader := git_commands.NewTagLoader(cmn, cmd) tagLoader := git_commands.NewTagLoader(cmn, cmd)
@@ -154,6 +158,7 @@ func NewGitCommandAux(
Tag: tagCommands, Tag: tagCommands,
Bisect: bisectCommands, Bisect: bisectCommands,
WorkingTree: workingTreeCommands, WorkingTree: workingTreeCommands,
Worktree: worktreeCommands,
Loaders: Loaders{ Loaders: Loaders{
BranchLoader: branchLoader, BranchLoader: branchLoader,
CommitFileLoader: commitFileLoader, CommitFileLoader: commitFileLoader,
@@ -161,6 +166,7 @@ func NewGitCommandAux(
FileLoader: fileLoader, FileLoader: fileLoader,
ReflogCommitLoader: reflogCommitLoader, ReflogCommitLoader: reflogCommitLoader,
RemoteLoader: remoteLoader, RemoteLoader: remoteLoader,
Worktrees: worktreeLoader,
StashLoader: stashLoader, StashLoader: stashLoader,
TagLoader: tagLoader, TagLoader: tagLoader,
}, },
+19 -9
View File
@@ -2,6 +2,7 @@ package git_commands
import ( import (
"fmt" "fmt"
"os"
"regexp" "regexp"
"strings" "strings"
@@ -117,6 +118,11 @@ outer:
} }
func (self *BranchLoader) obtainBranches() []*models.Branch { func (self *BranchLoader) obtainBranches() []*models.Branch {
currentDir, err := os.Getwd()
if err != nil {
panic(err)
}
output, err := self.getRawBranches() output, err := self.getRawBranches()
if err != nil { if err != nil {
panic(err) panic(err)
@@ -138,7 +144,7 @@ func (self *BranchLoader) obtainBranches() []*models.Branch {
return nil, false return nil, false
} }
return obtainBranch(split), true return obtainBranch(split, currentDir), true
}) })
} }
@@ -166,28 +172,32 @@ var branchFields = []string{
"upstream:track", "upstream:track",
"subject", "subject",
fmt.Sprintf("objectname:short=%d", utils.COMMIT_HASH_SHORT_SIZE), fmt.Sprintf("objectname:short=%d", utils.COMMIT_HASH_SHORT_SIZE),
"worktreepath",
} }
// Obtain branch information from parsed line output of getRawBranches() // Obtain branch information from parsed line output of getRawBranches()
func obtainBranch(split []string) *models.Branch { func obtainBranch(split []string, currentDir string) *models.Branch {
headMarker := split[0] headMarker := split[0]
fullName := split[1] fullName := split[1]
upstreamName := split[2] upstreamName := split[2]
track := split[3] track := split[3]
subject := split[4] subject := split[4]
commitHash := split[5] commitHash := split[5]
branchDir := split[6]
checkedOutByOtherWorktree := len(branchDir) > 0 && branchDir != currentDir
name := strings.TrimPrefix(fullName, "heads/") name := strings.TrimPrefix(fullName, "heads/")
pushables, pullables, gone := parseUpstreamInfo(upstreamName, track) pushables, pullables, gone := parseUpstreamInfo(upstreamName, track)
return &models.Branch{ return &models.Branch{
Name: name, Name: name,
Pushables: pushables, Pushables: pushables,
Pullables: pullables, Pullables: pullables,
UpstreamGone: gone, UpstreamGone: gone,
Head: headMarker == "*", Head: headMarker == "*",
Subject: subject, Subject: subject,
CommitHash: commitHash, CommitHash: commitHash,
CheckedOutByOtherWorktree: checkedOutByOtherWorktree,
} }
} }
@@ -81,7 +81,7 @@ func TestObtainBranch(t *testing.T) {
for _, s := range scenarios { for _, s := range scenarios {
t.Run(s.testName, func(t *testing.T) { t.Run(s.testName, func(t *testing.T) {
branch := obtainBranch(s.input) branch := obtainBranch(s.input, "current-dir")
assert.EqualValues(t, s.expectedBranch, branch) assert.EqualValues(t, s.expectedBranch, branch)
}) })
} }
+62
View File
@@ -0,0 +1,62 @@
package git_commands
import (
"errors"
"fmt"
"io/fs"
"log"
"os"
"github.com/jesseduffield/lazygit/pkg/commands/models"
)
type WorktreeCommands struct {
*GitCommon
}
func NewWorktreeCommands(gitCommon *GitCommon) *WorktreeCommands {
return &WorktreeCommands{
GitCommon: gitCommon,
}
}
func (self *WorktreeCommands) New(worktreePath string, committish string) error {
cmdArgs := NewGitCmd("worktree").Arg("add", worktreePath, committish).ToArgv()
return self.cmd.New(cmdArgs).Run()
}
func (self *WorktreeCommands) Delete(worktreePath string, force bool) error {
cmdArgs := NewGitCmd("worktree").Arg("remove").ArgIf(force, "-f").Arg(worktreePath).ToArgv()
return self.cmd.New(cmdArgs).Run()
}
func (self *WorktreeCommands) IsCurrentWorktree(w *models.Worktree) bool {
return IsCurrentWorktree(w)
}
func IsCurrentWorktree(w *models.Worktree) bool {
pwd, err := os.Getwd()
if err != nil {
log.Fatalln(err.Error())
}
return EqualPath(pwd, w.Path)
}
func (self *WorktreeCommands) IsWorktreePathMissing(w *models.Worktree) bool {
if _, err := os.Stat(w.Path); err != nil {
if errors.Is(err, fs.ErrNotExist) {
return true
}
log.Fatalln(fmt.Errorf("failed to check if worktree path `%s` exists\n%w", w.Path, err).Error())
}
return false
}
// checks if two paths are equal
// TODO: support relative paths
func EqualPath(a string, b string) bool {
return a == b
}
@@ -0,0 +1,178 @@
package git_commands
import (
"os"
"strings"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/jesseduffield/lazygit/pkg/common"
"github.com/samber/lo"
)
type WorktreeLoader struct {
*common.Common
cmd oscommands.ICmdObjBuilder
}
func NewWorktreeLoader(
common *common.Common,
cmd oscommands.ICmdObjBuilder,
) *WorktreeLoader {
return &WorktreeLoader{
Common: common,
cmd: cmd,
}
}
func (self *WorktreeLoader) GetWorktrees() ([]*models.Worktree, error) {
cmdArgs := NewGitCmd("worktree").Arg("list", "--porcelain", "-z").ToArgv()
worktreesOutput, err := self.cmd.New(cmdArgs).DontLog().RunWithOutput()
if err != nil {
return nil, err
}
splitLines := strings.Split(worktreesOutput, "\x00")
var worktrees []*models.Worktree
var current *models.Worktree
for _, splitLine := range splitLines {
if len(splitLine) == 0 && current != nil {
worktrees = append(worktrees, current)
current = nil
continue
}
if strings.HasPrefix(splitLine, "worktree ") {
path := strings.SplitN(splitLine, " ", 2)[1]
current = &models.Worktree{
IsMain: len(worktrees) == 0,
Path: path,
}
} else if strings.HasPrefix(splitLine, "branch ") {
branch := strings.SplitN(splitLine, " ", 2)[1]
current.Branch = strings.TrimPrefix(branch, "refs/heads/")
}
}
names := getUniqueNamesFromPaths(lo.Map(worktrees, func(worktree *models.Worktree, _ int) string {
return worktree.Path
}))
for index, worktree := range worktrees {
worktree.NameField = names[index]
}
pwd, err := os.Getwd()
if err != nil {
return nil, err
}
// move current worktree to the top
for i, worktree := range worktrees {
if EqualPath(worktree.Path, pwd) {
worktrees = append(worktrees[:i], worktrees[i+1:]...)
worktrees = append([]*models.Worktree{worktree}, worktrees...)
break
}
}
return worktrees, nil
}
type pathWithIndexT struct {
path string
index int
}
type nameWithIndexT struct {
name string
index int
}
func getUniqueNamesFromPaths(paths []string) []string {
pathsWithIndex := lo.Map(paths, func(path string, index int) pathWithIndexT {
return pathWithIndexT{path, index}
})
namesWithIndex := getUniqueNamesFromPathsAux(pathsWithIndex, 0)
// now sort based on index
result := make([]string, len(namesWithIndex))
for _, nameWithIndex := range namesWithIndex {
result[nameWithIndex.index] = nameWithIndex.name
}
return result
}
func getUniqueNamesFromPathsAux(paths []pathWithIndexT, depth int) []nameWithIndexT {
// If we have no paths, return an empty array
if len(paths) == 0 {
return []nameWithIndexT{}
}
// If we have only one path, return the last segment of the path
if len(paths) == 1 {
path := paths[0]
return []nameWithIndexT{{index: path.index, name: sliceAtDepth(path.path, depth)}}
}
// group the paths by their value at the specified depth
groups := make(map[string][]pathWithIndexT)
for _, path := range paths {
value := valueAtDepth(path.path, depth)
groups[value] = append(groups[value], path)
}
result := []nameWithIndexT{}
for _, group := range groups {
if len(group) == 1 {
path := group[0]
result = append(result, nameWithIndexT{index: path.index, name: sliceAtDepth(path.path, depth)})
} else {
result = append(result, getUniqueNamesFromPathsAux(group, depth+1)...)
}
}
return result
}
// if the path is /a/b/c/d, and the depth is 0, the value is 'd'. If the depth is 1, the value is 'c', etc
func valueAtDepth(path string, depth int) string {
path = strings.TrimPrefix(path, "/")
path = strings.TrimSuffix(path, "/")
// Split the path into segments
segments := strings.Split(path, "/")
// Get the length of segments
length := len(segments)
// If the depth is greater than the length of segments, return an empty string
if depth >= length {
return ""
}
// Return the segment at the specified depth from the end of the path
return segments[length-1-depth]
}
// if the path is /a/b/c/d, and the depth is 0, the value is 'd'. If the depth is 1, the value is 'b/c', etc
func sliceAtDepth(path string, depth int) string {
path = strings.TrimPrefix(path, "/")
path = strings.TrimSuffix(path, "/")
// Split the path into segments
segments := strings.Split(path, "/")
// Get the length of segments
length := len(segments)
// If the depth is greater than or equal to the length of segments, return an empty string
if depth >= length {
return ""
}
// Join the segments from the specified depth till end of the path
return strings.Join(segments[length-1-depth:], "/")
}
@@ -0,0 +1,52 @@
package git_commands
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestGetUniqueNamesFromPaths(t *testing.T) {
for _, scenario := range []struct {
input []string
expected []string
}{
{
input: []string{},
expected: []string{},
},
{
input: []string{
"/my/path/feature/one",
},
expected: []string{
"one",
},
},
{
input: []string{
"/my/path/feature/one/",
},
expected: []string{
"one",
},
},
{
input: []string{
"/a/b/c/d",
"/a/b/c/e",
"/a/b/f/d",
"/a/e/c/d",
},
expected: []string{
"b/c/d",
"e",
"f/d",
"e/c/d",
},
},
} {
actual := getUniqueNamesFromPaths(scenario.input)
assert.EqualValues(t, scenario.expected, actual)
}
}
+2
View File
@@ -26,6 +26,8 @@ type Branch struct {
Subject string Subject string
// commit hash // commit hash
CommitHash string CommitHash string
CheckedOutByOtherWorktree bool
} }
func (b *Branch) FullRefName() string { func (b *Branch) FullRefName() string {
+31
View File
@@ -0,0 +1,31 @@
package models
// A git worktree
type Worktree struct {
// if false, this is a linked worktree
IsMain bool
Path string
Branch string
// based on the path, but uniquified
NameField string
}
func (w *Worktree) RefName() string {
return w.Name()
}
func (w *Worktree) ID() string {
return w.Path
}
func (w *Worktree) Description() string {
return w.RefName()
}
func (w *Worktree) Name() string {
return w.NameField
}
func (w *Worktree) Main() bool {
return w.IsMain
}
+13
View File
@@ -376,3 +376,16 @@ func (self *ContextMgr) AllPatchExplorer() []types.IPatchExplorerContext {
return listContexts return listContexts
} }
func (self *ContextMgr) ContextForKey(key types.ContextKey) types.Context {
self.RLock()
defer self.RUnlock()
for _, context := range self.allContexts.Flatten() {
if context.GetKey() == key {
return context
}
}
return nil
}
+7
View File
@@ -5,12 +5,16 @@ import (
) )
const ( const (
// used as a nil value when passing a context key as an arg
NO_CONTEXT types.ContextKey = "none"
GLOBAL_CONTEXT_KEY types.ContextKey = "global" GLOBAL_CONTEXT_KEY types.ContextKey = "global"
STATUS_CONTEXT_KEY types.ContextKey = "status" STATUS_CONTEXT_KEY types.ContextKey = "status"
SNAKE_CONTEXT_KEY types.ContextKey = "snake" SNAKE_CONTEXT_KEY types.ContextKey = "snake"
FILES_CONTEXT_KEY types.ContextKey = "files" FILES_CONTEXT_KEY types.ContextKey = "files"
LOCAL_BRANCHES_CONTEXT_KEY types.ContextKey = "localBranches" LOCAL_BRANCHES_CONTEXT_KEY types.ContextKey = "localBranches"
REMOTES_CONTEXT_KEY types.ContextKey = "remotes" REMOTES_CONTEXT_KEY types.ContextKey = "remotes"
WORKTREES_CONTEXT_KEY types.ContextKey = "worktrees"
REMOTE_BRANCHES_CONTEXT_KEY types.ContextKey = "remoteBranches" REMOTE_BRANCHES_CONTEXT_KEY types.ContextKey = "remoteBranches"
TAGS_CONTEXT_KEY types.ContextKey = "tags" TAGS_CONTEXT_KEY types.ContextKey = "tags"
LOCAL_COMMITS_CONTEXT_KEY types.ContextKey = "commits" LOCAL_COMMITS_CONTEXT_KEY types.ContextKey = "commits"
@@ -49,6 +53,7 @@ var AllContextKeys = []types.ContextKey{
FILES_CONTEXT_KEY, FILES_CONTEXT_KEY,
LOCAL_BRANCHES_CONTEXT_KEY, LOCAL_BRANCHES_CONTEXT_KEY,
REMOTES_CONTEXT_KEY, REMOTES_CONTEXT_KEY,
WORKTREES_CONTEXT_KEY,
REMOTE_BRANCHES_CONTEXT_KEY, REMOTE_BRANCHES_CONTEXT_KEY,
TAGS_CONTEXT_KEY, TAGS_CONTEXT_KEY,
LOCAL_COMMITS_CONTEXT_KEY, LOCAL_COMMITS_CONTEXT_KEY,
@@ -84,6 +89,7 @@ type ContextTree struct {
LocalCommits *LocalCommitsContext LocalCommits *LocalCommitsContext
CommitFiles *CommitFilesContext CommitFiles *CommitFilesContext
Remotes *RemotesContext Remotes *RemotesContext
Worktrees *WorktreesContext
Submodules *SubmodulesContext Submodules *SubmodulesContext
RemoteBranches *RemoteBranchesContext RemoteBranches *RemoteBranchesContext
ReflogCommits *ReflogCommitsContext ReflogCommits *ReflogCommitsContext
@@ -121,6 +127,7 @@ func (self *ContextTree) Flatten() []types.Context {
self.Files, self.Files,
self.SubCommits, self.SubCommits,
self.Remotes, self.Remotes,
self.Worktrees,
self.RemoteBranches, self.RemoteBranches,
self.Tags, self.Tags,
self.Branches, self.Branches,
+1
View File
@@ -29,6 +29,7 @@ func NewContextTree(c *ContextCommon) *ContextTree {
Submodules: NewSubmodulesContext(c), Submodules: NewSubmodulesContext(c),
Menu: NewMenuContext(c), Menu: NewMenuContext(c),
Remotes: NewRemotesContext(c), Remotes: NewRemotesContext(c),
Worktrees: NewWorktreesContext(c),
RemoteBranches: NewRemoteBranchesContext(c), RemoteBranches: NewRemoteBranchesContext(c),
LocalCommits: NewLocalCommitsContext(c), LocalCommits: NewLocalCommitsContext(c),
CommitFiles: commitFilesContext, CommitFiles: commitFilesContext,
+56
View File
@@ -0,0 +1,56 @@
package context
import (
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/gui/presentation"
"github.com/jesseduffield/lazygit/pkg/gui/types"
)
type WorktreesContext struct {
*FilteredListViewModel[*models.Worktree]
*ListContextTrait
}
var _ types.IListContext = (*WorktreesContext)(nil)
func NewWorktreesContext(c *ContextCommon) *WorktreesContext {
viewModel := NewFilteredListViewModel(
func() []*models.Worktree { return c.Model().Worktrees },
func(Worktree *models.Worktree) []string {
return []string{Worktree.Name()}
},
)
getDisplayStrings := func(startIdx int, length int) [][]string {
return presentation.GetWorktreeDisplayStrings(
c.Model().Worktrees,
c.Git().Worktree.IsCurrentWorktree,
c.Git().Worktree.IsWorktreePathMissing,
)
}
return &WorktreesContext{
FilteredListViewModel: viewModel,
ListContextTrait: &ListContextTrait{
Context: NewSimpleContext(NewBaseContext(NewBaseContextOpts{
View: c.Views().Worktrees,
WindowName: "branches",
Key: WORKTREES_CONTEXT_KEY,
Kind: types.SIDE_CONTEXT,
Focusable: true,
})),
list: viewModel,
getDisplayStrings: getDisplayStrings,
c: c,
},
}
}
func (self *WorktreesContext) GetSelectedItemId() string {
item := self.GetSelected()
if item == nil {
return ""
}
return item.ID()
}
+21 -3
View File
@@ -45,7 +45,18 @@ func (gui *Gui) resetHelpersAndControllers() {
patchBuildingHelper := helpers.NewPatchBuildingHelper(helperCommon) patchBuildingHelper := helpers.NewPatchBuildingHelper(helperCommon)
stagingHelper := helpers.NewStagingHelper(helperCommon) stagingHelper := helpers.NewStagingHelper(helperCommon)
mergeConflictsHelper := helpers.NewMergeConflictsHelper(helperCommon) mergeConflictsHelper := helpers.NewMergeConflictsHelper(helperCommon)
refreshHelper := helpers.NewRefreshHelper(helperCommon, refsHelper, rebaseHelper, patchBuildingHelper, stagingHelper, mergeConflictsHelper, gui.fileWatcher) reposHelper := helpers.NewRecentReposHelper(helperCommon, recordDirectoryHelper, gui.onNewRepo)
worktreeHelper := helpers.NewWorktreeHelper(helperCommon, reposHelper)
refreshHelper := helpers.NewRefreshHelper(
helperCommon,
refsHelper,
rebaseHelper,
patchBuildingHelper,
stagingHelper,
mergeConflictsHelper,
worktreeHelper,
gui.fileWatcher,
)
diffHelper := helpers.NewDiffHelper(helperCommon) diffHelper := helpers.NewDiffHelper(helperCommon)
cherryPickHelper := helpers.NewCherryPickHelper( cherryPickHelper := helpers.NewCherryPickHelper(
helperCommon, helperCommon,
@@ -84,7 +95,7 @@ func (gui *Gui) resetHelpersAndControllers() {
Commits: commitsHelper, Commits: commitsHelper,
Snake: helpers.NewSnakeHelper(helperCommon), Snake: helpers.NewSnakeHelper(helperCommon),
Diff: diffHelper, Diff: diffHelper,
Repos: helpers.NewRecentReposHelper(helperCommon, recordDirectoryHelper, gui.onNewRepo), Repos: reposHelper,
RecordDirectory: recordDirectoryHelper, RecordDirectory: recordDirectoryHelper,
Update: helpers.NewUpdateHelper(helperCommon, gui.Updater), Update: helpers.NewUpdateHelper(helperCommon, gui.Updater),
Window: windowHelper, Window: windowHelper,
@@ -99,7 +110,8 @@ func (gui *Gui) resetHelpersAndControllers() {
modeHelper, modeHelper,
appStatusHelper, appStatusHelper,
), ),
Search: helpers.NewSearchHelper(helperCommon), Search: helpers.NewSearchHelper(helperCommon),
Worktree: worktreeHelper,
} }
gui.CustomCommandsClient = custom_commands.NewClient( gui.CustomCommandsClient = custom_commands.NewClient(
@@ -138,6 +150,7 @@ func (gui *Gui) resetHelpersAndControllers() {
common, common,
func(branches []*models.RemoteBranch) { gui.State.Model.RemoteBranches = branches }, func(branches []*models.RemoteBranch) { gui.State.Model.RemoteBranches = branches },
) )
worktreesController := controllers.NewWorktreesController(common)
undoController := controllers.NewUndoController(common) undoController := controllers.NewUndoController(common)
globalController := controllers.NewGlobalController(common) globalController := controllers.NewGlobalController(common)
contextLinesController := controllers.NewContextLinesController(common) contextLinesController := controllers.NewContextLinesController(common)
@@ -177,6 +190,7 @@ func (gui *Gui) resetHelpersAndControllers() {
for _, context := range []types.Context{ for _, context := range []types.Context{
gui.State.Contexts.Status, gui.State.Contexts.Status,
gui.State.Contexts.Remotes, gui.State.Contexts.Remotes,
gui.State.Contexts.Worktrees,
gui.State.Contexts.Tags, gui.State.Contexts.Tags,
gui.State.Contexts.Branches, gui.State.Contexts.Branches,
gui.State.Contexts.RemoteBranches, gui.State.Contexts.RemoteBranches,
@@ -298,6 +312,10 @@ func (gui *Gui) resetHelpersAndControllers() {
remotesController, remotesController,
) )
controllers.AttachControllers(gui.State.Contexts.Worktrees,
worktreesController,
)
controllers.AttachControllers(gui.State.Contexts.Stash, controllers.AttachControllers(gui.State.Contexts.Stash,
stashController, stashController,
) )
@@ -202,10 +202,37 @@ func (self *BranchesController) press(selectedBranch *models.Branch) error {
return self.c.ErrorMsg(self.c.Tr.AlreadyCheckedOutBranch) return self.c.ErrorMsg(self.c.Tr.AlreadyCheckedOutBranch)
} }
if selectedBranch.CheckedOutByOtherWorktree {
worktreeForRef, ok := self.worktreeForRef(selectedBranch.Name)
if ok && !self.c.Git().Worktree.IsCurrentWorktree(worktreeForRef) {
return self.promptToCheckoutWorktree(worktreeForRef)
}
}
self.c.LogAction(self.c.Tr.Actions.CheckoutBranch) self.c.LogAction(self.c.Tr.Actions.CheckoutBranch)
return self.c.Helpers().Refs.CheckoutRef(selectedBranch.Name, types.CheckoutRefOptions{}) return self.c.Helpers().Refs.CheckoutRef(selectedBranch.Name, types.CheckoutRefOptions{})
} }
func (self *BranchesController) worktreeForRef(ref string) (*models.Worktree, bool) {
for _, worktree := range self.c.Model().Worktrees {
if worktree.Branch == ref {
return worktree, true
}
}
return nil, false
}
func (self *BranchesController) promptToCheckoutWorktree(worktree *models.Worktree) error {
return self.c.Confirm(types.ConfirmOpts{
Title: "Switch to worktree",
Prompt: fmt.Sprintf("This branch is checked out by worktree %s. Do you want to switch to that worktree?", worktree.Name()),
HandleConfirm: func() error {
return self.c.Helpers().Worktree.Switch(worktree, context.LOCAL_BRANCHES_CONTEXT_KEY)
},
})
}
func (self *BranchesController) handleCreatePullRequest(selectedBranch *models.Branch) error { func (self *BranchesController) handleCreatePullRequest(selectedBranch *models.Branch) error {
return self.createPullRequest(selectedBranch.Name, "") return self.createPullRequest(selectedBranch.Name, "")
} }
+2
View File
@@ -47,6 +47,7 @@ type Helpers struct {
AppStatus *AppStatusHelper AppStatus *AppStatusHelper
WindowArrangement *WindowArrangementHelper WindowArrangement *WindowArrangementHelper
Search *SearchHelper Search *SearchHelper
Worktree *WorktreeHelper
} }
func NewStubHelpers() *Helpers { func NewStubHelpers() *Helpers {
@@ -80,5 +81,6 @@ func NewStubHelpers() *Helpers {
AppStatus: &AppStatusHelper{}, AppStatus: &AppStatusHelper{},
WindowArrangement: &WindowArrangementHelper{}, WindowArrangement: &WindowArrangementHelper{},
Search: &SearchHelper{}, Search: &SearchHelper{},
Worktree: &WorktreeHelper{},
} }
} }
@@ -27,6 +27,7 @@ type RefreshHelper struct {
patchBuildingHelper *PatchBuildingHelper patchBuildingHelper *PatchBuildingHelper
stagingHelper *StagingHelper stagingHelper *StagingHelper
mergeConflictsHelper *MergeConflictsHelper mergeConflictsHelper *MergeConflictsHelper
worktreeHelper *WorktreeHelper
fileWatcher types.IFileWatcher fileWatcher types.IFileWatcher
} }
@@ -37,6 +38,7 @@ func NewRefreshHelper(
patchBuildingHelper *PatchBuildingHelper, patchBuildingHelper *PatchBuildingHelper,
stagingHelper *StagingHelper, stagingHelper *StagingHelper,
mergeConflictsHelper *MergeConflictsHelper, mergeConflictsHelper *MergeConflictsHelper,
worktreeHelper *WorktreeHelper,
fileWatcher types.IFileWatcher, fileWatcher types.IFileWatcher,
) *RefreshHelper { ) *RefreshHelper {
return &RefreshHelper{ return &RefreshHelper{
@@ -46,6 +48,7 @@ func NewRefreshHelper(
patchBuildingHelper: patchBuildingHelper, patchBuildingHelper: patchBuildingHelper,
stagingHelper: stagingHelper, stagingHelper: stagingHelper,
mergeConflictsHelper: mergeConflictsHelper, mergeConflictsHelper: mergeConflictsHelper,
worktreeHelper: worktreeHelper,
fileWatcher: fileWatcher, fileWatcher: fileWatcher,
} }
} }
@@ -77,6 +80,7 @@ func (self *RefreshHelper) Refresh(options types.RefreshOptions) error {
types.REFLOG, types.REFLOG,
types.TAGS, types.TAGS,
types.REMOTES, types.REMOTES,
types.WORKTREES,
types.STATUS, types.STATUS,
types.BISECT_INFO, types.BISECT_INFO,
types.STAGING, types.STAGING,
@@ -128,6 +132,10 @@ func (self *RefreshHelper) Refresh(options types.RefreshOptions) error {
refresh(func() { _ = self.refreshRemotes() }) refresh(func() { _ = self.refreshRemotes() })
} }
if scopeSet.Includes(types.WORKTREES) {
refresh(func() { _ = self.refreshWorktrees() })
}
if scopeSet.Includes(types.STAGING) { if scopeSet.Includes(types.STAGING) {
refresh(func() { _ = self.stagingHelper.RefreshStagingPanel(types.OnFocusOpts{}) }) refresh(func() { _ = self.stagingHelper.RefreshStagingPanel(types.OnFocusOpts{}) })
} }
@@ -170,6 +178,7 @@ func getScopeNames(scopes []types.RefreshableView) []string {
types.REFLOG: "reflog", types.REFLOG: "reflog",
types.TAGS: "tags", types.TAGS: "tags",
types.REMOTES: "remotes", types.REMOTES: "remotes",
types.WORKTREES: "worktrees",
types.STATUS: "status", types.STATUS: "status",
types.BISECT_INFO: "bisect", types.BISECT_INFO: "bisect",
types.STAGING: "staging", types.STAGING: "staging",
@@ -557,6 +566,17 @@ func (self *RefreshHelper) refreshRemotes() error {
return nil return nil
} }
func (self *RefreshHelper) refreshWorktrees() error {
worktrees, err := self.c.Git().Loaders.Worktrees.GetWorktrees()
if err != nil {
return self.c.Error(err)
}
self.c.Model().Worktrees = worktrees
return self.c.PostRefreshUpdate(self.c.Contexts().Worktrees)
}
func (self *RefreshHelper) refreshStashEntries() error { func (self *RefreshHelper) refreshStashEntries() error {
self.c.Model().StashEntries = self.c.Git().Loaders.StashLoader. self.c.Model().StashEntries = self.c.Git().Loaders.StashLoader.
GetStashEntries(self.c.Modes().Filtering.GetPath()) GetStashEntries(self.c.Modes().Filtering.GetPath())
@@ -587,6 +607,10 @@ func (self *RefreshHelper) refreshStatus() {
name := presentation.GetBranchTextStyle(currentBranch.Name).Sprint(currentBranch.Name) name := presentation.GetBranchTextStyle(currentBranch.Name).Sprint(currentBranch.Name)
repoName := utils.GetCurrentRepoName() repoName := utils.GetCurrentRepoName()
mainWorktreeName := self.worktreeHelper.GetMainWorktreeName()
if repoName != mainWorktreeName {
repoName = fmt.Sprintf("%s(%s)", mainWorktreeName, style.FgBlue.Sprint(repoName))
}
status += fmt.Sprintf("%s → %s ", repoName, name) status += fmt.Sprintf("%s → %s ", repoName, name)
self.c.SetViewContent(self.c.Views().Status, status) self.c.SetViewContent(self.c.Views().Status, status)
+11 -6
View File
@@ -12,13 +12,14 @@ import (
"github.com/jesseduffield/lazygit/pkg/commands" "github.com/jesseduffield/lazygit/pkg/commands"
"github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/env" "github.com/jesseduffield/lazygit/pkg/env"
"github.com/jesseduffield/lazygit/pkg/gui/context"
"github.com/jesseduffield/lazygit/pkg/gui/presentation/icons" "github.com/jesseduffield/lazygit/pkg/gui/presentation/icons"
"github.com/jesseduffield/lazygit/pkg/gui/style" "github.com/jesseduffield/lazygit/pkg/gui/style"
"github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/utils" "github.com/jesseduffield/lazygit/pkg/utils"
) )
type onNewRepoFn func(startArgs appTypes.StartArgs, reuseState bool) error type onNewRepoFn func(startArgs appTypes.StartArgs, reuseState bool, contextKey types.ContextKey) error
// helps switch back and forth between repos // helps switch back and forth between repos
type ReposHelper struct { type ReposHelper struct {
@@ -46,7 +47,7 @@ func (self *ReposHelper) EnterSubmodule(submodule *models.SubmoduleConfig) error
} }
self.c.State().GetRepoPathStack().Push(wd) self.c.State().GetRepoPathStack().Push(wd)
return self.DispatchSwitchToRepo(submodule.Path, true) return self.DispatchSwitchToRepo(submodule.Path, true, context.NO_CONTEXT)
} }
func (self *ReposHelper) getCurrentBranch(path string) string { func (self *ReposHelper) getCurrentBranch(path string) string {
@@ -129,7 +130,7 @@ func (self *ReposHelper) CreateRecentReposMenu() error {
// if we were in a submodule, we want to forget about that stack of repos // 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 // so that hitting escape in the new repo does nothing
self.c.State().GetRepoPathStack().Clear() self.c.State().GetRepoPathStack().Clear()
return self.DispatchSwitchToRepo(path, false) return self.DispatchSwitchToRepo(path, false, context.NO_CONTEXT)
}, },
} }
}) })
@@ -137,7 +138,11 @@ func (self *ReposHelper) CreateRecentReposMenu() error {
return self.c.Menu(types.CreateMenuOptions{Title: self.c.Tr.RecentRepos, Items: menuItems}) return self.c.Menu(types.CreateMenuOptions{Title: self.c.Tr.RecentRepos, Items: menuItems})
} }
func (self *ReposHelper) DispatchSwitchToRepo(path string, reuse bool) error { func (self *ReposHelper) DispatchSwitchToRepo(path string, reuse bool, contextKey types.ContextKey) error {
return self.DispatchSwitchTo(path, reuse, self.c.Tr.ErrRepositoryMovedOrDeleted, contextKey)
}
func (self *ReposHelper) DispatchSwitchTo(path string, reuse bool, errMsg string, contextKey types.ContextKey) error {
env.UnsetGitDirEnvs() env.UnsetGitDirEnvs()
originalPath, err := os.Getwd() originalPath, err := os.Getwd()
if err != nil { if err != nil {
@@ -146,7 +151,7 @@ func (self *ReposHelper) DispatchSwitchToRepo(path string, reuse bool) error {
if err := os.Chdir(path); err != nil { if err := os.Chdir(path); err != nil {
if os.IsNotExist(err) { if os.IsNotExist(err) {
return self.c.ErrorMsg(self.c.Tr.ErrRepositoryMovedOrDeleted) return self.c.ErrorMsg(errMsg)
} }
return err return err
} }
@@ -171,5 +176,5 @@ func (self *ReposHelper) DispatchSwitchToRepo(path string, reuse bool) error {
self.c.Mutexes().RefreshingFilesMutex.Lock() self.c.Mutexes().RefreshingFilesMutex.Lock()
defer self.c.Mutexes().RefreshingFilesMutex.Unlock() defer self.c.Mutexes().RefreshingFilesMutex.Unlock()
return self.onNewRepo(appTypes.StartArgs{}, reuse) return self.onNewRepo(appTypes.StartArgs{}, reuse, contextKey)
} }
@@ -0,0 +1,93 @@
package helpers
import (
"errors"
"fmt"
"io/fs"
"log"
"os"
"github.com/jesseduffield/gocui"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/gui/types"
)
type IWorktreeHelper interface {
GetMainWorktreeName() string
GetCurrentWorktreeName() string
}
type WorktreeHelper struct {
c *HelperCommon
reposHelper *ReposHelper
}
func NewWorktreeHelper(c *HelperCommon, reposHelper *ReposHelper) *WorktreeHelper {
return &WorktreeHelper{
c: c,
reposHelper: reposHelper,
}
}
func (self *WorktreeHelper) GetMainWorktreeName() string {
for _, worktree := range self.c.Model().Worktrees {
if worktree.Main() {
return worktree.Name()
}
}
return ""
}
func (self *WorktreeHelper) IsCurrentWorktree(w *models.Worktree) bool {
pwd, err := os.Getwd()
if err != nil {
log.Fatalln(err.Error())
}
return pwd == w.Path
}
func (self *WorktreeHelper) IsWorktreePathMissing(w *models.Worktree) bool {
if _, err := os.Stat(w.Path); err != nil {
if errors.Is(err, fs.ErrNotExist) {
return true
}
log.Fatalln(fmt.Errorf("failed to check if worktree path `%s` exists\n%w", w.Path, err).Error())
}
return false
}
func (self *WorktreeHelper) NewWorktree() error {
return self.c.Prompt(types.PromptOpts{
Title: self.c.Tr.NewWorktreePath,
HandleConfirm: func(path string) error {
return self.c.Prompt(types.PromptOpts{
Title: self.c.Tr.NewWorktreePath,
HandleConfirm: func(committish string) error {
return self.c.WithWaitingStatus(self.c.Tr.AddingWorktree, func(gocui.Task) error {
self.c.LogAction(self.c.Tr.Actions.AddWorktree)
if err := self.c.Git().Worktree.New(sanitizedBranchName(path), committish); err != nil {
return err
}
return self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC})
})
},
})
},
})
}
func (self *WorktreeHelper) Switch(worktree *models.Worktree, contextKey types.ContextKey) error {
if self.c.Git().Worktree.IsCurrentWorktree(worktree) {
return self.c.ErrorMsg(self.c.Tr.AlreadyInWorktree)
}
self.c.LogAction(self.c.Tr.SwitchToWorktree)
// 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.reposHelper.DispatchSwitchTo(worktree.Path, true, self.c.Tr.ErrWorktreeMovedOrRemoved, contextKey)
}
+2 -1
View File
@@ -2,6 +2,7 @@ package controllers
import ( import (
"github.com/jesseduffield/gocui" "github.com/jesseduffield/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/context"
"github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/gui/types"
) )
@@ -77,7 +78,7 @@ func (self *QuitActions) Escape() error {
repoPathStack := self.c.State().GetRepoPathStack() repoPathStack := self.c.State().GetRepoPathStack()
if !repoPathStack.IsEmpty() { if !repoPathStack.IsEmpty() {
return self.c.Helpers().Repos.DispatchSwitchToRepo(repoPathStack.Pop(), true) return self.c.Helpers().Repos.DispatchSwitchToRepo(repoPathStack.Pop(), true, context.NO_CONTEXT)
} }
if self.c.UserConfig.QuitOnTopLevelReturn { if self.c.UserConfig.QuitOnTopLevelReturn {
+166
View File
@@ -0,0 +1,166 @@
package controllers
import (
"fmt"
"strings"
"text/tabwriter"
"github.com/jesseduffield/gocui"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/gui/context"
"github.com/jesseduffield/lazygit/pkg/gui/style"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/utils"
)
type WorktreesController struct {
baseController
c *ControllerCommon
}
var _ types.IController = &WorktreesController{}
func NewWorktreesController(
common *ControllerCommon,
) *WorktreesController {
return &WorktreesController{
baseController: baseController{},
c: common,
}
}
func (self *WorktreesController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
bindings := []*types.Binding{
{
Key: opts.GetKey(opts.Config.Universal.Select),
Handler: self.checkSelected(self.enter),
Description: self.c.Tr.SwitchToWorktree,
},
{
Key: opts.GetKey(opts.Config.Universal.Remove),
Handler: self.checkSelected(self.remove),
Description: self.c.Tr.RemoveWorktree,
},
{
Key: opts.GetKey(opts.Config.Universal.New),
Handler: self.add,
Description: self.c.Tr.CreateWorktree,
},
}
return bindings
}
func (self *WorktreesController) GetOnRenderToMain() func() error {
return func() error {
var task types.UpdateTask
worktree := self.context().GetSelected()
if worktree == nil {
task = types.NewRenderStringTask(self.c.Tr.NoWorktreesThisRepo)
} else {
main := ""
if worktree.Main() {
main = style.FgDefault.Sprintf(" %s", self.c.Tr.MainWorktree)
}
missing := ""
if self.c.Git().Worktree.IsWorktreePathMissing(worktree) {
missing = style.FgRed.Sprintf(" %s", self.c.Tr.MissingWorktree)
}
var builder strings.Builder
w := tabwriter.NewWriter(&builder, 0, 0, 2, ' ', 0)
_, _ = fmt.Fprintf(w, "%s:\t%s%s\n", self.c.Tr.Name, style.FgGreen.Sprint(worktree.Name()), main)
_, _ = fmt.Fprintf(w, "%s:\t%s\n", self.c.Tr.Branch, style.FgYellow.Sprint(worktree.Branch))
_, _ = fmt.Fprintf(w, "%s:\t%s%s\n", self.c.Tr.Path, style.FgCyan.Sprint(worktree.Path), missing)
_ = w.Flush()
task = types.NewRenderStringTask(builder.String())
}
return self.c.RenderToMainViews(types.RefreshMainOpts{
Pair: self.c.MainViewPairs().Normal,
Main: &types.ViewUpdateOpts{
Title: self.c.Tr.WorktreeTitle,
Task: task,
},
})
}
}
func (self *WorktreesController) add() error {
return self.c.Helpers().Worktree.NewWorktree()
}
func (self *WorktreesController) remove(worktree *models.Worktree) error {
if worktree.Main() {
return self.c.ErrorMsg(self.c.Tr.CantDeleteMainWorktree)
}
if self.c.Git().Worktree.IsCurrentWorktree(worktree) {
return self.c.ErrorMsg(self.c.Tr.CantDeleteCurrentWorktree)
}
return self.removeWithForce(worktree, false)
}
func (self *WorktreesController) removeWithForce(worktree *models.Worktree, force bool) error {
title := self.c.Tr.RemoveWorktreeTitle
var templateStr string
if force {
templateStr = self.c.Tr.ForceRemoveWorktreePrompt
} else {
templateStr = self.c.Tr.RemoveWorktreePrompt
}
message := utils.ResolvePlaceholderString(
templateStr,
map[string]string{
"worktreeName": worktree.Name(),
},
)
return self.c.Confirm(types.ConfirmOpts{
Title: title,
Prompt: message,
HandleConfirm: func() error {
return self.c.WithWaitingStatus(self.c.Tr.RemovingWorktree, func(gocui.Task) error {
self.c.LogAction(self.c.Tr.RemovingWorktree)
if err := self.c.Git().Worktree.Delete(worktree.Path, force); err != nil {
errMessage := err.Error()
if !force {
return self.removeWithForce(worktree, true)
}
return self.c.ErrorMsg(errMessage)
}
return self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.WORKTREES}})
})
},
})
}
func (self *WorktreesController) GetOnClick() func() error {
return self.checkSelected(self.enter)
}
func (self *WorktreesController) enter(worktree *models.Worktree) error {
return self.c.Helpers().Worktree.Switch(worktree, context.WORKTREES_CONTEXT_KEY)
}
func (self *WorktreesController) checkSelected(callback func(worktree *models.Worktree) error) func() error {
return func() error {
worktree := self.context().GetSelected()
if worktree == nil {
return nil
}
return callback(worktree)
}
}
func (self *WorktreesController) Context() types.Context {
return self.context()
}
func (self *WorktreesController) context() *context.WorktreesContext {
return self.c.Contexts().Worktrees
}
+17 -2
View File
@@ -274,7 +274,7 @@ func (self *GuiRepoState) GetSplitMainPanel() bool {
return self.SplitMainPanel return self.SplitMainPanel
} }
func (gui *Gui) onNewRepo(startArgs appTypes.StartArgs, reuseState bool) error { func (gui *Gui) onNewRepo(startArgs appTypes.StartArgs, reuseState bool, contextKey types.ContextKey) error {
var err error var err error
gui.git, err = commands.NewGitCommand( gui.git, err = commands.NewGitCommand(
gui.Common, gui.Common,
@@ -295,6 +295,17 @@ func (gui *Gui) onNewRepo(startArgs appTypes.StartArgs, reuseState bool) error {
return err return err
} }
// if a context key has been given, push that instead, and set its index to 0
if contextKey != context.NO_CONTEXT {
contextToPush = gui.c.ContextForKey(contextKey)
// when we pass a list context, the expectation is that our cursor goes to the top,
// because e.g. with worktrees, we'll show the current worktree at the top of the list.
listContext, ok := contextToPush.(types.IListContext)
if ok {
listContext.GetList().SetSelectedLineIdx(0)
}
}
if err := gui.c.PushContext(contextToPush); err != nil { if err := gui.c.PushContext(contextToPush); err != nil {
return err return err
} }
@@ -544,6 +555,10 @@ func (gui *Gui) viewTabMap() map[string][]context.TabView {
Tab: gui.c.Tr.TagsTitle, Tab: gui.c.Tr.TagsTitle,
ViewName: "tags", ViewName: "tags",
}, },
{
Tab: gui.c.Tr.WorktreesTitle,
ViewName: "worktrees",
},
}, },
"commits": { "commits": {
{ {
@@ -614,7 +629,7 @@ func (gui *Gui) Run(startArgs appTypes.StartArgs) error {
} }
// onNewRepo must be called after g.SetManager because SetManager deletes keybindings // onNewRepo must be called after g.SetManager because SetManager deletes keybindings
if err := gui.onNewRepo(startArgs, false); err != nil { if err := gui.onNewRepo(startArgs, false, context.NO_CONTEXT); err != nil {
return err return err
} }
+4
View File
@@ -76,6 +76,10 @@ func (self *guiCommon) Context() types.IContextMgr {
return self.gui.State.ContextMgr return self.gui.State.ContextMgr
} }
func (self *guiCommon) ContextForKey(key types.ContextKey) types.Context {
return self.gui.State.ContextMgr.ContextForKey(key)
}
func (self *guiCommon) ActivateContext(context types.Context) error { func (self *guiCommon) ActivateContext(context types.Context) error {
return self.gui.State.ContextMgr.ActivateContext(context, types.OnFocusOpts{}) return self.gui.State.ContextMgr.ActivateContext(context, types.OnFocusOpts{})
} }
+6
View File
@@ -12,6 +12,7 @@ import (
"github.com/jesseduffield/lazygit/pkg/i18n" "github.com/jesseduffield/lazygit/pkg/i18n"
"github.com/jesseduffield/lazygit/pkg/theme" "github.com/jesseduffield/lazygit/pkg/theme"
"github.com/jesseduffield/lazygit/pkg/utils" "github.com/jesseduffield/lazygit/pkg/utils"
"github.com/samber/lo"
) )
var branchPrefixColorCache = make(map[string]style.TextStyle) var branchPrefixColorCache = make(map[string]style.TextStyle)
@@ -49,6 +50,10 @@ func getBranchDisplayStrings(
coloredName := nameTextStyle.Sprint(displayName) coloredName := nameTextStyle.Sprint(displayName)
branchStatus := utils.WithPadding(ColoredBranchStatus(b, tr), 2, utils.AlignLeft) branchStatus := utils.WithPadding(ColoredBranchStatus(b, tr), 2, utils.AlignLeft)
if b.CheckedOutByOtherWorktree {
worktreeIcon := lo.Ternary(icons.IsIconEnabled(), icons.LINKED_WORKTREE_ICON, "(worktree)")
coloredName = fmt.Sprintf("%s %s", coloredName, style.FgDefault.Sprint(worktreeIcon))
}
coloredName = fmt.Sprintf("%s %s", coloredName, branchStatus) coloredName = fmt.Sprintf("%s %s", coloredName, branchStatus)
recencyColor := style.FgCyan recencyColor := style.FgCyan
@@ -58,6 +63,7 @@ func getBranchDisplayStrings(
res := make([]string, 0, 6) res := make([]string, 0, 6)
res = append(res, recencyColor.Sprint(b.Recency)) res = append(res, recencyColor.Sprint(b.Recency))
if icons.IsIconEnabled() { if icons.IsIconEnabled() {
res = append(res, nameTextStyle.Sprint(icons.IconForBranch(b))) res = append(res, nameTextStyle.Sprint(icons.IconForBranch(b)))
} }
+16 -7
View File
@@ -7,13 +7,15 @@ import (
) )
var ( var (
BRANCH_ICON = "\U000f062c" // 󰘬 BRANCH_ICON = "\U000f062c" // 󰘬
DETACHED_HEAD_ICON = "\ue729" //  DETACHED_HEAD_ICON = "\ue729" // 
TAG_ICON = "\uf02b" //  TAG_ICON = "\uf02b" // 
COMMIT_ICON = "\U000f0718" // 󰜘 COMMIT_ICON = "\U000f0718" // 󰜘
MERGE_COMMIT_ICON = "\U000f062d" // 󰘭 MERGE_COMMIT_ICON = "\U000f062d" // 󰘭
DEFAULT_REMOTE_ICON = "\uf02a2" // 󰊢 DEFAULT_REMOTE_ICON = "\uf02a2" // 󰊢
STASH_ICON = "\uf01c" //  STASH_ICON = "\uf01c" // 
LINKED_WORKTREE_ICON = "\uf838" // 
MISSING_LINKED_WORKTREE_ICON = "\uf839" // 
) )
var remoteIcons = map[string]string{ var remoteIcons = map[string]string{
@@ -68,3 +70,10 @@ func IconForRemote(remote *models.Remote) string {
func IconForStash(stash *models.StashEntry) string { func IconForStash(stash *models.StashEntry) string {
return STASH_ICON return STASH_ICON
} }
func IconForWorktree(missing bool) string {
if missing {
return MISSING_LINKED_WORKTREE_ICON
}
return LINKED_WORKTREE_ICON
}
+49
View File
@@ -0,0 +1,49 @@
package presentation
import (
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/gui/presentation/icons"
"github.com/jesseduffield/lazygit/pkg/gui/style"
"github.com/jesseduffield/lazygit/pkg/theme"
"github.com/samber/lo"
)
func GetWorktreeDisplayStrings(worktrees []*models.Worktree, isCurrent func(*models.Worktree) bool, isMissing func(*models.Worktree) bool) [][]string {
return lo.Map(worktrees, func(worktree *models.Worktree, _ int) []string {
return GetWorktreeDisplayString(
isCurrent(worktree),
isMissing(worktree),
worktree)
})
}
func GetWorktreeDisplayString(isCurrent bool, isPathMissing bool, worktree *models.Worktree) []string {
textStyle := theme.DefaultTextColor
current := ""
currentColor := style.FgCyan
if isCurrent {
current = " *"
currentColor = style.FgGreen
}
icon := icons.IconForWorktree(false)
if isPathMissing {
textStyle = style.FgRed
icon = icons.IconForWorktree(true)
}
res := []string{}
res = append(res, currentColor.Sprint(current))
if icons.IsIconEnabled() {
res = append(res, textStyle.Sprint(icon))
}
name := worktree.Name()
if worktree.Main() {
// TODO: i18n
name += " (main worktree)"
}
res = append(res, textStyle.Sprint(name))
return res
}
+2
View File
@@ -66,6 +66,7 @@ type IGuiCommon interface {
IsCurrentContext(Context) bool IsCurrentContext(Context) bool
// TODO: replace the above context-based methods with just using Context() e.g. replace PushContext() with Context().Push() // TODO: replace the above context-based methods with just using Context() e.g. replace PushContext() with Context().Push()
Context() IContextMgr Context() IContextMgr
ContextForKey(key ContextKey) Context
ActivateContext(context Context) error ActivateContext(context Context) error
@@ -196,6 +197,7 @@ type Model struct {
StashEntries []*models.StashEntry StashEntries []*models.StashEntry
SubCommits []*models.Commit SubCommits []*models.Commit
Remotes []*models.Remote Remotes []*models.Remote
Worktrees []*models.Worktree
// FilteredReflogCommits are the ones that appear in the reflog panel. // FilteredReflogCommits are the ones that appear in the reflog panel.
// when in filtering mode we only include the ones that match the given path // when in filtering mode we only include the ones that match the given path
+1
View File
@@ -13,6 +13,7 @@ const (
REFLOG REFLOG
TAGS TAGS
REMOTES REMOTES
WORKTREES
STATUS STATUS
SUBMODULES SUBMODULES
STAGING STAGING
+1
View File
@@ -8,6 +8,7 @@ type Views struct {
Files *gocui.View Files *gocui.View
Branches *gocui.View Branches *gocui.View
Remotes *gocui.View Remotes *gocui.View
Worktrees *gocui.View
Tags *gocui.View Tags *gocui.View
RemoteBranches *gocui.View RemoteBranches *gocui.View
ReflogCommits *gocui.View ReflogCommits *gocui.View
+3
View File
@@ -29,6 +29,7 @@ func (gui *Gui) orderedViewNameMappings() []viewNameMapping {
{viewPtr: &gui.Views.Files, name: "files"}, {viewPtr: &gui.Views.Files, name: "files"},
{viewPtr: &gui.Views.Tags, name: "tags"}, {viewPtr: &gui.Views.Tags, name: "tags"},
{viewPtr: &gui.Views.Remotes, name: "remotes"}, {viewPtr: &gui.Views.Remotes, name: "remotes"},
{viewPtr: &gui.Views.Worktrees, name: "worktrees"},
{viewPtr: &gui.Views.Branches, name: "localBranches"}, {viewPtr: &gui.Views.Branches, name: "localBranches"},
{viewPtr: &gui.Views.RemoteBranches, name: "remoteBranches"}, {viewPtr: &gui.Views.RemoteBranches, name: "remoteBranches"},
{viewPtr: &gui.Views.ReflogCommits, name: "reflogCommits"}, {viewPtr: &gui.Views.ReflogCommits, name: "reflogCommits"},
@@ -113,6 +114,8 @@ func (gui *Gui) createAllViews() error {
gui.Views.Remotes.Title = gui.c.Tr.RemotesTitle gui.Views.Remotes.Title = gui.c.Tr.RemotesTitle
gui.Views.Worktrees.Title = gui.c.Tr.WorktreesTitle
gui.Views.Tags.Title = gui.c.Tr.TagsTitle gui.Views.Tags.Title = gui.c.Tr.TagsTitle
gui.Views.Files.Title = gui.c.Tr.FilesTitle gui.Views.Files.Title = gui.c.Tr.FilesTitle
+48
View File
@@ -480,6 +480,7 @@ type TranslationSet struct {
ErrCannotEditDirectory string ErrCannotEditDirectory string
ErrStageDirWithInlineMergeConflicts string ErrStageDirWithInlineMergeConflicts string
ErrRepositoryMovedOrDeleted string ErrRepositoryMovedOrDeleted string
ErrWorktreeMovedOrRemoved string
CommandLog string CommandLog string
ToggleShowCommandLog string ToggleShowCommandLog string
FocusCommandLog string FocusCommandLog string
@@ -540,6 +541,27 @@ type TranslationSet struct {
FilterPrefix string FilterPrefix string
ExitSearchMode string ExitSearchMode string
ExitTextFilterMode string ExitTextFilterMode string
SwitchToWorktree string
RemoveWorktree string
RemoveWorktreeTitle string
WorktreesTitle string
WorktreeTitle string
RemoveWorktreePrompt string
ForceRemoveWorktreePrompt string
RemovingWorktree string
AddingWorktree string
CantDeleteCurrentWorktree string
AlreadyInWorktree string
CantDeleteMainWorktree string
NoWorktreesThisRepo string
MissingWorktree string
MainWorktree string
CreateWorktree string
NewWorktreePath string
NewWorktreeBranch string
Name string
Branch string
Path string
Actions Actions Actions Actions
Bisect Bisect Bisect Bisect
} }
@@ -666,6 +688,8 @@ type Actions struct {
ResetBisect string ResetBisect string
BisectSkip string BisectSkip string
BisectMark string BisectMark string
RemoveWorktree string
AddWorktree string
} }
const englishIntroPopupMessage = ` const englishIntroPopupMessage = `
@@ -1175,6 +1199,7 @@ func EnglishTranslationSet() TranslationSet {
ErrStageDirWithInlineMergeConflicts: "Cannot stage/unstage directory containing files with inline merge conflicts. Please fix up the merge conflicts first", ErrStageDirWithInlineMergeConflicts: "Cannot stage/unstage directory containing files with inline merge conflicts. Please fix up the merge conflicts first",
ErrRepositoryMovedOrDeleted: "Cannot find repo. It might have been moved or deleted ¯\\_(ツ)_/¯", ErrRepositoryMovedOrDeleted: "Cannot find repo. It might have been moved or deleted ¯\\_(ツ)_/¯",
CommandLog: "Command log", CommandLog: "Command log",
ErrWorktreeMovedOrRemoved: "Cannot find worktree. It might have been moved or removed ¯\\_(ツ)_/¯",
ToggleShowCommandLog: "Toggle show/hide command log", ToggleShowCommandLog: "Toggle show/hide command log",
FocusCommandLog: "Focus command log", FocusCommandLog: "Focus command log",
CommandLogHeader: "You can hide/focus this panel by pressing '%s'\n", CommandLogHeader: "You can hide/focus this panel by pressing '%s'\n",
@@ -1233,6 +1258,27 @@ func EnglishTranslationSet() TranslationSet {
SearchKeybindings: "%s: Next match, %s: Previous match, %s: Exit search mode", SearchKeybindings: "%s: Next match, %s: Previous match, %s: Exit search mode",
SearchPrefix: "Search: ", SearchPrefix: "Search: ",
FilterPrefix: "Filter: ", FilterPrefix: "Filter: ",
WorktreesTitle: "Worktrees",
WorktreeTitle: "Worktree",
SwitchToWorktree: "Switch to worktree",
RemoveWorktree: "Remove worktree",
RemoveWorktreeTitle: "Remove worktree",
RemoveWorktreePrompt: "Are you sure you want to remove worktree '{{.worktreeName}}'?",
ForceRemoveWorktreePrompt: "'{{.worktreeName}}' is not fully merged. Are you sure you want to remove it?",
RemovingWorktree: "Deleting worktree",
AddingWorktree: "Adding worktree",
CantDeleteCurrentWorktree: "You cannot remove the current worktree!",
AlreadyInWorktree: "You are already in the selected worktree",
CantDeleteMainWorktree: "You cannot remove the main worktree!",
NoWorktreesThisRepo: "No worktrees",
MissingWorktree: "(missing)",
MainWorktree: "(main)",
CreateWorktree: "Create worktree",
NewWorktreePath: "New worktree path",
NewWorktreeBranch: "New worktree branch (leave blank to use the current branch)",
Name: "Name",
Branch: "Branch",
Path: "Path",
Actions: Actions{ Actions: Actions{
// TODO: combine this with the original keybinding descriptions (those are all in lowercase atm) // TODO: combine this with the original keybinding descriptions (those are all in lowercase atm)
CheckoutCommit: "Checkout commit", CheckoutCommit: "Checkout commit",
@@ -1340,6 +1386,8 @@ func EnglishTranslationSet() TranslationSet {
ResetBisect: "Reset bisect", ResetBisect: "Reset bisect",
BisectSkip: "Bisect skip", BisectSkip: "Bisect skip",
BisectMark: "Bisect mark", BisectMark: "Bisect mark",
RemoveWorktree: "Remove worktree",
AddWorktree: "Add worktree",
}, },
Bisect: Bisect{ Bisect: Bisect{
Mark: "Mark %s as %s", Mark: "Mark %s as %s",