jesseduffield.lazygit/pkg/gui/controllers/basic_commits_controller.go
Stefan Haller 23d01ac1bd Redesign the 'w' worktree-creation flow
The old flow forced an up-front "normal vs detached" menu (meaningless for
commits, tags and stashes), then asked the user to type a worktree path from
scratch — easy to get wrong, and ambiguous about what relative paths resolve
against. It also offered the same two actions everywhere regardless of what
was selected.

Replace it with per-context "Worktree" menus whose items imply the intent
(new branch + worktree, worktree for an existing branch, detached worktree),
each feeding a shared name -> location -> create pipeline. The location menu
offers candidate parent directories as absolute paths instead of a blank
field, and "Worktree for a branch" is disabled (with a reason) when that
branch is already checked out somewhere, rather than failing after the fact.

Each ref/commit panel binds 'w' in its own controller and calls the matching
typed entry point on the worktree helper, so which menu opens is decided
statically by the call site rather than by dispatching on a ref's dynamic
type. The three commit panels share one menu through BasicCommitsController;
there is no longer a shared worktree-options controller.

The worktrees-panel 'n' flow and its old core (NewWorktree /
NewWorktreeCheckout) are left untouched here so nothing is written and then
rewritten; they migrate, and the dead i18n strings get removed, in a
follow-up commit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 18:53:05 +02:00

433 lines
13 KiB
Go

package controllers
import (
"fmt"
"strings"
"github.com/jesseduffield/lazygit/pkg/commands/git_commands"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/gui/context/traits"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/samber/lo"
)
// This controller is for all contexts that contain a list of commits.
var _ types.IController = &BasicCommitsController{}
type ContainsCommits interface {
types.Context
types.IListContext
GetSelected() *models.Commit
GetSelectedItems() ([]*models.Commit, int, int)
GetCommits() []*models.Commit
GetSelectedLineIdx() int
GetSelectionRangeAndMode() (int, int, traits.RangeSelectMode)
SetSelectionRangeAndMode(int, int, traits.RangeSelectMode)
}
type BasicCommitsController struct {
baseController
*ListControllerTrait[*models.Commit]
c *ControllerCommon
context ContainsCommits
}
func NewBasicCommitsController(c *ControllerCommon, context ContainsCommits) *BasicCommitsController {
return &BasicCommitsController{
baseController: baseController{},
c: c,
context: context,
ListControllerTrait: NewListControllerTrait(
c,
context,
context.GetSelected,
context.GetSelectedItems,
),
}
}
func (self *BasicCommitsController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
bindings := []*types.Binding{
{
Keys: opts.GetKeys(opts.Config.Commits.CheckoutCommit),
Handler: self.withItem(self.checkout),
GetDisabledReason: self.require(self.singleItemSelected()),
Description: self.c.Tr.Checkout,
Tooltip: self.c.Tr.CheckoutCommitTooltip,
DisplayOnScreen: true,
},
{
Keys: opts.GetKeys(opts.Config.Commits.CopyCommitAttributeToClipboard),
Handler: self.withItem(self.copyCommitAttribute),
GetDisabledReason: self.require(self.singleItemSelected()),
Description: self.c.Tr.CopyCommitAttributeToClipboard,
Tooltip: self.c.Tr.CopyCommitAttributeToClipboardTooltip,
OpensMenu: true,
},
{
Keys: opts.GetKeys(opts.Config.Commits.OpenInBrowser),
Handler: self.withItem(self.openInBrowser),
GetDisabledReason: self.require(self.singleItemSelected()),
Description: self.c.Tr.OpenCommitInBrowser,
},
{
Keys: opts.GetKeys(opts.Config.Universal.New),
Handler: self.withItem(self.newBranch),
GetDisabledReason: self.require(self.singleItemSelected()),
Description: self.c.Tr.CreateNewBranchFromCommit,
},
{
// Putting this in BasicCommitsController even though we really only want it in the commits
// panel. But I find it important that this ends up next to "New Branch", and I couldn't
// find another way to achieve this. It's not such a big deal to have it in subcommits and
// reflog too, I'd say.
Keys: opts.GetKeys(opts.Config.Branches.MoveCommitsToNewBranch),
Handler: self.c.Helpers().Refs.MoveCommitsToNewBranch,
GetDisabledReason: self.c.Helpers().Refs.CanMoveCommitsToNewBranch,
Description: self.c.Tr.MoveCommitsToNewBranch,
Tooltip: self.c.Tr.MoveCommitsToNewBranchTooltip,
},
{
Keys: opts.GetKeys(opts.Config.Worktrees.ViewWorktreeOptions),
Handler: self.withItem(self.c.Helpers().Worktree.NewWorktreeMenuForCommit),
Description: self.c.Tr.NewWorktree,
OpensMenu: true,
},
{
Keys: opts.GetKeys(opts.Config.Commits.ViewResetOptions),
Handler: self.withItem(self.createResetMenu),
GetDisabledReason: self.require(self.singleItemSelected()),
Description: self.c.Tr.ViewResetOptions,
Tooltip: self.c.Tr.ResetTooltip,
OpensMenu: true,
DisplayOnScreen: true,
},
{
Keys: opts.GetKeys(opts.Config.Commits.CherryPickCopy),
Handler: self.withItem(self.copyRange),
GetDisabledReason: self.require(self.itemRangeSelected(self.canCopyCommits)),
Description: self.c.Tr.CherryPickCopy,
Tooltip: utils.ResolvePlaceholderString(self.c.Tr.CherryPickCopyTooltip,
map[string]string{
"paste": opts.Config.Commits.PasteCommits.String(),
"escape": opts.Config.Universal.Return.String(),
},
),
DisplayOnScreen: true,
},
{
Keys: opts.GetKeys(opts.Config.Commits.ResetCherryPick),
Handler: self.c.Helpers().CherryPick.Reset,
Description: self.c.Tr.ResetCherryPick,
},
{
Keys: opts.GetKeys(opts.Config.Universal.OpenDiffTool),
Handler: self.withItem(self.openDiffTool),
GetDisabledReason: self.require(self.singleItemSelected()),
Description: self.c.Tr.OpenDiffTool,
},
{
Keys: opts.GetKeys(opts.Config.Commits.SelectCommitsOfCurrentBranch),
Handler: self.selectCommitsOfCurrentBranch,
GetDisabledReason: self.require(self.canSelectCommitsOfCurrentBranch),
Description: self.c.Tr.SelectCommitsOfCurrentBranch,
},
}
return bindings
}
func (self *BasicCommitsController) getCommitMessageBody(hash string) string {
commitMessageBody, err := self.c.Git().Commit.GetCommitMessage(hash)
if err != nil {
return ""
}
_, body := self.c.Helpers().Commits.SplitCommitMessageAndDescription(commitMessageBody)
return body
}
func (self *BasicCommitsController) copyCommitAttribute(commit *models.Commit) error {
commitMessageBody := self.getCommitMessageBody(commit.Hash())
var commitMessageBodyDisabled *types.DisabledReason
if commitMessageBody == "" {
commitMessageBodyDisabled = &types.DisabledReason{
Text: self.c.Tr.CommitHasNoMessageBody,
}
}
items := []*types.MenuItem{
{
Label: self.c.Tr.CommitHash,
OnPress: func() error {
return self.copyCommitHashToClipboard(commit)
},
},
{
Label: self.c.Tr.CommitSubject,
OnPress: func() error {
return self.copyCommitSubjectToClipboard(commit)
},
Keys: menuKey('s'),
},
{
Label: self.c.Tr.CommitMessage,
OnPress: func() error {
return self.copyCommitMessageToClipboard(commit)
},
Keys: menuKey('m'),
},
{
Label: self.c.Tr.CommitMessageBody,
DisabledReason: commitMessageBodyDisabled,
OnPress: func() error {
return self.copyCommitMessageBodyToClipboard(commitMessageBody)
},
Keys: menuKey('b'),
},
{
Label: self.c.Tr.CommitURL,
OnPress: func() error {
return self.copyCommitURLToClipboard(commit)
},
Keys: menuKey('u'),
},
{
Label: self.c.Tr.CommitDiff,
OnPress: func() error {
return self.copyCommitDiffToClipboard(commit)
},
Keys: menuKey('d'),
},
{
Label: self.c.Tr.CommitAuthor,
OnPress: func() error {
return self.copyAuthorToClipboard(commit)
},
Keys: menuKey('a'),
},
}
commitTagsItem := types.MenuItem{
Label: self.c.Tr.CommitTags,
OnPress: func() error {
return self.copyCommitTagsToClipboard(commit)
},
Keys: menuKey('t'),
}
if len(commit.Tags) == 0 {
commitTagsItem.DisabledReason = &types.DisabledReason{Text: self.c.Tr.CommitHasNoTags}
}
items = append(items, &commitTagsItem)
return self.c.Menu(types.CreateMenuOptions{
Title: self.c.Tr.Actions.CopyCommitAttributeToClipboard,
Items: items,
})
}
func (self *BasicCommitsController) copyCommitHashToClipboard(commit *models.Commit) error {
self.c.LogAction(self.c.Tr.Actions.CopyCommitHashToClipboard)
if err := self.c.OS().CopyToClipboard(commit.Hash()); err != nil {
return err
}
self.c.Toast(fmt.Sprintf("'%s' %s", commit.Hash(), self.c.Tr.CopiedToClipboard))
return nil
}
func (self *BasicCommitsController) copyCommitURLToClipboard(commit *models.Commit) error {
url, err := self.c.Helpers().Host.GetCommitURL(commit.Hash())
if err != nil {
return err
}
self.c.LogAction(self.c.Tr.Actions.CopyCommitURLToClipboard)
if err := self.c.OS().CopyToClipboard(url); err != nil {
return err
}
self.c.Toast(self.c.Tr.CommitURLCopiedToClipboard)
return nil
}
func (self *BasicCommitsController) copyCommitDiffToClipboard(commit *models.Commit) error {
diff, err := self.c.Git().Commit.GetCommitDiff(commit.Hash())
if err != nil {
return err
}
self.c.LogAction(self.c.Tr.Actions.CopyCommitDiffToClipboard)
if err := self.c.OS().CopyToClipboard(diff); err != nil {
return err
}
self.c.Toast(self.c.Tr.CommitDiffCopiedToClipboard)
return nil
}
func (self *BasicCommitsController) copyAuthorToClipboard(commit *models.Commit) error {
author, err := self.c.Git().Commit.GetCommitAuthor(commit.Hash())
if err != nil {
return err
}
formattedAuthor := fmt.Sprintf("%s <%s>", author.Name, author.Email)
self.c.LogAction(self.c.Tr.Actions.CopyCommitAuthorToClipboard)
if err := self.c.OS().CopyToClipboard(formattedAuthor); err != nil {
return err
}
self.c.Toast(self.c.Tr.CommitAuthorCopiedToClipboard)
return nil
}
func (self *BasicCommitsController) copyCommitMessageToClipboard(commit *models.Commit) error {
message, err := self.c.Git().Commit.GetCommitMessage(commit.Hash())
if err != nil {
return err
}
self.c.LogAction(self.c.Tr.Actions.CopyCommitMessageToClipboard)
if err := self.c.OS().CopyToClipboard(message); err != nil {
return err
}
self.c.Toast(self.c.Tr.CommitMessageCopiedToClipboard)
return nil
}
func (self *BasicCommitsController) copyCommitMessageBodyToClipboard(commitMessageBody string) error {
self.c.LogAction(self.c.Tr.Actions.CopyCommitMessageBodyToClipboard)
if err := self.c.OS().CopyToClipboard(commitMessageBody); err != nil {
return err
}
self.c.Toast(self.c.Tr.CommitMessageBodyCopiedToClipboard)
return nil
}
func (self *BasicCommitsController) copyCommitSubjectToClipboard(commit *models.Commit) error {
message, err := self.c.Git().Commit.GetCommitSubject(commit.Hash())
if err != nil {
return err
}
self.c.LogAction(self.c.Tr.Actions.CopyCommitSubjectToClipboard)
if err := self.c.OS().CopyToClipboard(message); err != nil {
return err
}
self.c.Toast(self.c.Tr.CommitSubjectCopiedToClipboard)
return nil
}
func (self *BasicCommitsController) copyCommitTagsToClipboard(commit *models.Commit) error {
message := strings.Join(commit.Tags, "\n")
self.c.LogAction(self.c.Tr.Actions.CopyCommitTagsToClipboard)
if err := self.c.OS().CopyToClipboard(message); err != nil {
return err
}
self.c.Toast(self.c.Tr.CommitTagsCopiedToClipboard)
return nil
}
func (self *BasicCommitsController) openInBrowser(commit *models.Commit) error {
url, err := self.c.Helpers().Host.GetCommitURL(commit.Hash())
if err != nil {
return err
}
self.c.LogAction(self.c.Tr.Actions.OpenCommitInBrowser)
if err := self.c.OS().OpenLink(url); err != nil {
return err
}
return nil
}
func (self *BasicCommitsController) newBranch(commit *models.Commit) error {
return self.c.Helpers().Refs.NewBranch(commit.RefName(), commit.Description(), "")
}
func (self *BasicCommitsController) createResetMenu(commit *models.Commit) error {
return self.c.Helpers().Refs.CreateGitResetMenu(commit.Hash(), commit.Hash())
}
func (self *BasicCommitsController) checkout(commit *models.Commit) error {
return self.c.Helpers().Refs.CreateCheckoutMenu(commit)
}
func (self *BasicCommitsController) copyRange(*models.Commit) error {
return self.c.Helpers().CherryPick.CopyRange(self.context.GetCommits(), self.context)
}
func (self *BasicCommitsController) canCopyCommits(selectedCommits []*models.Commit, startIdx int, endIdx int) *types.DisabledReason {
for _, commit := range selectedCommits {
if commit.Hash() == "" {
return &types.DisabledReason{Text: self.c.Tr.CannotCherryPickNonCommit, ShowErrorInPanel: true}
}
}
return nil
}
func (self *BasicCommitsController) openDiffTool(commit *models.Commit) error {
to := commit.RefName()
from, reverse := self.c.Modes().Diffing.GetFromAndReverseArgsForDiff(commit.ParentRefName())
_, err := self.c.RunSubprocess(self.c.Git().Diff.OpenDiffToolCmdObj(
git_commands.DiffToolCmdOptions{
Filepath: ".",
FromCommit: from,
ToCommit: to,
Reverse: reverse,
IsDirectory: true,
Staged: false,
}))
return err
}
func (self *BasicCommitsController) canSelectCommitsOfCurrentBranch() *types.DisabledReason {
if index := self.findFirstCommitAfterCurrentBranch(); index <= 0 {
return &types.DisabledReason{Text: self.c.Tr.NoCommitsThisBranch}
}
return nil
}
func (self *BasicCommitsController) findFirstCommitAfterCurrentBranch() int {
_, index, ok := lo.FindIndexOf(self.context.GetCommits(), func(c *models.Commit) bool {
return c.IsMerge() || c.Status == models.StatusMerged
})
if !ok {
return 0
}
return index
}
func (self *BasicCommitsController) selectCommitsOfCurrentBranch() error {
index := self.findFirstCommitAfterCurrentBranch()
if index <= 0 {
return nil
}
_, _, mode := self.context.GetSelectionRangeAndMode()
if mode != traits.RangeSelectModeSticky {
// If we are in sticky range mode already, keep that; otherwise, open a non-sticky range
mode = traits.RangeSelectModeNonSticky
}
// Create the range from bottom to top, so that when you cancel the range,
// the head commit is selected
self.context.SetSelectionRangeAndMode(0, index-1, mode)
self.context.HandleFocus(types.OnFocusOpts{})
return nil
}