1
0
mirror of https://github.com/jesseduffield/lazygit.git synced 2025-07-31 14:24:25 +03:00

start moving commit panel handlers into controller

more

and more

move rebase commit refreshing into existing abstraction

and more

and more

WIP

and more

handling clicks

properly fix merge conflicts

update cheatsheet

lots more preparation to start moving things into controllers

WIP

better typing

expand on remotes controller

moving more code into controllers
This commit is contained in:
Jesse Duffield
2022-01-16 14:46:53 +11:00
parent a90b6efded
commit 1dd7307fde
104 changed files with 4980 additions and 4111 deletions

View File

@ -0,0 +1,273 @@
package controllers
import (
"fmt"
"strings"
"github.com/jesseduffield/lazygit/pkg/commands"
"github.com/jesseduffield/lazygit/pkg/commands/git_commands"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/config"
"github.com/jesseduffield/lazygit/pkg/gui/popup"
"github.com/jesseduffield/lazygit/pkg/gui/types"
)
type BisectController struct {
c *ControllerCommon
context types.IListContext
git *commands.GitCommand
getSelectedLocalCommit func() *models.Commit
getCommits func() []*models.Commit
}
var _ types.IController = &BisectController{}
func NewBisectController(
c *ControllerCommon,
context types.IListContext,
git *commands.GitCommand,
getSelectedLocalCommit func() *models.Commit,
getCommits func() []*models.Commit,
) *BisectController {
return &BisectController{
c: c,
context: context,
git: git,
getSelectedLocalCommit: getSelectedLocalCommit,
getCommits: getCommits,
}
}
func (self *BisectController) Keybindings(getKey func(key string) interface{}, config config.KeybindingConfig, guards types.KeybindingGuards) []*types.Binding {
bindings := []*types.Binding{
{
Key: getKey(config.Commits.ViewBisectOptions),
Handler: guards.OutsideFilterMode(self.checkSelected(self.openMenu)),
Description: self.c.Tr.LcViewBisectOptions,
OpensMenu: true,
},
}
return bindings
}
func (self *BisectController) openMenu(commit *models.Commit) error {
// no shame in getting this directly rather than using the cached value
// given how cheap it is to obtain
info := self.git.Bisect.GetInfo()
if info.Started() {
return self.openMidBisectMenu(info, commit)
} else {
return self.openStartBisectMenu(info, commit)
}
}
func (self *BisectController) openMidBisectMenu(info *git_commands.BisectInfo, commit *models.Commit) error {
// if there is not yet a 'current' bisect commit, or if we have
// selected the current commit, we need to jump to the next 'current' commit
// after we perform a bisect action. The reason we don't unconditionally jump
// is that sometimes the user will want to go and mark a few commits as skipped
// in a row and they wouldn't want to be jumped back to the current bisect
// commit each time.
// Originally we were allowing the user to, from the bisect menu, select whether
// they were talking about the selected commit or the current bisect commit,
// and that was a bit confusing (and required extra keypresses).
selectCurrentAfter := info.GetCurrentSha() == "" || info.GetCurrentSha() == commit.Sha
// we need to wait to reselect if our bisect commits aren't ancestors of our 'start'
// ref, because we'll be reloading our commits in that case.
waitToReselect := selectCurrentAfter && !self.git.Bisect.ReachableFromStart(info)
menuItems := []*popup.MenuItem{
{
DisplayString: fmt.Sprintf(self.c.Tr.Bisect.Mark, commit.ShortSha(), info.NewTerm()),
OnPress: func() error {
self.c.LogAction(self.c.Tr.Actions.BisectMark)
if err := self.git.Bisect.Mark(commit.Sha, info.NewTerm()); err != nil {
return self.c.Error(err)
}
return self.afterMark(selectCurrentAfter, waitToReselect)
},
},
{
DisplayString: fmt.Sprintf(self.c.Tr.Bisect.Mark, commit.ShortSha(), info.OldTerm()),
OnPress: func() error {
self.c.LogAction(self.c.Tr.Actions.BisectMark)
if err := self.git.Bisect.Mark(commit.Sha, info.OldTerm()); err != nil {
return self.c.Error(err)
}
return self.afterMark(selectCurrentAfter, waitToReselect)
},
},
{
DisplayString: fmt.Sprintf(self.c.Tr.Bisect.Skip, commit.ShortSha()),
OnPress: func() error {
self.c.LogAction(self.c.Tr.Actions.BisectSkip)
if err := self.git.Bisect.Skip(commit.Sha); err != nil {
return self.c.Error(err)
}
return self.afterMark(selectCurrentAfter, waitToReselect)
},
},
{
DisplayString: self.c.Tr.Bisect.ResetOption,
OnPress: func() error {
return self.Reset()
},
},
}
return self.c.Menu(popup.CreateMenuOptions{
Title: self.c.Tr.Bisect.BisectMenuTitle,
Items: menuItems,
})
}
func (self *BisectController) openStartBisectMenu(info *git_commands.BisectInfo, commit *models.Commit) error {
return self.c.Menu(popup.CreateMenuOptions{
Title: self.c.Tr.Bisect.BisectMenuTitle,
Items: []*popup.MenuItem{
{
DisplayString: fmt.Sprintf(self.c.Tr.Bisect.MarkStart, commit.ShortSha(), info.NewTerm()),
OnPress: func() error {
self.c.LogAction(self.c.Tr.Actions.StartBisect)
if err := self.git.Bisect.Start(); err != nil {
return self.c.Error(err)
}
if err := self.git.Bisect.Mark(commit.Sha, info.NewTerm()); err != nil {
return self.c.Error(err)
}
return self.postBisectCommandRefresh()
},
},
{
DisplayString: fmt.Sprintf(self.c.Tr.Bisect.MarkStart, commit.ShortSha(), info.OldTerm()),
OnPress: func() error {
self.c.LogAction(self.c.Tr.Actions.StartBisect)
if err := self.git.Bisect.Start(); err != nil {
return self.c.Error(err)
}
if err := self.git.Bisect.Mark(commit.Sha, info.OldTerm()); err != nil {
return self.c.Error(err)
}
return self.postBisectCommandRefresh()
},
},
},
})
}
func (self *BisectController) Reset() error {
return self.c.Ask(popup.AskOpts{
Title: self.c.Tr.Bisect.ResetTitle,
Prompt: self.c.Tr.Bisect.ResetPrompt,
HandleConfirm: func() error {
self.c.LogAction(self.c.Tr.Actions.ResetBisect)
if err := self.git.Bisect.Reset(); err != nil {
return self.c.Error(err)
}
return self.postBisectCommandRefresh()
},
})
}
func (self *BisectController) showBisectCompleteMessage(candidateShas []string) error {
prompt := self.c.Tr.Bisect.CompletePrompt
if len(candidateShas) > 1 {
prompt = self.c.Tr.Bisect.CompletePromptIndeterminate
}
formattedCommits, err := self.git.Commit.GetCommitsOneline(candidateShas)
if err != nil {
return self.c.Error(err)
}
return self.c.Ask(popup.AskOpts{
Title: self.c.Tr.Bisect.CompleteTitle,
Prompt: fmt.Sprintf(prompt, strings.TrimSpace(formattedCommits)),
HandleConfirm: func() error {
self.c.LogAction(self.c.Tr.Actions.ResetBisect)
if err := self.git.Bisect.Reset(); err != nil {
return self.c.Error(err)
}
return self.postBisectCommandRefresh()
},
})
}
func (self *BisectController) afterMark(selectCurrent bool, waitToReselect bool) error {
done, candidateShas, err := self.git.Bisect.IsDone()
if err != nil {
return self.c.Error(err)
}
if err := self.afterBisectMarkRefresh(selectCurrent, waitToReselect); err != nil {
return self.c.Error(err)
}
if done {
return self.showBisectCompleteMessage(candidateShas)
}
return nil
}
func (self *BisectController) postBisectCommandRefresh() error {
return self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{}})
}
func (self *BisectController) afterBisectMarkRefresh(selectCurrent bool, waitToReselect bool) error {
selectFn := func() {
if selectCurrent {
self.selectCurrentBisectCommit()
}
}
if waitToReselect {
return self.c.Refresh(types.RefreshOptions{Mode: types.SYNC, Scope: []types.RefreshableView{}, Then: selectFn})
} else {
selectFn()
return self.postBisectCommandRefresh()
}
}
func (self *BisectController) selectCurrentBisectCommit() {
info := self.git.Bisect.GetInfo()
if info.GetCurrentSha() != "" {
// find index of commit with that sha, move cursor to that.
for i, commit := range self.getCommits() {
if commit.Sha == info.GetCurrentSha() {
self.context.GetPanelState().SetSelectedLineIdx(i)
_ = self.context.HandleFocus()
break
}
}
}
}
func (self *BisectController) checkSelected(callback func(*models.Commit) error) func() error {
return func() error {
commit := self.getSelectedLocalCommit()
if commit == nil {
return nil
}
return callback(commit)
}
}
func (self *BisectController) Context() types.Context {
return self.context
}

View File

@ -0,0 +1,10 @@
package controllers
import "github.com/jesseduffield/lazygit/pkg/common"
// if Go let me do private struct embedding of structs with public fields (which it should)
// I would just do that. But alas.
type ControllerCommon struct {
*common.Common
IGuiCommon
}

View File

@ -0,0 +1,737 @@
package controllers
import (
"fmt"
"regexp"
"strings"
"github.com/jesseduffield/gocui"
"github.com/jesseduffield/lazygit/pkg/commands"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/jesseduffield/lazygit/pkg/config"
"github.com/jesseduffield/lazygit/pkg/gui/context"
"github.com/jesseduffield/lazygit/pkg/gui/filetree"
"github.com/jesseduffield/lazygit/pkg/gui/popup"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/utils"
)
type FilesController struct {
// I've said publicly that I'm against single-letter variable names but in this
// case I would actually prefer a _zero_ letter variable name in the form of
// struct embedding, but Go does not allow hiding public fields in an embedded struct
// to the client
c *ControllerCommon
context types.IListContext
git *commands.GitCommand
os *oscommands.OSCommand
getSelectedFileNode func() *filetree.FileNode
allContexts context.ContextTree
fileTreeViewModel *filetree.FileTreeViewModel
enterSubmodule func(submodule *models.SubmoduleConfig) error
getSubmodules func() []*models.SubmoduleConfig
setCommitMessage func(message string)
getCheckedOutBranch func() *models.Branch
withGpgHandling func(cmdObj oscommands.ICmdObj, waitingStatus string, onSuccess func() error) error
getFailedCommitMessage func() string
getCommits func() []*models.Commit
getSelectedPath func() string
switchToMergeFn func(path string) error
suggestionsHelper ISuggestionsHelper
refHelper IRefHelper
fileHelper IFileHelper
workingTreeHelper IWorkingTreeHelper
}
var _ types.IController = &FilesController{}
func NewFilesController(
c *ControllerCommon,
context types.IListContext,
git *commands.GitCommand,
os *oscommands.OSCommand,
getSelectedFileNode func() *filetree.FileNode,
allContexts context.ContextTree,
fileTreeViewModel *filetree.FileTreeViewModel,
enterSubmodule func(submodule *models.SubmoduleConfig) error,
getSubmodules func() []*models.SubmoduleConfig,
setCommitMessage func(message string),
withGpgHandling func(cmdObj oscommands.ICmdObj, waitingStatus string, onSuccess func() error) error,
getFailedCommitMessage func() string,
getCommits func() []*models.Commit,
getSelectedPath func() string,
switchToMergeFn func(path string) error,
suggestionsHelper ISuggestionsHelper,
refHelper IRefHelper,
fileHelper IFileHelper,
workingTreeHelper IWorkingTreeHelper,
) *FilesController {
return &FilesController{
c: c,
context: context,
git: git,
os: os,
getSelectedFileNode: getSelectedFileNode,
allContexts: allContexts,
fileTreeViewModel: fileTreeViewModel,
enterSubmodule: enterSubmodule,
getSubmodules: getSubmodules,
setCommitMessage: setCommitMessage,
withGpgHandling: withGpgHandling,
getFailedCommitMessage: getFailedCommitMessage,
getCommits: getCommits,
getSelectedPath: getSelectedPath,
switchToMergeFn: switchToMergeFn,
suggestionsHelper: suggestionsHelper,
refHelper: refHelper,
fileHelper: fileHelper,
workingTreeHelper: workingTreeHelper,
}
}
func (self *FilesController) Keybindings(getKey func(key string) interface{}, config config.KeybindingConfig, guards types.KeybindingGuards) []*types.Binding {
bindings := []*types.Binding{
{
Key: getKey(config.Universal.Select),
Handler: self.checkSelectedFileNode(self.press),
Description: self.c.Tr.LcToggleStaged,
},
{
Key: gocui.MouseLeft,
Handler: func() error { return self.context.HandleClick(self.checkSelectedFileNode(self.press)) },
},
{
Key: getKey("<c-b>"), // TODO: softcode
Handler: self.handleStatusFilterPressed,
Description: self.c.Tr.LcFileFilter,
},
{
Key: getKey(config.Files.CommitChanges),
Handler: self.HandleCommitPress,
Description: self.c.Tr.CommitChanges,
},
{
Key: getKey(config.Files.CommitChangesWithoutHook),
Handler: self.HandleWIPCommitPress,
Description: self.c.Tr.LcCommitChangesWithoutHook,
},
{
Key: getKey(config.Files.AmendLastCommit),
Handler: self.handleAmendCommitPress,
Description: self.c.Tr.AmendLastCommit,
},
{
Key: getKey(config.Files.CommitChangesWithEditor),
Handler: self.HandleCommitEditorPress,
Description: self.c.Tr.CommitChangesWithEditor,
},
{
Key: getKey(config.Universal.Edit),
Handler: self.edit,
Description: self.c.Tr.LcEditFile,
},
{
Key: getKey(config.Universal.OpenFile),
Handler: self.Open,
Description: self.c.Tr.LcOpenFile,
},
{
Key: getKey(config.Files.IgnoreFile),
Handler: self.ignore,
Description: self.c.Tr.LcIgnoreFile,
},
{
Key: getKey(config.Files.RefreshFiles),
Handler: self.refresh,
Description: self.c.Tr.LcRefreshFiles,
},
{
Key: getKey(config.Files.StashAllChanges),
Handler: self.stash,
Description: self.c.Tr.LcStashAllChanges,
},
{
Key: getKey(config.Files.ViewStashOptions),
Handler: self.createStashMenu,
Description: self.c.Tr.LcViewStashOptions,
OpensMenu: true,
},
{
Key: getKey(config.Files.ToggleStagedAll),
Handler: self.stageAll,
Description: self.c.Tr.LcToggleStagedAll,
},
{
Key: getKey(config.Universal.GoInto),
Handler: self.enter,
Description: self.c.Tr.FileEnter,
},
{
ViewName: "",
Key: getKey(config.Universal.ExecuteCustomCommand),
Handler: self.handleCustomCommand,
Description: self.c.Tr.LcExecuteCustomCommand,
},
{
Key: getKey(config.Commits.ViewResetOptions),
Handler: self.createResetMenu,
Description: self.c.Tr.LcViewResetToUpstreamOptions,
OpensMenu: true,
},
{
Key: getKey(config.Files.ToggleTreeView),
Handler: self.toggleTreeView,
Description: self.c.Tr.LcToggleTreeView,
},
{
Key: getKey(config.Files.OpenMergeTool),
Handler: self.OpenMergeTool,
Description: self.c.Tr.LcOpenMergeTool,
},
}
return append(bindings, self.context.Keybindings(getKey, config, guards)...)
}
func (self *FilesController) press(node *filetree.FileNode) error {
if node.IsLeaf() {
file := node.File
if file.HasInlineMergeConflicts {
return self.c.PushContext(self.allContexts.Merging)
}
if file.HasUnstagedChanges {
self.c.LogAction(self.c.Tr.Actions.StageFile)
if err := self.git.WorkingTree.StageFile(file.Name); err != nil {
return self.c.Error(err)
}
} else {
self.c.LogAction(self.c.Tr.Actions.UnstageFile)
if err := self.git.WorkingTree.UnStageFile(file.Names(), file.Tracked); err != nil {
return self.c.Error(err)
}
}
} else {
// if any files within have inline merge conflicts we can't stage or unstage,
// or it'll end up with those >>>>>> lines actually staged
if node.GetHasInlineMergeConflicts() {
return self.c.ErrorMsg(self.c.Tr.ErrStageDirWithInlineMergeConflicts)
}
if node.GetHasUnstagedChanges() {
self.c.LogAction(self.c.Tr.Actions.StageFile)
if err := self.git.WorkingTree.StageFile(node.Path); err != nil {
return self.c.Error(err)
}
} else {
// pretty sure it doesn't matter that we're always passing true here
self.c.LogAction(self.c.Tr.Actions.UnstageFile)
if err := self.git.WorkingTree.UnStageFile([]string{node.Path}, true); err != nil {
return self.c.Error(err)
}
}
}
if err := self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}}); err != nil {
return err
}
return self.context.HandleFocus()
}
func (self *FilesController) checkSelectedFileNode(callback func(*filetree.FileNode) error) func() error {
return func() error {
node := self.getSelectedFileNode()
if node == nil {
return nil
}
return callback(node)
}
}
func (self *FilesController) checkSelectedFile(callback func(*models.File) error) func() error {
return func() error {
file := self.getSelectedFile()
if file == nil {
return nil
}
return callback(file)
}
}
func (self *FilesController) Context() types.Context {
return self.context
}
func (self *FilesController) getSelectedFile() *models.File {
node := self.getSelectedFileNode()
if node == nil {
return nil
}
return node.File
}
func (self *FilesController) enter() error {
return self.EnterFile(types.OnFocusOpts{ClickedViewName: "", ClickedViewLineIdx: -1})
}
func (self *FilesController) EnterFile(opts types.OnFocusOpts) error {
node := self.getSelectedFileNode()
if node == nil {
return nil
}
if node.File == nil {
return self.handleToggleDirCollapsed()
}
file := node.File
submoduleConfigs := self.getSubmodules()
if file.IsSubmodule(submoduleConfigs) {
submoduleConfig := file.SubmoduleConfig(submoduleConfigs)
return self.enterSubmodule(submoduleConfig)
}
if file.HasInlineMergeConflicts {
return self.switchToMerge()
}
if file.HasMergeConflicts {
return self.c.ErrorMsg(self.c.Tr.FileStagingRequirements)
}
return self.c.PushContext(self.allContexts.Staging, opts)
}
func (self *FilesController) allFilesStaged() bool {
for _, file := range self.fileTreeViewModel.GetAllFiles() {
if file.HasUnstagedChanges {
return false
}
}
return true
}
func (self *FilesController) stageAll() error {
var err error
if self.allFilesStaged() {
self.c.LogAction(self.c.Tr.Actions.UnstageAllFiles)
err = self.git.WorkingTree.UnstageAll()
} else {
self.c.LogAction(self.c.Tr.Actions.StageAllFiles)
err = self.git.WorkingTree.StageAll()
}
if err != nil {
_ = self.c.Error(err)
}
if err := self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}}); err != nil {
return err
}
return self.allContexts.Files.HandleFocus()
}
func (self *FilesController) ignore() error {
node := self.getSelectedFileNode()
if node == nil {
return nil
}
if node.GetPath() == ".gitignore" {
return self.c.ErrorMsg("Cannot ignore .gitignore")
}
unstageFiles := func() error {
return node.ForEachFile(func(file *models.File) error {
if file.HasStagedChanges {
if err := self.git.WorkingTree.UnStageFile(file.Names(), file.Tracked); err != nil {
return err
}
}
return nil
})
}
if node.GetIsTracked() {
return self.c.Ask(popup.AskOpts{
Title: self.c.Tr.IgnoreTracked,
Prompt: self.c.Tr.IgnoreTrackedPrompt,
HandleConfirm: func() error {
self.c.LogAction(self.c.Tr.Actions.IgnoreFile)
// not 100% sure if this is necessary but I'll assume it is
if err := unstageFiles(); err != nil {
return err
}
if err := self.git.WorkingTree.RemoveTrackedFiles(node.GetPath()); err != nil {
return err
}
if err := self.git.WorkingTree.Ignore(node.GetPath()); err != nil {
return err
}
return self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}})
},
})
}
self.c.LogAction(self.c.Tr.Actions.IgnoreFile)
if err := unstageFiles(); err != nil {
return err
}
if err := self.git.WorkingTree.Ignore(node.GetPath()); err != nil {
return self.c.Error(err)
}
return self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}})
}
func (self *FilesController) HandleWIPCommitPress() error {
skipHookPrefix := self.c.UserConfig.Git.SkipHookPrefix
if skipHookPrefix == "" {
return self.c.ErrorMsg(self.c.Tr.SkipHookPrefixNotConfigured)
}
self.setCommitMessage(skipHookPrefix)
return self.HandleCommitPress()
}
func (self *FilesController) commitPrefixConfigForRepo() *config.CommitPrefixConfig {
cfg, ok := self.c.UserConfig.Git.CommitPrefixes[utils.GetCurrentRepoName()]
if !ok {
return nil
}
return &cfg
}
func (self *FilesController) prepareFilesForCommit() error {
noStagedFiles := !self.workingTreeHelper.AnyStagedFiles()
if noStagedFiles && self.c.UserConfig.Gui.SkipNoStagedFilesWarning {
self.c.LogAction(self.c.Tr.Actions.StageAllFiles)
err := self.git.WorkingTree.StageAll()
if err != nil {
return err
}
return self.syncRefresh()
}
return nil
}
// for when you need to refetch files before continuing an action. Runs synchronously.
func (self *FilesController) syncRefresh() error {
return self.c.Refresh(types.RefreshOptions{Mode: types.SYNC, Scope: []types.RefreshableView{types.FILES}})
}
func (self *FilesController) refresh() error {
return self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}})
}
func (self *FilesController) HandleCommitPress() error {
if err := self.prepareFilesForCommit(); err != nil {
return self.c.Error(err)
}
if self.fileTreeViewModel.GetItemsLength() == 0 {
return self.c.ErrorMsg(self.c.Tr.NoFilesStagedTitle)
}
if !self.workingTreeHelper.AnyStagedFiles() {
return self.promptToStageAllAndRetry(self.HandleCommitPress)
}
failedCommitMessage := self.getFailedCommitMessage()
if len(failedCommitMessage) > 0 {
self.setCommitMessage(failedCommitMessage)
} else {
commitPrefixConfig := self.commitPrefixConfigForRepo()
if commitPrefixConfig != nil {
prefixPattern := commitPrefixConfig.Pattern
prefixReplace := commitPrefixConfig.Replace
rgx, err := regexp.Compile(prefixPattern)
if err != nil {
return self.c.ErrorMsg(fmt.Sprintf("%s: %s", self.c.Tr.LcCommitPrefixPatternError, err.Error()))
}
prefix := rgx.ReplaceAllString(self.getCheckedOutBranch().Name, prefixReplace)
self.setCommitMessage(prefix)
}
}
if err := self.c.PushContext(self.allContexts.CommitMessage); err != nil {
return err
}
return nil
}
func (self *FilesController) promptToStageAllAndRetry(retry func() error) error {
return self.c.Ask(popup.AskOpts{
Title: self.c.Tr.NoFilesStagedTitle,
Prompt: self.c.Tr.NoFilesStagedPrompt,
HandleConfirm: func() error {
self.c.LogAction(self.c.Tr.Actions.StageAllFiles)
if err := self.git.WorkingTree.StageAll(); err != nil {
return self.c.Error(err)
}
if err := self.syncRefresh(); err != nil {
return self.c.Error(err)
}
return retry()
},
})
}
func (self *FilesController) handleAmendCommitPress() error {
if self.fileTreeViewModel.GetItemsLength() == 0 {
return self.c.ErrorMsg(self.c.Tr.NoFilesStagedTitle)
}
if !self.workingTreeHelper.AnyStagedFiles() {
return self.promptToStageAllAndRetry(self.handleAmendCommitPress)
}
if len(self.getCommits()) == 0 {
return self.c.ErrorMsg(self.c.Tr.NoCommitToAmend)
}
return self.c.Ask(popup.AskOpts{
Title: strings.Title(self.c.Tr.AmendLastCommit),
Prompt: self.c.Tr.SureToAmend,
HandleConfirm: func() error {
cmdObj := self.git.Commit.AmendHeadCmdObj()
self.c.LogAction(self.c.Tr.Actions.AmendCommit)
return self.withGpgHandling(cmdObj, self.c.Tr.AmendingStatus, nil)
},
})
}
// HandleCommitEditorPress - handle when the user wants to commit changes via
// their editor rather than via the popup panel
func (self *FilesController) HandleCommitEditorPress() error {
if self.fileTreeViewModel.GetItemsLength() == 0 {
return self.c.ErrorMsg(self.c.Tr.NoFilesStagedTitle)
}
if !self.workingTreeHelper.AnyStagedFiles() {
return self.promptToStageAllAndRetry(self.HandleCommitEditorPress)
}
self.c.LogAction(self.c.Tr.Actions.Commit)
return self.c.RunSubprocessAndRefresh(
self.git.Commit.CommitEditorCmdObj(),
)
}
func (self *FilesController) handleStatusFilterPressed() error {
return self.c.Menu(popup.CreateMenuOptions{
Title: self.c.Tr.FilteringMenuTitle,
Items: []*popup.MenuItem{
{
DisplayString: self.c.Tr.FilterStagedFiles,
OnPress: func() error {
return self.setStatusFiltering(filetree.DisplayStaged)
},
},
{
DisplayString: self.c.Tr.FilterUnstagedFiles,
OnPress: func() error {
return self.setStatusFiltering(filetree.DisplayUnstaged)
},
},
{
DisplayString: self.c.Tr.ResetCommitFilterState,
OnPress: func() error {
return self.setStatusFiltering(filetree.DisplayAll)
},
},
},
})
}
func (self *FilesController) setStatusFiltering(filter filetree.FileTreeDisplayFilter) error {
self.fileTreeViewModel.SetFilter(filter)
return self.c.PostRefreshUpdate(self.context)
}
func (self *FilesController) edit() error {
node := self.getSelectedFileNode()
if node == nil {
return nil
}
if node.File == nil {
return self.c.ErrorMsg(self.c.Tr.ErrCannotEditDirectory)
}
return self.fileHelper.EditFile(node.GetPath())
}
func (self *FilesController) Open() error {
node := self.getSelectedFileNode()
if node == nil {
return nil
}
return self.fileHelper.OpenFile(node.GetPath())
}
func (self *FilesController) switchToMerge() error {
file := self.getSelectedFile()
if file == nil {
return nil
}
self.switchToMergeFn(path)
}
func (self *FilesController) handleCustomCommand() error {
return self.c.Prompt(popup.PromptOpts{
Title: self.c.Tr.CustomCommand,
FindSuggestionsFunc: self.suggestionsHelper.GetCustomCommandsHistorySuggestionsFunc(),
HandleConfirm: func(command string) error {
self.c.GetAppState().CustomCommandsHistory = utils.Limit(
utils.Uniq(
append(self.c.GetAppState().CustomCommandsHistory, command),
),
1000,
)
err := self.c.SaveAppState()
if err != nil {
self.c.Log.Error(err)
}
self.c.LogAction(self.c.Tr.Actions.CustomCommand)
return self.c.RunSubprocessAndRefresh(
self.os.Cmd.NewShell(command),
)
},
})
}
func (self *FilesController) createStashMenu() error {
return self.c.Menu(popup.CreateMenuOptions{
Title: self.c.Tr.LcStashOptions,
Items: []*popup.MenuItem{
{
DisplayString: self.c.Tr.LcStashAllChanges,
OnPress: func() error {
self.c.LogAction(self.c.Tr.Actions.StashAllChanges)
return self.handleStashSave(self.git.Stash.Save)
},
},
{
DisplayString: self.c.Tr.LcStashStagedChanges,
OnPress: func() error {
self.c.LogAction(self.c.Tr.Actions.StashStagedChanges)
return self.handleStashSave(self.git.Stash.SaveStagedChanges)
},
},
},
})
}
func (self *FilesController) stash() error {
return self.handleStashSave(self.git.Stash.Save)
}
func (self *FilesController) createResetMenu() error {
return self.refHelper.CreateGitResetMenu("@{upstream}")
}
func (self *FilesController) handleToggleDirCollapsed() error {
node := self.getSelectedFileNode()
if node == nil {
return nil
}
self.fileTreeViewModel.ToggleCollapsed(node.GetPath())
if err := self.c.PostRefreshUpdate(self.allContexts.Files); err != nil {
self.c.Log.Error(err)
}
return nil
}
func (self *FilesController) toggleTreeView() error {
// get path of currently selected file
path := self.getSelectedPath()
self.fileTreeViewModel.ToggleShowTree()
// find that same node in the new format and move the cursor to it
if path != "" {
self.fileTreeViewModel.ExpandToPath(path)
index, found := self.fileTreeViewModel.GetIndexForPath(path)
if found {
self.context.GetPanelState().SetSelectedLineIdx(index)
}
}
return self.c.PostRefreshUpdate(self.context)
}
func (self *FilesController) OpenMergeTool() error {
return self.c.Ask(popup.AskOpts{
Title: self.c.Tr.MergeToolTitle,
Prompt: self.c.Tr.MergeToolPrompt,
HandleConfirm: func() error {
self.c.LogAction(self.c.Tr.Actions.OpenMergeTool)
return self.c.RunSubprocessAndRefresh(
self.git.WorkingTree.OpenMergeToolCmdObj(),
)
},
})
}
func (self *FilesController) ResetSubmodule(submodule *models.SubmoduleConfig) error {
return self.c.WithWaitingStatus(self.c.Tr.LcResettingSubmoduleStatus, func() error {
self.c.LogAction(self.c.Tr.Actions.ResetSubmodule)
file := self.workingTreeHelper.FileForSubmodule(submodule)
if file != nil {
if err := self.git.WorkingTree.UnStageFile(file.Names(), file.Tracked); err != nil {
return self.c.Error(err)
}
}
if err := self.git.Submodule.Stash(submodule); err != nil {
return self.c.Error(err)
}
if err := self.git.Submodule.Reset(submodule); err != nil {
return self.c.Error(err)
}
return self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.FILES, types.SUBMODULES}})
})
}
func (self *FilesController) handleStashSave(stashFunc func(message string) error) error {
if !self.workingTreeHelper.IsWorkingTreeDirty() {
return self.c.ErrorMsg(self.c.Tr.NoTrackedStagedFilesStash)
}
return self.c.Prompt(popup.PromptOpts{
Title: self.c.Tr.StashChanges,
HandleConfirm: func(stashComment string) error {
if err := stashFunc(stashComment); err != nil {
return self.c.Error(err)
}
return self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.STASH, types.FILES}})
},
})
}

View File

@ -0,0 +1,783 @@
package controllers
import (
"fmt"
"github.com/jesseduffield/gocui"
"github.com/jesseduffield/lazygit/pkg/commands"
"github.com/jesseduffield/lazygit/pkg/commands/hosting_service"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/jesseduffield/lazygit/pkg/config"
"github.com/jesseduffield/lazygit/pkg/gui/popup"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/utils"
)
type (
CheckoutRefFn func(refName string, opts types.CheckoutRefOptions) error
CreateGitResetMenuFn func(refName string) error
SwitchToCommitFilesContextFn func(SwitchToCommitFilesContextOpts) error
CreateTagMenuFn func(commitSha string) error
GetHostingServiceMgrFn func() *hosting_service.HostingServiceMgr
PullFilesFn func() error
CheckMergeOrRebase func(error) error
OpenSearchFn func(viewName string) error
)
type LocalCommitsController struct {
c *ControllerCommon
context types.IListContext
os *oscommands.OSCommand
git *commands.GitCommand
refHelper IRefHelper
getSelectedLocalCommit func() *models.Commit
getCommits func() []*models.Commit
getSelectedLocalCommitIdx func() int
checkMergeOrRebase CheckMergeOrRebase
pullFiles PullFilesFn
createTagMenu CreateTagMenuFn
getHostingServiceMgr GetHostingServiceMgrFn
switchToCommitFilesContext SwitchToCommitFilesContextFn
openSearch OpenSearchFn
getLimitCommits func() bool
setLimitCommits func(bool)
getShowWholeGitGraph func() bool
setShowWholeGitGraph func(bool)
}
var _ types.IController = &LocalCommitsController{}
func NewLocalCommitsController(
c *ControllerCommon,
context types.IListContext,
os *oscommands.OSCommand,
git *commands.GitCommand,
refHelper IRefHelper,
getSelectedLocalCommit func() *models.Commit,
getCommits func() []*models.Commit,
getSelectedLocalCommitIdx func() int,
checkMergeOrRebase CheckMergeOrRebase,
pullFiles PullFilesFn,
createTagMenu CreateTagMenuFn,
getHostingServiceMgr GetHostingServiceMgrFn,
switchToCommitFilesContext SwitchToCommitFilesContextFn,
openSearch OpenSearchFn,
getLimitCommits func() bool,
setLimitCommits func(bool),
getShowWholeGitGraph func() bool,
setShowWholeGitGraph func(bool),
) *LocalCommitsController {
return &LocalCommitsController{
c: c,
context: context,
os: os,
git: git,
refHelper: refHelper,
getSelectedLocalCommit: getSelectedLocalCommit,
getCommits: getCommits,
getSelectedLocalCommitIdx: getSelectedLocalCommitIdx,
checkMergeOrRebase: checkMergeOrRebase,
pullFiles: pullFiles,
createTagMenu: createTagMenu,
getHostingServiceMgr: getHostingServiceMgr,
switchToCommitFilesContext: switchToCommitFilesContext,
openSearch: openSearch,
getLimitCommits: getLimitCommits,
setLimitCommits: setLimitCommits,
getShowWholeGitGraph: getShowWholeGitGraph,
setShowWholeGitGraph: setShowWholeGitGraph,
}
}
func (self *LocalCommitsController) Keybindings(
getKey func(key string) interface{},
config config.KeybindingConfig,
guards types.KeybindingGuards,
) []*types.Binding {
outsideFilterModeBindings := []*types.Binding{
{
Key: getKey(config.Commits.SquashDown),
Handler: self.squashDown,
Description: self.c.Tr.LcSquashDown,
},
{
Key: getKey(config.Commits.MarkCommitAsFixup),
Handler: self.fixup,
Description: self.c.Tr.LcFixupCommit,
},
{
Key: getKey(config.Commits.RenameCommit),
Handler: self.checkSelected(self.reword),
Description: self.c.Tr.LcRewordCommit,
},
{
Key: getKey(config.Commits.RenameCommitWithEditor),
Handler: self.rewordEditor,
Description: self.c.Tr.LcRenameCommitEditor,
},
{
Key: getKey(config.Universal.Remove),
Handler: self.drop,
Description: self.c.Tr.LcDeleteCommit,
},
{
Key: getKey(config.Universal.Edit),
Handler: self.edit,
Description: self.c.Tr.LcEditCommit,
},
{
Key: getKey(config.Commits.PickCommit),
Handler: self.pick,
Description: self.c.Tr.LcPickCommit,
},
{
Key: getKey(config.Commits.CreateFixupCommit),
Handler: self.checkSelected(self.handleCreateFixupCommit),
Description: self.c.Tr.LcCreateFixupCommit,
},
{
Key: getKey(config.Commits.SquashAboveCommits),
Handler: self.checkSelected(self.handleSquashAllAboveFixupCommits),
Description: self.c.Tr.LcSquashAboveCommits,
},
{
Key: getKey(config.Commits.MoveDownCommit),
Handler: self.handleCommitMoveDown,
Description: self.c.Tr.LcMoveDownCommit,
},
{
Key: getKey(config.Commits.MoveUpCommit),
Handler: self.handleCommitMoveUp,
Description: self.c.Tr.LcMoveUpCommit,
},
{
Key: getKey(config.Commits.AmendToCommit),
Handler: self.handleCommitAmendTo,
Description: self.c.Tr.LcAmendToCommit,
},
{
Key: getKey(config.Commits.RevertCommit),
Handler: self.checkSelected(self.handleCommitRevert),
Description: self.c.Tr.LcRevertCommit,
},
// overriding these navigation keybindings because we might need to load
// more commits on demand
{
Key: getKey(config.Universal.StartSearch),
Handler: func() error { return self.handleOpenSearch("commits") },
Description: self.c.Tr.LcStartSearch,
Tag: "navigation",
},
{
Key: getKey(config.Universal.GotoBottom),
Handler: self.gotoBottom,
Description: self.c.Tr.LcGotoBottom,
Tag: "navigation",
},
{
Key: gocui.MouseLeft,
Handler: func() error { return self.context.HandleClick(self.checkSelected(self.enter)) },
},
}
for _, binding := range outsideFilterModeBindings {
binding.Handler = guards.OutsideFilterMode(binding.Handler)
}
bindings := append(outsideFilterModeBindings, []*types.Binding{
{
Key: getKey(config.Commits.OpenLogMenu),
Handler: self.handleOpenLogMenu,
Description: self.c.Tr.LcOpenLogMenu,
OpensMenu: true,
},
{
Key: getKey(config.Commits.ViewResetOptions),
Handler: self.checkSelected(self.handleCreateCommitResetMenu),
Description: self.c.Tr.LcResetToThisCommit,
},
{
Key: getKey(config.Universal.GoInto),
Handler: self.checkSelected(self.enter),
Description: self.c.Tr.LcViewCommitFiles,
},
{
Key: getKey(config.Commits.CheckoutCommit),
Handler: self.checkSelected(self.handleCheckoutCommit),
Description: self.c.Tr.LcCheckoutCommit,
},
{
Key: getKey(config.Commits.TagCommit),
Handler: self.checkSelected(self.handleTagCommit),
Description: self.c.Tr.LcTagCommit,
},
{
Key: getKey(config.Commits.CopyCommitMessageToClipboard),
Handler: self.checkSelected(self.handleCopySelectedCommitMessageToClipboard),
Description: self.c.Tr.LcCopyCommitMessageToClipboard,
},
{
Key: getKey(config.Commits.OpenInBrowser),
Handler: self.checkSelected(self.handleOpenCommitInBrowser),
Description: self.c.Tr.LcOpenCommitInBrowser,
},
}...)
return append(bindings, self.context.Keybindings(getKey, config, guards)...)
}
func (self *LocalCommitsController) squashDown() error {
if len(self.getCommits()) <= 1 {
return self.c.ErrorMsg(self.c.Tr.YouNoCommitsToSquash)
}
applied, err := self.handleMidRebaseCommand("squash")
if err != nil {
return err
}
if applied {
return nil
}
return self.c.Ask(popup.AskOpts{
Title: self.c.Tr.Squash,
Prompt: self.c.Tr.SureSquashThisCommit,
HandleConfirm: func() error {
return self.c.WithWaitingStatus(self.c.Tr.SquashingStatus, func() error {
self.c.LogAction(self.c.Tr.Actions.SquashCommitDown)
return self.interactiveRebase("squash")
})
},
})
}
func (self *LocalCommitsController) fixup() error {
if len(self.getCommits()) <= 1 {
return self.c.ErrorMsg(self.c.Tr.YouNoCommitsToSquash)
}
applied, err := self.handleMidRebaseCommand("fixup")
if err != nil {
return err
}
if applied {
return nil
}
return self.c.Ask(popup.AskOpts{
Title: self.c.Tr.Fixup,
Prompt: self.c.Tr.SureFixupThisCommit,
HandleConfirm: func() error {
return self.c.WithWaitingStatus(self.c.Tr.FixingStatus, func() error {
self.c.LogAction(self.c.Tr.Actions.FixupCommit)
return self.interactiveRebase("fixup")
})
},
})
}
func (self *LocalCommitsController) reword(commit *models.Commit) error {
applied, err := self.handleMidRebaseCommand("reword")
if err != nil {
return err
}
if applied {
return nil
}
message, err := self.git.Commit.GetCommitMessage(commit.Sha)
if err != nil {
return self.c.Error(err)
}
// TODO: use the commit message panel here
return self.c.Prompt(popup.PromptOpts{
Title: self.c.Tr.LcRewordCommit,
InitialContent: message,
HandleConfirm: func(response string) error {
self.c.LogAction(self.c.Tr.Actions.RewordCommit)
if err := self.git.Rebase.RewordCommit(self.getCommits(), self.getSelectedLocalCommitIdx(), response); err != nil {
return self.c.Error(err)
}
return self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC})
},
})
}
func (self *LocalCommitsController) rewordEditor() error {
applied, err := self.handleMidRebaseCommand("reword")
if err != nil {
return err
}
if applied {
return nil
}
self.c.LogAction(self.c.Tr.Actions.RewordCommit)
subProcess, err := self.git.Rebase.RewordCommitInEditor(
self.getCommits(), self.getSelectedLocalCommitIdx(),
)
if err != nil {
return self.c.Error(err)
}
if subProcess != nil {
return self.c.RunSubprocessAndRefresh(subProcess)
}
return nil
}
func (self *LocalCommitsController) drop() error {
applied, err := self.handleMidRebaseCommand("drop")
if err != nil {
return err
}
if applied {
return nil
}
return self.c.Ask(popup.AskOpts{
Title: self.c.Tr.DeleteCommitTitle,
Prompt: self.c.Tr.DeleteCommitPrompt,
HandleConfirm: func() error {
return self.c.WithWaitingStatus(self.c.Tr.DeletingStatus, func() error {
self.c.LogAction(self.c.Tr.Actions.DropCommit)
return self.interactiveRebase("drop")
})
},
})
}
func (self *LocalCommitsController) edit() error {
applied, err := self.handleMidRebaseCommand("edit")
if err != nil {
return err
}
if applied {
return nil
}
return self.c.WithWaitingStatus(self.c.Tr.RebasingStatus, func() error {
self.c.LogAction(self.c.Tr.Actions.EditCommit)
return self.interactiveRebase("edit")
})
}
func (self *LocalCommitsController) pick() error {
applied, err := self.handleMidRebaseCommand("pick")
if err != nil {
return err
}
if applied {
return nil
}
// at this point we aren't actually rebasing so we will interpret this as an
// attempt to pull. We might revoke this later after enabling configurable keybindings
return self.pullFiles()
}
func (self *LocalCommitsController) interactiveRebase(action string) error {
err := self.git.Rebase.InteractiveRebase(self.getCommits(), self.getSelectedLocalCommitIdx(), action)
return self.checkMergeOrRebase(err)
}
// handleMidRebaseCommand sees if the selected commit is in fact a rebasing
// commit meaning you are trying to edit the todo file rather than actually
// begin a rebase. It then updates the todo file with that action
func (self *LocalCommitsController) handleMidRebaseCommand(action string) (bool, error) {
selectedCommit := self.getSelectedLocalCommit()
if selectedCommit.Status != "rebasing" {
return false, nil
}
// for now we do not support setting 'reword' because it requires an editor
// and that means we either unconditionally wait around for the subprocess to ask for
// our input or we set a lazygit client as the EDITOR env variable and have it
// request us to edit the commit message when prompted.
if action == "reword" {
return true, self.c.ErrorMsg(self.c.Tr.LcRewordNotSupported)
}
self.c.LogAction("Update rebase TODO")
self.c.LogCommand(
fmt.Sprintf("Updating rebase action of commit %s to '%s'", selectedCommit.ShortSha(), action),
false,
)
if err := self.git.Rebase.EditRebaseTodo(
self.getSelectedLocalCommitIdx(), action,
); err != nil {
return false, self.c.Error(err)
}
return true, self.c.Refresh(types.RefreshOptions{
Mode: types.SYNC, Scope: []types.RefreshableView{types.REBASE_COMMITS},
})
}
func (self *LocalCommitsController) handleCommitMoveDown() error {
index := self.context.GetPanelState().GetSelectedLineIdx()
commits := self.getCommits()
selectedCommit := self.getCommits()[index]
if selectedCommit.Status == "rebasing" {
if commits[index+1].Status != "rebasing" {
return nil
}
// logging directly here because MoveTodoDown doesn't have enough information
// to provide a useful log
self.c.LogAction(self.c.Tr.Actions.MoveCommitDown)
self.c.LogCommand(fmt.Sprintf("Moving commit %s down", selectedCommit.ShortSha()), false)
if err := self.git.Rebase.MoveTodoDown(index); err != nil {
return self.c.Error(err)
}
self.context.HandleNextLine()
return self.c.Refresh(types.RefreshOptions{
Mode: types.SYNC, Scope: []types.RefreshableView{types.REBASE_COMMITS},
})
}
return self.c.WithWaitingStatus(self.c.Tr.MovingStatus, func() error {
self.c.LogAction(self.c.Tr.Actions.MoveCommitDown)
err := self.git.Rebase.MoveCommitDown(self.getCommits(), index)
if err == nil {
self.context.HandleNextLine()
}
return self.checkMergeOrRebase(err)
})
}
func (self *LocalCommitsController) handleCommitMoveUp() error {
index := self.context.GetPanelState().GetSelectedLineIdx()
if index == 0 {
return nil
}
selectedCommit := self.getCommits()[index]
if selectedCommit.Status == "rebasing" {
// logging directly here because MoveTodoDown doesn't have enough information
// to provide a useful log
self.c.LogAction(self.c.Tr.Actions.MoveCommitUp)
self.c.LogCommand(
fmt.Sprintf("Moving commit %s up", selectedCommit.ShortSha()),
false,
)
if err := self.git.Rebase.MoveTodoDown(index - 1); err != nil {
return self.c.Error(err)
}
self.context.HandlePrevLine()
return self.c.Refresh(types.RefreshOptions{
Mode: types.SYNC, Scope: []types.RefreshableView{types.REBASE_COMMITS},
})
}
return self.c.WithWaitingStatus(self.c.Tr.MovingStatus, func() error {
self.c.LogAction(self.c.Tr.Actions.MoveCommitUp)
err := self.git.Rebase.MoveCommitDown(self.getCommits(), index-1)
if err == nil {
self.context.HandlePrevLine()
}
return self.checkMergeOrRebase(err)
})
}
func (self *LocalCommitsController) handleCommitAmendTo() error {
return self.c.Ask(popup.AskOpts{
Title: self.c.Tr.AmendCommitTitle,
Prompt: self.c.Tr.AmendCommitPrompt,
HandleConfirm: func() error {
return self.c.WithWaitingStatus(self.c.Tr.AmendingStatus, func() error {
self.c.LogAction(self.c.Tr.Actions.AmendCommit)
err := self.git.Rebase.AmendTo(self.getSelectedLocalCommit().Sha)
return self.checkMergeOrRebase(err)
})
},
})
}
func (self *LocalCommitsController) handleCommitRevert(commit *models.Commit) error {
if commit.IsMerge() {
return self.createRevertMergeCommitMenu(commit)
} else {
return self.c.Ask(popup.AskOpts{
Title: self.c.Tr.Actions.RevertCommit,
Prompt: utils.ResolvePlaceholderString(
self.c.Tr.ConfirmRevertCommit,
map[string]string{
"selectedCommit": commit.ShortSha(),
}),
HandleConfirm: func() error {
self.c.LogAction(self.c.Tr.Actions.RevertCommit)
if err := self.git.Commit.Revert(commit.Sha); err != nil {
return self.c.Error(err)
}
return self.afterRevertCommit()
},
})
}
}
func (self *LocalCommitsController) createRevertMergeCommitMenu(commit *models.Commit) error {
menuItems := make([]*popup.MenuItem, len(commit.Parents))
for i, parentSha := range commit.Parents {
i := i
message, err := self.git.Commit.GetCommitMessageFirstLine(parentSha)
if err != nil {
return self.c.Error(err)
}
menuItems[i] = &popup.MenuItem{
DisplayString: fmt.Sprintf("%s: %s", utils.SafeTruncate(parentSha, 8), message),
OnPress: func() error {
parentNumber := i + 1
self.c.LogAction(self.c.Tr.Actions.RevertCommit)
if err := self.git.Commit.RevertMerge(commit.Sha, parentNumber); err != nil {
return self.c.Error(err)
}
return self.afterRevertCommit()
},
}
}
return self.c.Menu(popup.CreateMenuOptions{Title: self.c.Tr.SelectParentCommitForMerge, Items: menuItems})
}
func (self *LocalCommitsController) afterRevertCommit() error {
self.context.HandleNextLine()
return self.c.Refresh(types.RefreshOptions{
Mode: types.BLOCK_UI, Scope: []types.RefreshableView{types.COMMITS, types.BRANCHES},
})
}
func (self *LocalCommitsController) enter(commit *models.Commit) error {
return self.switchToCommitFilesContext(SwitchToCommitFilesContextOpts{
RefName: commit.Sha,
CanRebase: true,
Context: self.context,
WindowName: "commits",
})
}
func (self *LocalCommitsController) handleCreateFixupCommit(commit *models.Commit) error {
prompt := utils.ResolvePlaceholderString(
self.c.Tr.SureCreateFixupCommit,
map[string]string{
"commit": commit.Sha,
},
)
return self.c.Ask(popup.AskOpts{
Title: self.c.Tr.CreateFixupCommit,
Prompt: prompt,
HandleConfirm: func() error {
self.c.LogAction(self.c.Tr.Actions.CreateFixupCommit)
if err := self.git.Commit.CreateFixupCommit(commit.Sha); err != nil {
return self.c.Error(err)
}
return self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC})
},
})
}
func (self *LocalCommitsController) handleSquashAllAboveFixupCommits(commit *models.Commit) error {
prompt := utils.ResolvePlaceholderString(
self.c.Tr.SureSquashAboveCommits,
map[string]string{
"commit": commit.Sha,
},
)
return self.c.Ask(popup.AskOpts{
Title: self.c.Tr.SquashAboveCommits,
Prompt: prompt,
HandleConfirm: func() error {
return self.c.WithWaitingStatus(self.c.Tr.SquashingStatus, func() error {
self.c.LogAction(self.c.Tr.Actions.SquashAllAboveFixupCommits)
err := self.git.Rebase.SquashAllAboveFixupCommits(commit.Sha)
return self.checkMergeOrRebase(err)
})
},
})
}
func (self *LocalCommitsController) handleTagCommit(commit *models.Commit) error {
return self.createTagMenu(commit.Sha)
}
func (self *LocalCommitsController) handleCheckoutCommit(commit *models.Commit) error {
return self.c.Ask(popup.AskOpts{
Title: self.c.Tr.LcCheckoutCommit,
Prompt: self.c.Tr.SureCheckoutThisCommit,
HandleConfirm: func() error {
self.c.LogAction(self.c.Tr.Actions.CheckoutCommit)
return self.refHelper.CheckoutRef(commit.Sha, types.CheckoutRefOptions{})
},
})
}
func (self *LocalCommitsController) handleCreateCommitResetMenu(commit *models.Commit) error {
return self.refHelper.CreateGitResetMenu(commit.Sha)
}
func (self *LocalCommitsController) handleOpenSearch(string) error {
// we usually lazyload these commits but now that we're searching we need to load them now
if self.getLimitCommits() {
self.setLimitCommits(false)
if err := self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.COMMITS}}); err != nil {
return err
}
}
return self.openSearch("commits")
}
func (self *LocalCommitsController) gotoBottom() error {
// we usually lazyload these commits but now that we're jumping to the bottom we need to load them now
if self.getLimitCommits() {
self.setLimitCommits(false)
if err := self.c.Refresh(types.RefreshOptions{Mode: types.SYNC, Scope: []types.RefreshableView{types.COMMITS}}); err != nil {
return err
}
}
self.context.HandleGotoBottom()
return nil
}
func (self *LocalCommitsController) handleCopySelectedCommitMessageToClipboard(commit *models.Commit) error {
message, err := self.git.Commit.GetCommitMessage(commit.Sha)
if err != nil {
return self.c.Error(err)
}
self.c.LogAction(self.c.Tr.Actions.CopyCommitMessageToClipboard)
if err := self.os.CopyToClipboard(message); err != nil {
return self.c.Error(err)
}
self.c.Toast(self.c.Tr.CommitMessageCopiedToClipboard)
return nil
}
func (self *LocalCommitsController) handleOpenLogMenu() error {
return self.c.Menu(popup.CreateMenuOptions{
Title: self.c.Tr.LogMenuTitle,
Items: []*popup.MenuItem{
{
DisplayString: self.c.Tr.ToggleShowGitGraphAll,
OnPress: func() error {
self.setShowWholeGitGraph(!self.getShowWholeGitGraph())
if self.getShowWholeGitGraph() {
self.setLimitCommits(false)
}
return self.c.WithWaitingStatus(self.c.Tr.LcLoadingCommits, func() error {
return self.c.Refresh(types.RefreshOptions{Mode: types.SYNC, Scope: []types.RefreshableView{types.COMMITS}})
})
},
},
{
DisplayString: self.c.Tr.ShowGitGraph,
OpensMenu: true,
OnPress: func() error {
onPress := func(value string) func() error {
return func() error {
self.c.UserConfig.Git.Log.ShowGraph = value
return nil
}
}
return self.c.Menu(popup.CreateMenuOptions{
Title: self.c.Tr.LogMenuTitle,
Items: []*popup.MenuItem{
{
DisplayString: "always",
OnPress: onPress("always"),
},
{
DisplayString: "never",
OnPress: onPress("never"),
},
{
DisplayString: "when maximised",
OnPress: onPress("when-maximised"),
},
},
})
},
},
{
DisplayString: self.c.Tr.SortCommits,
OpensMenu: true,
OnPress: func() error {
onPress := func(value string) func() error {
return func() error {
self.c.UserConfig.Git.Log.Order = value
return self.c.WithWaitingStatus(self.c.Tr.LcLoadingCommits, func() error {
return self.c.Refresh(types.RefreshOptions{Mode: types.SYNC, Scope: []types.RefreshableView{types.COMMITS}})
})
}
}
return self.c.Menu(popup.CreateMenuOptions{
Title: self.c.Tr.LogMenuTitle,
Items: []*popup.MenuItem{
{
DisplayString: "topological (topo-order)",
OnPress: onPress("topo-order"),
},
{
DisplayString: "date-order",
OnPress: onPress("date-order"),
},
{
DisplayString: "author-date-order",
OnPress: onPress("author-date-order"),
},
},
})
},
},
},
})
}
func (self *LocalCommitsController) handleOpenCommitInBrowser(commit *models.Commit) error {
hostingServiceMgr := self.getHostingServiceMgr()
url, err := hostingServiceMgr.GetCommitURL(commit.Sha)
if err != nil {
return self.c.Error(err)
}
self.c.LogAction(self.c.Tr.Actions.OpenCommitInBrowser)
if err := self.os.OpenLink(url); err != nil {
return self.c.Error(err)
}
return nil
}
func (self *LocalCommitsController) checkSelected(callback func(*models.Commit) error) func() error {
return func() error {
commit := self.getSelectedLocalCommit()
if commit == nil {
return nil
}
return callback(commit)
}
}
func (self *LocalCommitsController) Context() types.Context {
return self.context
}

View File

@ -0,0 +1,70 @@
package controllers
import (
"github.com/jesseduffield/gocui"
"github.com/jesseduffield/lazygit/pkg/config"
"github.com/jesseduffield/lazygit/pkg/gui/popup"
"github.com/jesseduffield/lazygit/pkg/gui/types"
)
type MenuController struct {
c *ControllerCommon
context types.IListContext
getSelectedMenuItem func() *popup.MenuItem
}
var _ types.IController = &MenuController{}
func NewMenuController(
c *ControllerCommon,
context types.IListContext,
getSelectedMenuItem func() *popup.MenuItem,
) *MenuController {
return &MenuController{
c: c,
context: context,
getSelectedMenuItem: getSelectedMenuItem,
}
}
func (self *MenuController) Keybindings(getKey func(key string) interface{}, config config.KeybindingConfig, guards types.KeybindingGuards) []*types.Binding {
bindings := []*types.Binding{
{
Key: getKey(config.Universal.Select),
Handler: self.press,
},
{
Key: getKey(config.Universal.Confirm),
Handler: self.press,
},
{
Key: getKey(config.Universal.ConfirmAlt1),
Handler: self.press,
},
{
Key: gocui.MouseLeft,
Handler: func() error { return self.context.HandleClick(self.press) },
},
}
return append(bindings, self.context.Keybindings(getKey, config, guards)...)
}
func (self *MenuController) press() error {
selectedItem := self.getSelectedMenuItem()
if err := self.c.PopContext(); err != nil {
return err
}
if err := selectedItem.OnPress(); err != nil {
return err
}
return nil
}
func (self *MenuController) Context() types.Context {
return self.context
}

View File

@ -0,0 +1,204 @@
package controllers
import (
"sync"
"github.com/jesseduffield/gocui"
"github.com/jesseduffield/lazygit/pkg/commands"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/config"
"github.com/jesseduffield/lazygit/pkg/gui/context"
"github.com/jesseduffield/lazygit/pkg/gui/popup"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/utils"
)
type RemotesController struct {
c *ControllerCommon
context types.IListContext
git *commands.GitCommand
getSelectedRemote func() *models.Remote
setRemoteBranches func([]*models.RemoteBranch)
allContexts context.ContextTree
fetchMutex *sync.Mutex
}
var _ types.IController = &RemotesController{}
func NewRemotesController(
c *ControllerCommon,
context types.IListContext,
git *commands.GitCommand,
allContexts context.ContextTree,
getSelectedRemote func() *models.Remote,
setRemoteBranches func([]*models.RemoteBranch),
fetchMutex *sync.Mutex,
) *RemotesController {
return &RemotesController{
c: c,
git: git,
allContexts: allContexts,
context: context,
getSelectedRemote: getSelectedRemote,
setRemoteBranches: setRemoteBranches,
fetchMutex: fetchMutex,
}
}
func (self *RemotesController) Keybindings(getKey func(key string) interface{}, config config.KeybindingConfig, guards types.KeybindingGuards) []*types.Binding {
bindings := []*types.Binding{
{
Key: getKey(config.Universal.GoInto),
Handler: self.checkSelected(self.enter),
},
{
Key: gocui.MouseLeft,
Handler: func() error { return self.context.HandleClick(self.checkSelected(self.enter)) },
},
{
Key: getKey(config.Branches.FetchRemote),
Handler: self.checkSelected(self.fetch),
Description: self.c.Tr.LcFetchRemote,
},
{
Key: getKey(config.Universal.New),
Handler: self.add,
Description: self.c.Tr.LcAddNewRemote,
},
{
Key: getKey(config.Universal.Remove),
Handler: self.checkSelected(self.remove),
Description: self.c.Tr.LcRemoveRemote,
},
{
Key: getKey(config.Universal.Edit),
Handler: self.checkSelected(self.edit),
Description: self.c.Tr.LcEditRemote,
},
}
return append(bindings, self.context.Keybindings(getKey, config, guards)...)
}
func (self *RemotesController) enter(remote *models.Remote) error {
// naive implementation: get the branches from the remote and render them to the list, change the context
self.setRemoteBranches(remote.Branches)
newSelectedLine := 0
if len(remote.Branches) == 0 {
newSelectedLine = -1
}
self.allContexts.RemoteBranches.GetPanelState().SetSelectedLineIdx(newSelectedLine)
return self.c.PushContext(self.allContexts.RemoteBranches)
}
func (self *RemotesController) add() error {
return self.c.Prompt(popup.PromptOpts{
Title: self.c.Tr.LcNewRemoteName,
HandleConfirm: func(remoteName string) error {
return self.c.Prompt(popup.PromptOpts{
Title: self.c.Tr.LcNewRemoteUrl,
HandleConfirm: func(remoteUrl string) error {
self.c.LogAction(self.c.Tr.Actions.AddRemote)
if err := self.git.Remote.AddRemote(remoteName, remoteUrl); err != nil {
return err
}
return self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.REMOTES}})
},
})
},
})
}
func (self *RemotesController) remove(remote *models.Remote) error {
return self.c.Ask(popup.AskOpts{
Title: self.c.Tr.LcRemoveRemote,
Prompt: self.c.Tr.LcRemoveRemotePrompt + " '" + remote.Name + "'?",
HandleConfirm: func() error {
self.c.LogAction(self.c.Tr.Actions.RemoveRemote)
if err := self.git.Remote.RemoveRemote(remote.Name); err != nil {
return self.c.Error(err)
}
return self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.BRANCHES, types.REMOTES}})
},
})
}
func (self *RemotesController) edit(remote *models.Remote) error {
editNameMessage := utils.ResolvePlaceholderString(
self.c.Tr.LcEditRemoteName,
map[string]string{
"remoteName": remote.Name,
},
)
return self.c.Prompt(popup.PromptOpts{
Title: editNameMessage,
InitialContent: remote.Name,
HandleConfirm: func(updatedRemoteName string) error {
if updatedRemoteName != remote.Name {
self.c.LogAction(self.c.Tr.Actions.UpdateRemote)
if err := self.git.Remote.RenameRemote(remote.Name, updatedRemoteName); err != nil {
return self.c.Error(err)
}
}
editUrlMessage := utils.ResolvePlaceholderString(
self.c.Tr.LcEditRemoteUrl,
map[string]string{
"remoteName": updatedRemoteName,
},
)
urls := remote.Urls
url := ""
if len(urls) > 0 {
url = urls[0]
}
return self.c.Prompt(popup.PromptOpts{
Title: editUrlMessage,
InitialContent: url,
HandleConfirm: func(updatedRemoteUrl string) error {
self.c.LogAction(self.c.Tr.Actions.UpdateRemote)
if err := self.git.Remote.UpdateRemoteUrl(updatedRemoteName, updatedRemoteUrl); err != nil {
return self.c.Error(err)
}
return self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.BRANCHES, types.REMOTES}})
},
})
},
})
}
func (self *RemotesController) fetch(remote *models.Remote) error {
return self.c.WithWaitingStatus(self.c.Tr.FetchingRemoteStatus, func() error {
self.fetchMutex.Lock()
defer self.fetchMutex.Unlock()
err := self.git.Sync.FetchRemote(remote.Name)
if err != nil {
_ = self.c.Error(err)
}
return self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.BRANCHES, types.REMOTES}})
})
}
func (self *RemotesController) checkSelected(callback func(*models.Remote) error) func() error {
return func() error {
file := self.getSelectedRemote()
if file == nil {
return nil
}
return callback(file)
}
}
func (self *RemotesController) Context() types.Context {
return self.context
}

View File

@ -5,65 +5,57 @@ import (
"path/filepath"
"strings"
"github.com/jesseduffield/gocui"
"github.com/jesseduffield/lazygit/pkg/commands"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/common"
"github.com/jesseduffield/lazygit/pkg/config"
"github.com/jesseduffield/lazygit/pkg/gui/popup"
"github.com/jesseduffield/lazygit/pkg/gui/style"
"github.com/jesseduffield/lazygit/pkg/gui/types"
)
// if Go let me do private struct embedding of structs with public fields (which it should)
// I would just do that. But alas.
type ControllerCommon struct {
*common.Common
IGuiCommon
type SubmodulesController struct {
c *ControllerCommon
context types.IListContext
git *commands.GitCommand
enterSubmodule func(submodule *models.SubmoduleConfig) error
getSelectedSubmodule func() *models.SubmoduleConfig
}
type SubmodulesController struct {
// I've said publicly that I'm against single-letter variable names but in this
// case I would actually prefer a _zero_ letter variable name in the form of
// struct embedding, but Go does not allow hiding public fields in an embedded struct
// to the client
c *ControllerCommon
enterSubmoduleFn func(submodule *models.SubmoduleConfig) error
getSelectedSubmodule func() *models.SubmoduleConfig
git *commands.GitCommand
submodules []*models.SubmoduleConfig
}
var _ types.IController = &SubmodulesController{}
func NewSubmodulesController(
c *ControllerCommon,
enterSubmoduleFn func(submodule *models.SubmoduleConfig) error,
context types.IListContext,
git *commands.GitCommand,
submodules []*models.SubmoduleConfig,
enterSubmodule func(submodule *models.SubmoduleConfig) error,
getSelectedSubmodule func() *models.SubmoduleConfig,
) *SubmodulesController {
return &SubmodulesController{
c: c,
enterSubmoduleFn: enterSubmoduleFn,
context: context,
git: git,
submodules: submodules,
enterSubmodule: enterSubmodule,
getSelectedSubmodule: getSelectedSubmodule,
}
}
func (self *SubmodulesController) Keybindings(getKey func(key string) interface{}, config config.KeybindingConfig) []*types.Binding {
return []*types.Binding{
func (self *SubmodulesController) Keybindings(getKey func(key string) interface{}, config config.KeybindingConfig, guards types.KeybindingGuards) []*types.Binding {
bindings := []*types.Binding{
{
Key: getKey(config.Universal.GoInto),
Handler: self.forSubmodule(self.enter),
Handler: self.checkSelected(self.enter),
Description: self.c.Tr.LcEnterSubmodule,
},
{
Key: getKey(config.Universal.Remove),
Handler: self.forSubmodule(self.remove),
Handler: self.checkSelected(self.remove),
Description: self.c.Tr.LcRemoveSubmodule,
},
{
Key: getKey(config.Submodules.Update),
Handler: self.forSubmodule(self.update),
Handler: self.checkSelected(self.update),
Description: self.c.Tr.LcSubmoduleUpdate,
},
{
@ -73,12 +65,12 @@ func (self *SubmodulesController) Keybindings(getKey func(key string) interface{
},
{
Key: getKey(config.Universal.Edit),
Handler: self.forSubmodule(self.editURL),
Handler: self.checkSelected(self.editURL),
Description: self.c.Tr.LcEditSubmoduleUrl,
},
{
Key: getKey(config.Submodules.Init),
Handler: self.forSubmodule(self.init),
Handler: self.checkSelected(self.init),
Description: self.c.Tr.LcInitSubmodule,
},
{
@ -87,11 +79,17 @@ func (self *SubmodulesController) Keybindings(getKey func(key string) interface{
Description: self.c.Tr.LcViewBulkSubmoduleOptions,
OpensMenu: true,
},
{
Key: gocui.MouseLeft,
Handler: func() error { return self.context.HandleClick(self.checkSelected(self.enter)) },
},
}
return append(bindings, self.context.Keybindings(getKey, config, guards)...)
}
func (self *SubmodulesController) enter(submodule *models.SubmoduleConfig) error {
return self.enterSubmoduleFn(submodule)
return self.enterSubmodule(submodule)
}
func (self *SubmodulesController) add() error {
@ -231,7 +229,7 @@ func (self *SubmodulesController) remove(submodule *models.SubmoduleConfig) erro
})
}
func (self *SubmodulesController) forSubmodule(callback func(*models.SubmoduleConfig) error) func() error {
func (self *SubmodulesController) checkSelected(callback func(*models.SubmoduleConfig) error) func() error {
return func() error {
submodule := self.getSelectedSubmodule()
if submodule == nil {
@ -241,3 +239,7 @@ func (self *SubmodulesController) forSubmodule(callback func(*models.SubmoduleCo
return callback(submodule)
}
}
func (self *SubmodulesController) Context() types.Context {
return self.context
}

View File

@ -0,0 +1,253 @@
package controllers
import (
"fmt"
"strings"
"github.com/jesseduffield/lazygit/pkg/commands"
"github.com/jesseduffield/lazygit/pkg/commands/git_commands"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/config"
"github.com/jesseduffield/lazygit/pkg/gui/popup"
"github.com/jesseduffield/lazygit/pkg/gui/types"
)
type SyncController struct {
// I've said publicly that I'm against single-letter variable names but in this
// case I would actually prefer a _zero_ letter variable name in the form of
// struct embedding, but Go does not allow hiding public fields in an embedded struct
// to the client
c *ControllerCommon
git *commands.GitCommand
getCheckedOutBranch func() *models.Branch
suggestionsHelper ISuggestionsHelper
getSuggestedRemote func() string
checkMergeOrRebase func(error) error
}
var _ types.IController = &SyncController{}
func NewSyncController(
c *ControllerCommon,
git *commands.GitCommand,
getCheckedOutBranch func() *models.Branch,
suggestionsHelper ISuggestionsHelper,
getSuggestedRemote func() string,
checkMergeOrRebase func(error) error,
) *SyncController {
return &SyncController{
c: c,
git: git,
getCheckedOutBranch: getCheckedOutBranch,
suggestionsHelper: suggestionsHelper,
getSuggestedRemote: getSuggestedRemote,
checkMergeOrRebase: checkMergeOrRebase,
}
}
func (self *SyncController) Keybindings(getKey func(key string) interface{}, config config.KeybindingConfig, guards types.KeybindingGuards) []*types.Binding {
bindings := []*types.Binding{
{
Key: getKey(config.Universal.PushFiles),
Handler: guards.NoPopupPanel(self.HandlePush),
Description: self.c.Tr.LcPush,
},
{
Key: getKey(config.Universal.PullFiles),
Handler: guards.NoPopupPanel(self.HandlePull),
Description: self.c.Tr.LcPull,
},
}
return bindings
}
func (self *SyncController) Context() types.Context {
return nil
}
func (self *SyncController) HandlePush() error {
return self.branchCheckedOut(self.push)()
}
func (self *SyncController) HandlePull() error {
return self.branchCheckedOut(self.pull)()
}
func (self *SyncController) branchCheckedOut(f func(*models.Branch) error) func() error {
return func() error {
currentBranch := self.getCheckedOutBranch()
if currentBranch == nil {
// need to wait for branches to refresh
return nil
}
return f(currentBranch)
}
}
func (self *SyncController) push(currentBranch *models.Branch) error {
// if we have pullables we'll ask if the user wants to force push
if currentBranch.IsTrackingRemote() {
opts := pushOpts{
force: false,
upstreamRemote: currentBranch.UpstreamRemote,
upstreamBranch: currentBranch.UpstreamBranch,
}
if currentBranch.HasCommitsToPull() {
opts.force = true
return self.requestToForcePush(opts)
} else {
return self.pushAux(opts)
}
} else {
if self.git.Config.GetPushToCurrent() {
return self.pushAux(pushOpts{setUpstream: true})
} else {
return self.promptForUpstream(currentBranch, func(upstream string) error {
var upstreamBranch, upstreamRemote string
split := strings.Split(upstream, " ")
if len(split) == 2 {
upstreamRemote = split[0]
upstreamBranch = split[1]
} else {
upstreamRemote = upstream
upstreamBranch = ""
}
return self.pushAux(pushOpts{
force: false,
upstreamRemote: upstreamRemote,
upstreamBranch: upstreamBranch,
setUpstream: true,
})
})
}
}
}
func (self *SyncController) pull(currentBranch *models.Branch) error {
action := self.c.Tr.Actions.Pull
// if we have no upstream branch we need to set that first
if !currentBranch.IsTrackingRemote() {
return self.promptForUpstream(currentBranch, func(upstream string) error {
var upstreamBranch, upstreamRemote string
split := strings.Split(upstream, " ")
if len(split) != 2 {
return self.c.ErrorMsg(self.c.Tr.InvalidUpstream)
}
upstreamRemote = split[0]
upstreamBranch = split[1]
if err := self.git.Branch.SetCurrentBranchUpstream(upstreamRemote, upstreamBranch); err != nil {
errorMessage := err.Error()
if strings.Contains(errorMessage, "does not exist") {
errorMessage = fmt.Sprintf("upstream branch %s not found.\nIf you expect it to exist, you should fetch (with 'f').\nOtherwise, you should push (with 'shift+P')", upstream)
}
return self.c.ErrorMsg(errorMessage)
}
return self.PullAux(PullFilesOptions{UpstreamRemote: upstreamRemote, UpstreamBranch: upstreamBranch, Action: action})
})
}
return self.PullAux(PullFilesOptions{UpstreamRemote: currentBranch.UpstreamRemote, UpstreamBranch: currentBranch.UpstreamBranch, Action: action})
}
func (self *SyncController) promptForUpstream(currentBranch *models.Branch, onConfirm func(string) error) error {
suggestedRemote := self.getSuggestedRemote()
return self.c.Prompt(popup.PromptOpts{
Title: self.c.Tr.EnterUpstream,
InitialContent: suggestedRemote + " " + currentBranch.Name,
FindSuggestionsFunc: self.suggestionsHelper.GetRemoteBranchesSuggestionsFunc(" "),
HandleConfirm: onConfirm,
})
}
type PullFilesOptions struct {
UpstreamRemote string
UpstreamBranch string
FastForwardOnly bool
Action string
}
func (self *SyncController) PullAux(opts PullFilesOptions) error {
return self.c.WithLoaderPanel(self.c.Tr.PullWait, func() error {
return self.pullWithLock(opts)
})
}
func (self *SyncController) pullWithLock(opts PullFilesOptions) error {
self.c.LogAction(opts.Action)
err := self.git.Sync.Pull(
git_commands.PullOptions{
RemoteName: opts.UpstreamRemote,
BranchName: opts.UpstreamBranch,
FastForwardOnly: opts.FastForwardOnly,
},
)
return self.checkMergeOrRebase(err)
}
type pushOpts struct {
force bool
upstreamRemote string
upstreamBranch string
setUpstream bool
}
func (self *SyncController) pushAux(opts pushOpts) error {
return self.c.WithLoaderPanel(self.c.Tr.PushWait, func() error {
self.c.LogAction(self.c.Tr.Actions.Push)
err := self.git.Sync.Push(git_commands.PushOpts{
Force: opts.force,
UpstreamRemote: opts.upstreamRemote,
UpstreamBranch: opts.upstreamBranch,
SetUpstream: opts.setUpstream,
})
if err != nil {
if !opts.force && strings.Contains(err.Error(), "Updates were rejected") {
forcePushDisabled := self.c.UserConfig.Git.DisableForcePushing
if forcePushDisabled {
_ = self.c.ErrorMsg(self.c.Tr.UpdatesRejectedAndForcePushDisabled)
return nil
}
_ = self.c.Ask(popup.AskOpts{
Title: self.c.Tr.ForcePush,
Prompt: self.c.Tr.ForcePushPrompt,
HandleConfirm: func() error {
newOpts := opts
newOpts.force = true
return self.pushAux(newOpts)
},
})
return nil
}
_ = self.c.Error(err)
}
return self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC})
})
}
func (self *SyncController) requestToForcePush(opts pushOpts) error {
forcePushDisabled := self.c.UserConfig.Git.DisableForcePushing
if forcePushDisabled {
return self.c.ErrorMsg(self.c.Tr.ForcePushDisabled)
}
return self.c.Ask(popup.AskOpts{
Title: self.c.Tr.ForcePush,
Prompt: self.c.Tr.ForcePushPrompt,
HandleConfirm: func() error {
return self.pushAux(opts)
},
})
}

View File

@ -0,0 +1,229 @@
package controllers
import (
"github.com/jesseduffield/lazygit/pkg/commands"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/config"
"github.com/jesseduffield/lazygit/pkg/gui/context"
"github.com/jesseduffield/lazygit/pkg/gui/popup"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/utils"
)
type TagsController struct {
c *ControllerCommon
context types.IListContext
git *commands.GitCommand
allContexts context.ContextTree
refHelper IRefHelper
suggestionsHelper ISuggestionsHelper
getSelectedTag func() *models.Tag
switchToSubCommitsContext func(string) error
}
var _ types.IController = &TagsController{}
func NewTagsController(
c *ControllerCommon,
context types.IListContext,
git *commands.GitCommand,
allContexts context.ContextTree,
refHelper IRefHelper,
suggestionsHelper ISuggestionsHelper,
getSelectedTag func() *models.Tag,
switchToSubCommitsContext func(string) error,
) *TagsController {
return &TagsController{
c: c,
context: context,
git: git,
allContexts: allContexts,
refHelper: refHelper,
suggestionsHelper: suggestionsHelper,
getSelectedTag: getSelectedTag,
switchToSubCommitsContext: switchToSubCommitsContext,
}
}
func (self *TagsController) Keybindings(getKey func(key string) interface{}, config config.KeybindingConfig, guards types.KeybindingGuards) []*types.Binding {
bindings := []*types.Binding{
{
Key: getKey(config.Universal.Select),
Handler: self.withSelectedTag(self.checkout),
Description: self.c.Tr.LcCheckout,
},
{
Key: getKey(config.Universal.Remove),
Handler: self.withSelectedTag(self.delete),
Description: self.c.Tr.LcDeleteTag,
},
{
Key: getKey(config.Branches.PushTag),
Handler: self.withSelectedTag(self.push),
Description: self.c.Tr.LcPushTag,
},
{
Key: getKey(config.Universal.New),
Handler: self.create,
Description: self.c.Tr.LcCreateTag,
},
{
Key: getKey(config.Commits.ViewResetOptions),
Handler: self.withSelectedTag(self.createResetMenu),
Description: self.c.Tr.LcViewResetOptions,
OpensMenu: true,
},
{
Key: getKey(config.Universal.GoInto),
Handler: self.withSelectedTag(self.enter),
Description: self.c.Tr.LcViewCommits,
},
}
return append(bindings, self.context.Keybindings(getKey, config, guards)...)
}
func (self *TagsController) checkout(tag *models.Tag) error {
self.c.LogAction(self.c.Tr.Actions.CheckoutTag)
if err := self.refHelper.CheckoutRef(tag.Name, types.CheckoutRefOptions{}); err != nil {
return err
}
return self.c.PushContext(self.allContexts.Branches)
}
func (self *TagsController) enter(tag *models.Tag) error {
return self.switchToSubCommitsContext(tag.Name)
}
func (self *TagsController) delete(tag *models.Tag) error {
prompt := utils.ResolvePlaceholderString(
self.c.Tr.DeleteTagPrompt,
map[string]string{
"tagName": tag.Name,
},
)
return self.c.Ask(popup.AskOpts{
Title: self.c.Tr.DeleteTagTitle,
Prompt: prompt,
HandleConfirm: func() error {
self.c.LogAction(self.c.Tr.Actions.DeleteTag)
if err := self.git.Tag.Delete(tag.Name); err != nil {
return self.c.Error(err)
}
return self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.COMMITS, types.TAGS}})
},
})
}
func (self *TagsController) push(tag *models.Tag) error {
title := utils.ResolvePlaceholderString(
self.c.Tr.PushTagTitle,
map[string]string{
"tagName": tag.Name,
},
)
return self.c.Prompt(popup.PromptOpts{
Title: title,
InitialContent: "origin",
FindSuggestionsFunc: self.suggestionsHelper.GetRemoteSuggestionsFunc(),
HandleConfirm: func(response string) error {
return self.c.WithWaitingStatus(self.c.Tr.PushingTagStatus, func() error {
self.c.LogAction(self.c.Tr.Actions.PushTag)
err := self.git.Tag.Push(response, tag.Name)
if err != nil {
_ = self.c.Error(err)
}
return nil
})
},
})
}
func (self *TagsController) createResetMenu(tag *models.Tag) error {
return self.refHelper.CreateGitResetMenu(tag.Name)
}
func (self *TagsController) CreateTagMenu(commitSha string) error {
return self.c.Menu(popup.CreateMenuOptions{
Title: self.c.Tr.TagMenuTitle,
Items: []*popup.MenuItem{
{
DisplayString: self.c.Tr.LcLightweightTag,
OnPress: func() error {
return self.handleCreateLightweightTag(commitSha)
},
},
{
DisplayString: self.c.Tr.LcAnnotatedTag,
OnPress: func() error {
return self.handleCreateAnnotatedTag(commitSha)
},
},
},
})
}
func (self *TagsController) afterTagCreate() error {
self.context.GetPanelState().SetSelectedLineIdx(0)
return self.c.Refresh(types.RefreshOptions{
Mode: types.ASYNC, Scope: []types.RefreshableView{types.COMMITS, types.TAGS},
})
}
func (self *TagsController) handleCreateAnnotatedTag(commitSha string) error {
return self.c.Prompt(popup.PromptOpts{
Title: self.c.Tr.TagNameTitle,
HandleConfirm: func(tagName string) error {
return self.c.Prompt(popup.PromptOpts{
Title: self.c.Tr.TagMessageTitle,
HandleConfirm: func(msg string) error {
self.c.LogAction(self.c.Tr.Actions.CreateAnnotatedTag)
if err := self.git.Tag.CreateAnnotated(tagName, commitSha, msg); err != nil {
return self.c.Error(err)
}
return self.afterTagCreate()
},
})
},
})
}
func (self *TagsController) handleCreateLightweightTag(commitSha string) error {
return self.c.Prompt(popup.PromptOpts{
Title: self.c.Tr.TagNameTitle,
HandleConfirm: func(tagName string) error {
self.c.LogAction(self.c.Tr.Actions.CreateLightweightTag)
if err := self.git.Tag.CreateLightweight(tagName, commitSha); err != nil {
return self.c.Error(err)
}
return self.afterTagCreate()
},
})
}
func (self *TagsController) create() error {
// leaving commit SHA blank so that we're just creating the tag for the current commit
return self.CreateTagMenu("")
}
func (self *TagsController) withSelectedTag(f func(tag *models.Tag) error) func() error {
return func() error {
tag := self.getSelectedTag()
if tag == nil {
return nil
}
return f(tag)
}
}
func (self *TagsController) Context() types.Context {
return self.context
}

View File

@ -1,6 +1,9 @@
package controllers
import (
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/jesseduffield/lazygit/pkg/config"
"github.com/jesseduffield/lazygit/pkg/gui/popup"
"github.com/jesseduffield/lazygit/pkg/gui/types"
)
@ -8,6 +11,54 @@ import (
type IGuiCommon interface {
popup.IPopupHandler
LogAction(string)
LogAction(action string)
LogCommand(cmdStr string, isCommandLine bool)
// we call this when we want to refetch some models and render the result. Internally calls PostRefreshUpdate
Refresh(types.RefreshOptions) error
// we call this when we've changed something in the view model but not the actual model,
// e.g. expanding or collapsing a folder in a file view. Calling 'Refresh' in this
// case would be overkill, although refresh will internally call 'PostRefreshUpdate'
PostRefreshUpdate(types.Context) error
RunSubprocessAndRefresh(oscommands.ICmdObj) error
PushContext(context types.Context, opts ...types.OnFocusOpts) error
PopContext() error
GetAppState() *config.AppState
SaveAppState() error
}
type IRefHelper interface {
CheckoutRef(ref string, options types.CheckoutRefOptions) error
CreateGitResetMenu(ref string) error
ResetToRef(ref string, strength string, envVars []string) error
}
type ISuggestionsHelper interface {
GetRemoteSuggestionsFunc() func(string) []*types.Suggestion
GetBranchNameSuggestionsFunc() func(string) []*types.Suggestion
GetFilePathSuggestionsFunc() func(string) []*types.Suggestion
GetRemoteBranchesSuggestionsFunc(separator string) func(string) []*types.Suggestion
GetRefsSuggestionsFunc() func(string) []*types.Suggestion
GetCustomCommandsHistorySuggestionsFunc() func(string) []*types.Suggestion
}
type IFileHelper interface {
EditFile(filename string) error
EditFileAtLine(filename string, lineNumber int) error
OpenFile(filename string) error
}
type IWorkingTreeHelper interface {
AnyStagedFiles() bool
AnyTrackedFiles() bool
IsWorkingTreeDirty() bool
FileForSubmodule(submodule *models.SubmoduleConfig) *models.File
}
// all fields mandatory (except `CanRebase` because it's boolean)
type SwitchToCommitFilesContextOpts struct {
RefName string
CanRebase bool
Context types.Context
WindowName string
}

View File

@ -0,0 +1,266 @@
package controllers
import (
"github.com/jesseduffield/lazygit/pkg/commands"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/commands/types/enums"
"github.com/jesseduffield/lazygit/pkg/config"
"github.com/jesseduffield/lazygit/pkg/gui/popup"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/utils"
)
// Quick summary of how this all works:
// when you want to undo or redo, we start from the top of the reflog and work
// down until we've reached the last user-initiated reflog entry that hasn't already been undone
// we then do the reverse of what that reflog describes.
// When we do this, we create a new reflog entry, and tag it as either an undo or redo
// Then, next time we want to undo, we'll use those entries to know which user-initiated
// actions we can skip. E.g. if I do do three things, A, B, and C, and hit undo twice,
// the reflog will read UUCBA, and when I read the first two undos, I know to skip the following
// two user actions, meaning we end up undoing reflog entry C. Redoing works in a similar way.
type UndoController struct {
c *ControllerCommon
git *commands.GitCommand
refHelper IRefHelper
workingTreeHelper IWorkingTreeHelper
getFilteredReflogCommits func() []*models.Commit
}
var _ types.IController = &UndoController{}
func NewUndoController(
c *ControllerCommon,
git *commands.GitCommand,
refHelper IRefHelper,
workingTreeHelper IWorkingTreeHelper,
getFilteredReflogCommits func() []*models.Commit,
) *UndoController {
return &UndoController{
c: c,
git: git,
refHelper: refHelper,
workingTreeHelper: workingTreeHelper,
getFilteredReflogCommits: getFilteredReflogCommits,
}
}
type ReflogActionKind int
const (
CHECKOUT ReflogActionKind = iota
COMMIT
REBASE
CURRENT_REBASE
)
type reflogAction struct {
kind ReflogActionKind
from string
to string
}
func (self *UndoController) Keybindings(
getKey func(key string) interface{},
config config.KeybindingConfig,
guards types.KeybindingGuards,
) []*types.Binding {
bindings := []*types.Binding{
{
Key: getKey(config.Universal.Undo),
Handler: self.reflogUndo,
Description: self.c.Tr.LcUndoReflog,
},
{
Key: getKey(config.Universal.Redo),
Handler: self.reflogRedo,
Description: self.c.Tr.LcRedoReflog,
},
}
return bindings
}
func (self *UndoController) Context() types.Context {
return nil
}
func (self *UndoController) reflogUndo() error {
undoEnvVars := []string{"GIT_REFLOG_ACTION=[lazygit undo]"}
undoingStatus := self.c.Tr.UndoingStatus
if self.git.Status.WorkingTreeState() == enums.REBASE_MODE_REBASING {
return self.c.ErrorMsg(self.c.Tr.LcCantUndoWhileRebasing)
}
return self.parseReflogForActions(func(counter int, action reflogAction) (bool, error) {
if counter != 0 {
return false, nil
}
switch action.kind {
case COMMIT, REBASE:
self.c.LogAction(self.c.Tr.Actions.Undo)
return true, self.hardResetWithAutoStash(action.from, hardResetOptions{
EnvVars: undoEnvVars,
WaitingStatus: undoingStatus,
})
case CHECKOUT:
self.c.LogAction(self.c.Tr.Actions.Undo)
return true, self.refHelper.CheckoutRef(action.from, types.CheckoutRefOptions{
EnvVars: undoEnvVars,
WaitingStatus: undoingStatus,
})
case CURRENT_REBASE:
// do nothing
}
self.c.Log.Error("didn't match on the user action when trying to undo")
return true, nil
})
}
func (self *UndoController) reflogRedo() error {
redoEnvVars := []string{"GIT_REFLOG_ACTION=[lazygit redo]"}
redoingStatus := self.c.Tr.RedoingStatus
if self.git.Status.WorkingTreeState() == enums.REBASE_MODE_REBASING {
return self.c.ErrorMsg(self.c.Tr.LcCantRedoWhileRebasing)
}
return self.parseReflogForActions(func(counter int, action reflogAction) (bool, error) {
// if we're redoing and the counter is zero, we just return
if counter == 0 {
return true, nil
} else if counter > 1 {
return false, nil
}
switch action.kind {
case COMMIT, REBASE:
self.c.LogAction(self.c.Tr.Actions.Redo)
return true, self.hardResetWithAutoStash(action.to, hardResetOptions{
EnvVars: redoEnvVars,
WaitingStatus: redoingStatus,
})
case CHECKOUT:
self.c.LogAction(self.c.Tr.Actions.Redo)
return true, self.refHelper.CheckoutRef(action.to, types.CheckoutRefOptions{
EnvVars: redoEnvVars,
WaitingStatus: redoingStatus,
})
case CURRENT_REBASE:
// do nothing
}
self.c.Log.Error("didn't match on the user action when trying to redo")
return true, nil
})
}
// Here we're going through the reflog and maintaining a counter that represents how many
// undos/redos/user actions we've seen. when we hit a user action we call the callback specifying
// what the counter is up to and the nature of the action.
// If we find ourselves mid-rebase, we just return because undo/redo mid rebase
// requires knowledge of previous TODO file states, which you can't just get from the reflog.
// Though we might support this later, hence the use of the CURRENT_REBASE action kind.
func (self *UndoController) parseReflogForActions(onUserAction func(counter int, action reflogAction) (bool, error)) error {
counter := 0
reflogCommits := self.getFilteredReflogCommits()
rebaseFinishCommitSha := ""
var action *reflogAction
for reflogCommitIdx, reflogCommit := range reflogCommits {
action = nil
prevCommitSha := ""
if len(reflogCommits)-1 >= reflogCommitIdx+1 {
prevCommitSha = reflogCommits[reflogCommitIdx+1].Sha
}
if rebaseFinishCommitSha == "" {
if ok, _ := utils.FindStringSubmatch(reflogCommit.Name, `^\[lazygit undo\]`); ok {
counter++
} else if ok, _ := utils.FindStringSubmatch(reflogCommit.Name, `^\[lazygit redo\]`); ok {
counter--
} else if ok, _ := utils.FindStringSubmatch(reflogCommit.Name, `^rebase -i \(abort\)|^rebase -i \(finish\)`); ok {
rebaseFinishCommitSha = reflogCommit.Sha
} else if ok, match := utils.FindStringSubmatch(reflogCommit.Name, `^checkout: moving from ([\S]+) to ([\S]+)`); ok {
action = &reflogAction{kind: CHECKOUT, from: match[1], to: match[2]}
} else if ok, _ := utils.FindStringSubmatch(reflogCommit.Name, `^commit|^reset: moving to|^pull`); ok {
action = &reflogAction{kind: COMMIT, from: prevCommitSha, to: reflogCommit.Sha}
} else if ok, _ := utils.FindStringSubmatch(reflogCommit.Name, `^rebase -i \(start\)`); ok {
// if we're here then we must be currently inside an interactive rebase
action = &reflogAction{kind: CURRENT_REBASE, from: prevCommitSha}
}
} else if ok, _ := utils.FindStringSubmatch(reflogCommit.Name, `^rebase -i \(start\)`); ok {
action = &reflogAction{kind: REBASE, from: prevCommitSha, to: rebaseFinishCommitSha}
rebaseFinishCommitSha = ""
}
if action != nil {
if action.kind != CURRENT_REBASE && action.from == action.to {
// if we're going from one place to the same place we'll ignore the action.
continue
}
ok, err := onUserAction(counter, *action)
if ok {
return err
}
counter--
}
}
return nil
}
type hardResetOptions struct {
WaitingStatus string
EnvVars []string
}
// only to be used in the undo flow for now (does an autostash)
func (self *UndoController) hardResetWithAutoStash(commitSha string, options hardResetOptions) error {
reset := func() error {
if err := self.refHelper.ResetToRef(commitSha, "hard", options.EnvVars); err != nil {
return self.c.Error(err)
}
return nil
}
// if we have any modified tracked files we need to ask the user if they want us to stash for them
dirtyWorkingTree := self.workingTreeHelper.IsWorkingTreeDirty()
if dirtyWorkingTree {
// offer to autostash changes
return self.c.Ask(popup.AskOpts{
Title: self.c.Tr.AutoStashTitle,
Prompt: self.c.Tr.AutoStashPrompt,
HandleConfirm: func() error {
return self.c.WithWaitingStatus(options.WaitingStatus, func() error {
if err := self.git.Stash.Save(self.c.Tr.StashPrefix + commitSha); err != nil {
return self.c.Error(err)
}
if err := reset(); err != nil {
return err
}
err := self.git.Stash.Pop(0)
if err := self.c.Refresh(types.RefreshOptions{}); err != nil {
return err
}
if err != nil {
return self.c.Error(err)
}
return nil
})
},
})
}
return self.c.WithWaitingStatus(options.WaitingStatus, func() error {
return reset()
})
}