mirror of
https://github.com/jesseduffield/lazygit.git
synced 2025-03-27 22:01:46 +02:00
Something dumb that we're currently doing is expecting list items to define an ID method which returns a string. We use that when copying items to clipboard with ctrl+o and when getting a ref name for diffing. This commit gets us a little deeper into that hole by explicitly requiring list items to implement that method so that we can easily use the new helper functions in list_controller_trait.go. In future we need to just remove the whole ID thing entirely but I'm too lazy to do that right now.
966 lines
27 KiB
Go
966 lines
27 KiB
Go
package controllers
|
|
|
|
import (
|
|
"strings"
|
|
|
|
"github.com/jesseduffield/gocui"
|
|
"github.com/jesseduffield/lazygit/pkg/commands/git_commands"
|
|
"github.com/jesseduffield/lazygit/pkg/commands/models"
|
|
"github.com/jesseduffield/lazygit/pkg/gui/context"
|
|
"github.com/jesseduffield/lazygit/pkg/gui/filetree"
|
|
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
|
)
|
|
|
|
type FilesController struct {
|
|
baseController // nolint: unused
|
|
*ListControllerTrait[*filetree.FileNode]
|
|
c *ControllerCommon
|
|
}
|
|
|
|
var _ types.IController = &FilesController{}
|
|
|
|
func NewFilesController(
|
|
c *ControllerCommon,
|
|
) *FilesController {
|
|
return &FilesController{
|
|
c: c,
|
|
ListControllerTrait: NewListControllerTrait[*filetree.FileNode](
|
|
c,
|
|
c.Contexts().Files,
|
|
c.Contexts().Files.GetSelected,
|
|
c.Contexts().Files.GetSelectedItems,
|
|
),
|
|
}
|
|
}
|
|
|
|
func (self *FilesController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
|
|
return []*types.Binding{
|
|
{
|
|
Key: opts.GetKey(opts.Config.Universal.Select),
|
|
Handler: self.withItem(self.press),
|
|
GetDisabledReason: self.require(self.singleItemSelected()),
|
|
Description: self.c.Tr.ToggleStaged,
|
|
},
|
|
{
|
|
Key: opts.GetKey(opts.Config.Files.OpenStatusFilter),
|
|
Handler: self.handleStatusFilterPressed,
|
|
Description: self.c.Tr.FileFilter,
|
|
},
|
|
{
|
|
Key: opts.GetKey(opts.Config.Files.CopyFileInfoToClipboard),
|
|
Handler: self.openCopyMenu,
|
|
Description: self.c.Tr.CopyToClipboardMenu,
|
|
OpensMenu: true,
|
|
},
|
|
{
|
|
Key: opts.GetKey(opts.Config.Files.CommitChanges),
|
|
Handler: self.c.Helpers().WorkingTree.HandleCommitPress,
|
|
Description: self.c.Tr.CommitChanges,
|
|
},
|
|
{
|
|
Key: opts.GetKey(opts.Config.Files.CommitChangesWithoutHook),
|
|
Handler: self.c.Helpers().WorkingTree.HandleWIPCommitPress,
|
|
Description: self.c.Tr.CommitChangesWithoutHook,
|
|
},
|
|
{
|
|
Key: opts.GetKey(opts.Config.Files.AmendLastCommit),
|
|
Handler: self.handleAmendCommitPress,
|
|
Description: self.c.Tr.AmendLastCommit,
|
|
},
|
|
{
|
|
Key: opts.GetKey(opts.Config.Files.CommitChangesWithEditor),
|
|
Handler: self.c.Helpers().WorkingTree.HandleCommitEditorPress,
|
|
Description: self.c.Tr.CommitChangesWithEditor,
|
|
},
|
|
{
|
|
Key: opts.GetKey(opts.Config.Files.FindBaseCommitForFixup),
|
|
Handler: self.c.Helpers().FixupHelper.HandleFindBaseCommitForFixupPress,
|
|
Description: self.c.Tr.FindBaseCommitForFixup,
|
|
Tooltip: self.c.Tr.FindBaseCommitForFixupTooltip,
|
|
},
|
|
{
|
|
Key: opts.GetKey(opts.Config.Universal.Edit),
|
|
Handler: self.withItem(self.edit),
|
|
GetDisabledReason: self.require(self.singleItemSelected()),
|
|
Description: self.c.Tr.EditFile,
|
|
},
|
|
{
|
|
Key: opts.GetKey(opts.Config.Universal.OpenFile),
|
|
Handler: self.Open,
|
|
GetDisabledReason: self.require(self.singleItemSelected()),
|
|
Description: self.c.Tr.OpenFile,
|
|
},
|
|
{
|
|
Key: opts.GetKey(opts.Config.Files.IgnoreFile),
|
|
Handler: self.withItem(self.ignoreOrExcludeMenu),
|
|
GetDisabledReason: self.require(self.singleItemSelected()),
|
|
Description: self.c.Tr.Actions.IgnoreExcludeFile,
|
|
OpensMenu: true,
|
|
},
|
|
{
|
|
Key: opts.GetKey(opts.Config.Files.RefreshFiles),
|
|
Handler: self.refresh,
|
|
Description: self.c.Tr.RefreshFiles,
|
|
},
|
|
{
|
|
Key: opts.GetKey(opts.Config.Files.StashAllChanges),
|
|
Handler: self.stash,
|
|
Description: self.c.Tr.StashAllChanges,
|
|
},
|
|
{
|
|
Key: opts.GetKey(opts.Config.Files.ViewStashOptions),
|
|
Handler: self.createStashMenu,
|
|
Description: self.c.Tr.ViewStashOptions,
|
|
OpensMenu: true,
|
|
},
|
|
{
|
|
Key: opts.GetKey(opts.Config.Files.ToggleStagedAll),
|
|
Handler: self.toggleStagedAll,
|
|
Description: self.c.Tr.ToggleStagedAll,
|
|
},
|
|
{
|
|
Key: opts.GetKey(opts.Config.Universal.GoInto),
|
|
Handler: self.enter,
|
|
GetDisabledReason: self.require(self.singleItemSelected()),
|
|
Description: self.c.Tr.FileEnter,
|
|
},
|
|
{
|
|
Key: opts.GetKey(opts.Config.Commits.ViewResetOptions),
|
|
Handler: self.createResetToUpstreamMenu,
|
|
Description: self.c.Tr.ViewResetToUpstreamOptions,
|
|
OpensMenu: true,
|
|
},
|
|
{
|
|
Key: opts.GetKey(opts.Config.Files.ViewResetOptions),
|
|
Handler: self.createResetMenu,
|
|
Description: self.c.Tr.ViewResetOptions,
|
|
OpensMenu: true,
|
|
},
|
|
{
|
|
Key: opts.GetKey(opts.Config.Files.ToggleTreeView),
|
|
Handler: self.toggleTreeView,
|
|
Description: self.c.Tr.ToggleTreeView,
|
|
},
|
|
{
|
|
Key: opts.GetKey(opts.Config.Universal.OpenDiffTool),
|
|
Handler: self.withItem(self.openDiffTool),
|
|
GetDisabledReason: self.require(self.singleItemSelected()),
|
|
Description: self.c.Tr.OpenDiffTool,
|
|
},
|
|
{
|
|
Key: opts.GetKey(opts.Config.Files.OpenMergeTool),
|
|
Handler: self.c.Helpers().WorkingTree.OpenMergeTool,
|
|
Description: self.c.Tr.OpenMergeTool,
|
|
},
|
|
{
|
|
Key: opts.GetKey(opts.Config.Files.Fetch),
|
|
Handler: self.fetch,
|
|
Description: self.c.Tr.Fetch,
|
|
},
|
|
}
|
|
}
|
|
|
|
func (self *FilesController) GetMouseKeybindings(opts types.KeybindingsOpts) []*gocui.ViewMouseBinding {
|
|
return []*gocui.ViewMouseBinding{
|
|
{
|
|
ViewName: "main",
|
|
Key: gocui.MouseLeft,
|
|
Handler: self.onClickMain,
|
|
FocusedView: self.context().GetViewName(),
|
|
},
|
|
{
|
|
ViewName: "patchBuilding",
|
|
Key: gocui.MouseLeft,
|
|
Handler: self.onClickMain,
|
|
FocusedView: self.context().GetViewName(),
|
|
},
|
|
{
|
|
ViewName: "mergeConflicts",
|
|
Key: gocui.MouseLeft,
|
|
Handler: self.onClickMain,
|
|
FocusedView: self.context().GetViewName(),
|
|
},
|
|
{
|
|
ViewName: "secondary",
|
|
Key: gocui.MouseLeft,
|
|
Handler: self.onClickSecondary,
|
|
FocusedView: self.context().GetViewName(),
|
|
},
|
|
{
|
|
ViewName: "patchBuildingSecondary",
|
|
Key: gocui.MouseLeft,
|
|
Handler: self.onClickSecondary,
|
|
FocusedView: self.context().GetViewName(),
|
|
},
|
|
}
|
|
}
|
|
|
|
func (self *FilesController) GetOnRenderToMain() func() error {
|
|
return func() error {
|
|
return self.c.Helpers().Diff.WithDiffModeCheck(func() error {
|
|
node := self.context().GetSelected()
|
|
|
|
if node == nil {
|
|
return self.c.RenderToMainViews(types.RefreshMainOpts{
|
|
Pair: self.c.MainViewPairs().Normal,
|
|
Main: &types.ViewUpdateOpts{
|
|
Title: self.c.Tr.DiffTitle,
|
|
SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(),
|
|
Task: types.NewRenderStringTask(self.c.Tr.NoChangedFiles),
|
|
},
|
|
})
|
|
}
|
|
|
|
if node.File != nil && node.File.HasInlineMergeConflicts {
|
|
hasConflicts, err := self.c.Helpers().MergeConflicts.SetMergeState(node.GetPath())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if hasConflicts {
|
|
return self.c.Helpers().MergeConflicts.Render()
|
|
}
|
|
}
|
|
|
|
self.c.Helpers().MergeConflicts.ResetMergeState()
|
|
|
|
pair := self.c.MainViewPairs().Normal
|
|
if node.File != nil {
|
|
pair = self.c.MainViewPairs().Staging
|
|
}
|
|
|
|
split := self.c.UserConfig.Gui.SplitDiff == "always" || (node.GetHasUnstagedChanges() && node.GetHasStagedChanges())
|
|
mainShowsStaged := !split && node.GetHasStagedChanges()
|
|
|
|
cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, false, mainShowsStaged)
|
|
title := self.c.Tr.UnstagedChanges
|
|
if mainShowsStaged {
|
|
title = self.c.Tr.StagedChanges
|
|
}
|
|
refreshOpts := types.RefreshMainOpts{
|
|
Pair: pair,
|
|
Main: &types.ViewUpdateOpts{
|
|
Task: types.NewRunPtyTask(cmdObj.GetCmd()),
|
|
SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(),
|
|
Title: title,
|
|
},
|
|
}
|
|
|
|
if split {
|
|
cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, false, true)
|
|
|
|
title := self.c.Tr.StagedChanges
|
|
if mainShowsStaged {
|
|
title = self.c.Tr.UnstagedChanges
|
|
}
|
|
|
|
refreshOpts.Secondary = &types.ViewUpdateOpts{
|
|
Title: title,
|
|
SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(),
|
|
Task: types.NewRunPtyTask(cmdObj.GetCmd()),
|
|
}
|
|
}
|
|
|
|
return self.c.RenderToMainViews(refreshOpts)
|
|
})
|
|
}
|
|
}
|
|
|
|
func (self *FilesController) GetOnClick() func() error {
|
|
return self.withItemGraceful(self.press)
|
|
}
|
|
|
|
// if we are dealing with a status for which there is no key in this map,
|
|
// then we won't optimistically render: we'll just let `git status` tell
|
|
// us what the new status is.
|
|
// There are no doubt more entries that could be added to these two maps.
|
|
var stageStatusMap = map[string]string{
|
|
"??": "A ",
|
|
" M": "M ",
|
|
"MM": "M ",
|
|
" D": "D ",
|
|
" A": "A ",
|
|
"AM": "A ",
|
|
"MD": "D ",
|
|
}
|
|
|
|
var unstageStatusMap = map[string]string{
|
|
"A ": "??",
|
|
"M ": " M",
|
|
"D ": " D",
|
|
}
|
|
|
|
func (self *FilesController) optimisticStage(file *models.File) bool {
|
|
newShortStatus, ok := stageStatusMap[file.ShortStatus]
|
|
if !ok {
|
|
return false
|
|
}
|
|
|
|
models.SetStatusFields(file, newShortStatus)
|
|
return true
|
|
}
|
|
|
|
func (self *FilesController) optimisticUnstage(file *models.File) bool {
|
|
newShortStatus, ok := unstageStatusMap[file.ShortStatus]
|
|
if !ok {
|
|
return false
|
|
}
|
|
|
|
models.SetStatusFields(file, newShortStatus)
|
|
return true
|
|
}
|
|
|
|
// Running a git add command followed by a git status command can take some time (e.g. 200ms).
|
|
// Given how often users stage/unstage files in Lazygit, we're adding some
|
|
// optimistic rendering to make things feel faster. When we go to stage
|
|
// a file, we'll first update that file's status in-memory, then re-render
|
|
// the files panel. Then we'll immediately do a proper git status call
|
|
// so that if the optimistic rendering got something wrong, it's quickly
|
|
// corrected.
|
|
func (self *FilesController) optimisticChange(node *filetree.FileNode, optimisticChangeFn func(*models.File) bool) error {
|
|
rerender := false
|
|
err := node.ForEachFile(func(f *models.File) error {
|
|
// can't act on the file itself: we need to update the original model file
|
|
for _, modelFile := range self.c.Model().Files {
|
|
if modelFile.Name == f.Name {
|
|
if optimisticChangeFn(modelFile) {
|
|
rerender = true
|
|
}
|
|
break
|
|
}
|
|
}
|
|
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if rerender {
|
|
if err := self.c.PostRefreshUpdate(self.c.Contexts().Files); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (self *FilesController) pressWithLock(node *filetree.FileNode) error {
|
|
// Obtaining this lock because optimistic rendering requires us to mutate
|
|
// the files in our model.
|
|
self.c.Mutexes().RefreshingFilesMutex.Lock()
|
|
defer self.c.Mutexes().RefreshingFilesMutex.Unlock()
|
|
|
|
if node.IsFile() {
|
|
file := node.File
|
|
|
|
if file.HasUnstagedChanges {
|
|
self.c.LogAction(self.c.Tr.Actions.StageFile)
|
|
|
|
if err := self.optimisticChange(node, self.optimisticStage); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := self.c.Git().WorkingTree.StageFile(file.Name); err != nil {
|
|
return self.c.Error(err)
|
|
}
|
|
} else {
|
|
self.c.LogAction(self.c.Tr.Actions.UnstageFile)
|
|
|
|
if err := self.optimisticChange(node, self.optimisticUnstage); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := self.c.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.optimisticChange(node, self.optimisticStage); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := self.c.Git().WorkingTree.StageFile(node.Path); err != nil {
|
|
return self.c.Error(err)
|
|
}
|
|
} else {
|
|
self.c.LogAction(self.c.Tr.Actions.UnstageFile)
|
|
|
|
if err := self.optimisticChange(node, self.optimisticUnstage); err != nil {
|
|
return err
|
|
}
|
|
|
|
// pretty sure it doesn't matter that we're always passing true here
|
|
if err := self.c.Git().WorkingTree.UnStageFile([]string{node.Path}, true); err != nil {
|
|
return self.c.Error(err)
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (self *FilesController) press(node *filetree.FileNode) error {
|
|
if node.IsFile() && node.File.HasInlineMergeConflicts {
|
|
return self.switchToMerge()
|
|
}
|
|
|
|
if err := self.pressWithLock(node); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}, Mode: types.ASYNC}); err != nil {
|
|
return err
|
|
}
|
|
|
|
return self.context().HandleFocus(types.OnFocusOpts{})
|
|
}
|
|
|
|
func (self *FilesController) Context() types.Context {
|
|
return self.context()
|
|
}
|
|
|
|
func (self *FilesController) context() *context.WorkingTreeContext {
|
|
return self.c.Contexts().Files
|
|
}
|
|
|
|
func (self *FilesController) getSelectedFile() *models.File {
|
|
node := self.context().GetSelected()
|
|
if node == nil {
|
|
return nil
|
|
}
|
|
return node.File
|
|
}
|
|
|
|
func (self *FilesController) enter() error {
|
|
return self.EnterFile(types.OnFocusOpts{ClickedWindowName: "", ClickedViewLineIdx: -1})
|
|
}
|
|
|
|
func (self *FilesController) EnterFile(opts types.OnFocusOpts) error {
|
|
node := self.context().GetSelected()
|
|
if node == nil {
|
|
return nil
|
|
}
|
|
|
|
if node.File == nil {
|
|
return self.handleToggleDirCollapsed()
|
|
}
|
|
|
|
file := node.File
|
|
|
|
submoduleConfigs := self.c.Model().Submodules
|
|
if file.IsSubmodule(submoduleConfigs) {
|
|
submoduleConfig := file.SubmoduleConfig(submoduleConfigs)
|
|
return self.c.Helpers().Repos.EnterSubmodule(submoduleConfig)
|
|
}
|
|
|
|
if file.HasInlineMergeConflicts {
|
|
return self.switchToMerge()
|
|
}
|
|
if file.HasMergeConflicts {
|
|
return self.c.ErrorMsg(self.c.Tr.FileStagingRequirements)
|
|
}
|
|
|
|
return self.c.PushContext(self.c.Contexts().Staging, opts)
|
|
}
|
|
|
|
func (self *FilesController) toggleStagedAll() error {
|
|
if err := self.toggleStagedAllWithLock(); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}, Mode: types.ASYNC}); err != nil {
|
|
return err
|
|
}
|
|
|
|
return self.context().HandleFocus(types.OnFocusOpts{})
|
|
}
|
|
|
|
func (self *FilesController) toggleStagedAllWithLock() error {
|
|
self.c.Mutexes().RefreshingFilesMutex.Lock()
|
|
defer self.c.Mutexes().RefreshingFilesMutex.Unlock()
|
|
|
|
root := self.context().FileTreeViewModel.GetRoot()
|
|
|
|
// 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 root.GetHasInlineMergeConflicts() {
|
|
return self.c.ErrorMsg(self.c.Tr.ErrStageDirWithInlineMergeConflicts)
|
|
}
|
|
|
|
if root.GetHasUnstagedChanges() {
|
|
self.c.LogAction(self.c.Tr.Actions.StageAllFiles)
|
|
|
|
if err := self.optimisticChange(root, self.optimisticStage); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := self.c.Git().WorkingTree.StageAll(); err != nil {
|
|
return self.c.Error(err)
|
|
}
|
|
} else {
|
|
self.c.LogAction(self.c.Tr.Actions.UnstageAllFiles)
|
|
|
|
if err := self.optimisticChange(root, self.optimisticUnstage); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := self.c.Git().WorkingTree.UnstageAll(); err != nil {
|
|
return self.c.Error(err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (self *FilesController) unstageFiles(node *filetree.FileNode) error {
|
|
return node.ForEachFile(func(file *models.File) error {
|
|
if file.HasStagedChanges {
|
|
if err := self.c.Git().WorkingTree.UnStageFile(file.Names(), file.Tracked); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func (self *FilesController) ignoreOrExcludeTracked(node *filetree.FileNode, trAction string, f func(string) error) error {
|
|
self.c.LogAction(trAction)
|
|
// not 100% sure if this is necessary but I'll assume it is
|
|
if err := self.unstageFiles(node); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := self.c.Git().WorkingTree.RemoveTrackedFiles(node.GetPath()); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := f(node.GetPath()); err != nil {
|
|
return err
|
|
}
|
|
|
|
return self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}})
|
|
}
|
|
|
|
func (self *FilesController) ignoreOrExcludeUntracked(node *filetree.FileNode, trAction string, f func(string) error) error {
|
|
self.c.LogAction(trAction)
|
|
|
|
if err := f(node.GetPath()); err != nil {
|
|
return err
|
|
}
|
|
|
|
return self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}})
|
|
}
|
|
|
|
func (self *FilesController) ignoreOrExcludeFile(node *filetree.FileNode, trText string, trPrompt string, trAction string, f func(string) error) error {
|
|
if node.GetIsTracked() {
|
|
return self.c.Confirm(types.ConfirmOpts{
|
|
Title: trText,
|
|
Prompt: trPrompt,
|
|
HandleConfirm: func() error {
|
|
return self.ignoreOrExcludeTracked(node, trAction, f)
|
|
},
|
|
})
|
|
}
|
|
return self.ignoreOrExcludeUntracked(node, trAction, f)
|
|
}
|
|
|
|
func (self *FilesController) ignore(node *filetree.FileNode) error {
|
|
if node.GetPath() == ".gitignore" {
|
|
return self.c.ErrorMsg(self.c.Tr.Actions.IgnoreFileErr)
|
|
}
|
|
err := self.ignoreOrExcludeFile(node, self.c.Tr.IgnoreTracked, self.c.Tr.IgnoreTrackedPrompt, self.c.Tr.Actions.IgnoreExcludeFile, self.c.Git().WorkingTree.Ignore)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (self *FilesController) exclude(node *filetree.FileNode) error {
|
|
if node.GetPath() == ".git/info/exclude" {
|
|
return self.c.ErrorMsg(self.c.Tr.Actions.ExcludeFileErr)
|
|
}
|
|
|
|
if node.GetPath() == ".gitignore" {
|
|
return self.c.ErrorMsg(self.c.Tr.Actions.ExcludeGitIgnoreErr)
|
|
}
|
|
|
|
err := self.ignoreOrExcludeFile(node, self.c.Tr.ExcludeTracked, self.c.Tr.ExcludeTrackedPrompt, self.c.Tr.Actions.ExcludeFile, self.c.Git().WorkingTree.Exclude)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (self *FilesController) ignoreOrExcludeMenu(node *filetree.FileNode) error {
|
|
return self.c.Menu(types.CreateMenuOptions{
|
|
Title: self.c.Tr.Actions.IgnoreExcludeFile,
|
|
Items: []*types.MenuItem{
|
|
{
|
|
LabelColumns: []string{self.c.Tr.IgnoreFile},
|
|
OnPress: func() error {
|
|
if err := self.ignore(node); err != nil {
|
|
return self.c.Error(err)
|
|
}
|
|
return nil
|
|
},
|
|
Key: 'i',
|
|
},
|
|
{
|
|
LabelColumns: []string{self.c.Tr.ExcludeFile},
|
|
OnPress: func() error {
|
|
if err := self.exclude(node); err != nil {
|
|
return self.c.Error(err)
|
|
}
|
|
return nil
|
|
},
|
|
Key: 'e',
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
func (self *FilesController) refresh() error {
|
|
return self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}})
|
|
}
|
|
|
|
func (self *FilesController) handleAmendCommitPress() error {
|
|
return self.c.Confirm(types.ConfirmOpts{
|
|
Title: self.c.Tr.AmendLastCommitTitle,
|
|
Prompt: self.c.Tr.SureToAmend,
|
|
HandleConfirm: func() error {
|
|
return self.c.Helpers().WorkingTree.WithEnsureCommitableFiles(func() error {
|
|
if len(self.c.Model().Commits) == 0 {
|
|
return self.c.ErrorMsg(self.c.Tr.NoCommitToAmend)
|
|
}
|
|
|
|
return self.c.Helpers().AmendHelper.AmendHead()
|
|
})
|
|
},
|
|
})
|
|
}
|
|
|
|
func (self *FilesController) handleStatusFilterPressed() error {
|
|
return self.c.Menu(types.CreateMenuOptions{
|
|
Title: self.c.Tr.FilteringMenuTitle,
|
|
Items: []*types.MenuItem{
|
|
{
|
|
Label: self.c.Tr.FilterStagedFiles,
|
|
OnPress: func() error {
|
|
return self.setStatusFiltering(filetree.DisplayStaged)
|
|
},
|
|
},
|
|
{
|
|
Label: self.c.Tr.FilterUnstagedFiles,
|
|
OnPress: func() error {
|
|
return self.setStatusFiltering(filetree.DisplayUnstaged)
|
|
},
|
|
},
|
|
{
|
|
Label: self.c.Tr.ResetFilter,
|
|
OnPress: func() error {
|
|
return self.setStatusFiltering(filetree.DisplayAll)
|
|
},
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
func (self *FilesController) setStatusFiltering(filter filetree.FileTreeDisplayFilter) error {
|
|
self.context().FileTreeViewModel.SetStatusFilter(filter)
|
|
return self.c.PostRefreshUpdate(self.context())
|
|
}
|
|
|
|
func (self *FilesController) edit(node *filetree.FileNode) error {
|
|
if node.File == nil {
|
|
return self.c.ErrorMsg(self.c.Tr.ErrCannotEditDirectory)
|
|
}
|
|
|
|
return self.c.Helpers().Files.EditFile(node.GetPath())
|
|
}
|
|
|
|
func (self *FilesController) Open() error {
|
|
node := self.context().GetSelected()
|
|
if node == nil {
|
|
return nil
|
|
}
|
|
|
|
return self.c.Helpers().Files.OpenFile(node.GetPath())
|
|
}
|
|
|
|
func (self *FilesController) openDiffTool(node *filetree.FileNode) error {
|
|
fromCommit := ""
|
|
reverse := false
|
|
if self.c.Modes().Diffing.Active() {
|
|
fromCommit = self.c.Modes().Diffing.Ref
|
|
reverse = self.c.Modes().Diffing.Reverse
|
|
}
|
|
return self.c.RunSubprocessAndRefresh(
|
|
self.c.Git().Diff.OpenDiffToolCmdObj(
|
|
git_commands.DiffToolCmdOptions{
|
|
Filepath: node.Path,
|
|
FromCommit: fromCommit,
|
|
ToCommit: "",
|
|
Reverse: reverse,
|
|
IsDirectory: !node.IsFile(),
|
|
Staged: !node.GetHasUnstagedChanges(),
|
|
}),
|
|
)
|
|
}
|
|
|
|
func (self *FilesController) switchToMerge() error {
|
|
file := self.getSelectedFile()
|
|
if file == nil {
|
|
return nil
|
|
}
|
|
|
|
return self.c.Helpers().MergeConflicts.SwitchToMerge(file.Name)
|
|
}
|
|
|
|
func (self *FilesController) createStashMenu() error {
|
|
return self.c.Menu(types.CreateMenuOptions{
|
|
Title: self.c.Tr.StashOptions,
|
|
Items: []*types.MenuItem{
|
|
{
|
|
Label: self.c.Tr.StashAllChanges,
|
|
OnPress: func() error {
|
|
if !self.c.Helpers().WorkingTree.IsWorkingTreeDirty() {
|
|
return self.c.ErrorMsg(self.c.Tr.NoFilesToStash)
|
|
}
|
|
return self.handleStashSave(self.c.Git().Stash.Push, self.c.Tr.Actions.StashAllChanges)
|
|
},
|
|
Key: 'a',
|
|
},
|
|
{
|
|
Label: self.c.Tr.StashAllChangesKeepIndex,
|
|
OnPress: func() error {
|
|
if !self.c.Helpers().WorkingTree.IsWorkingTreeDirty() {
|
|
return self.c.ErrorMsg(self.c.Tr.NoFilesToStash)
|
|
}
|
|
// if there are no staged files it behaves the same as Stash.Save
|
|
return self.handleStashSave(self.c.Git().Stash.StashAndKeepIndex, self.c.Tr.Actions.StashAllChangesKeepIndex)
|
|
},
|
|
Key: 'i',
|
|
},
|
|
{
|
|
Label: self.c.Tr.StashIncludeUntrackedChanges,
|
|
OnPress: func() error {
|
|
return self.handleStashSave(self.c.Git().Stash.StashIncludeUntrackedChanges, self.c.Tr.Actions.StashIncludeUntrackedChanges)
|
|
},
|
|
Key: 'U',
|
|
},
|
|
{
|
|
Label: self.c.Tr.StashStagedChanges,
|
|
OnPress: func() error {
|
|
// there must be something in staging otherwise the current implementation mucks the stash up
|
|
if !self.c.Helpers().WorkingTree.AnyStagedFiles() {
|
|
return self.c.ErrorMsg(self.c.Tr.NoTrackedStagedFilesStash)
|
|
}
|
|
return self.handleStashSave(self.c.Git().Stash.SaveStagedChanges, self.c.Tr.Actions.StashStagedChanges)
|
|
},
|
|
Key: 's',
|
|
},
|
|
{
|
|
Label: self.c.Tr.StashUnstagedChanges,
|
|
OnPress: func() error {
|
|
if !self.c.Helpers().WorkingTree.IsWorkingTreeDirty() {
|
|
return self.c.ErrorMsg(self.c.Tr.NoFilesToStash)
|
|
}
|
|
if self.c.Helpers().WorkingTree.AnyStagedFiles() {
|
|
return self.handleStashSave(self.c.Git().Stash.StashUnstagedChanges, self.c.Tr.Actions.StashUnstagedChanges)
|
|
}
|
|
// ordinary stash
|
|
return self.handleStashSave(self.c.Git().Stash.Push, self.c.Tr.Actions.StashUnstagedChanges)
|
|
},
|
|
Key: 'u',
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
func (self *FilesController) openCopyMenu() error {
|
|
node := self.context().GetSelected()
|
|
|
|
copyNameItem := &types.MenuItem{
|
|
Label: self.c.Tr.CopyFileName,
|
|
OnPress: func() error {
|
|
if err := self.c.OS().CopyToClipboard(node.Name()); err != nil {
|
|
return self.c.Error(err)
|
|
}
|
|
self.c.Toast(self.c.Tr.FileNameCopiedToast)
|
|
return nil
|
|
},
|
|
DisabledReason: self.require(self.singleItemSelected())(),
|
|
Key: 'n',
|
|
}
|
|
copyPathItem := &types.MenuItem{
|
|
Label: self.c.Tr.CopyFilePath,
|
|
OnPress: func() error {
|
|
if err := self.c.OS().CopyToClipboard(node.Path); err != nil {
|
|
return self.c.Error(err)
|
|
}
|
|
self.c.Toast(self.c.Tr.FilePathCopiedToast)
|
|
return nil
|
|
},
|
|
DisabledReason: self.require(self.singleItemSelected())(),
|
|
Key: 'p',
|
|
}
|
|
copyFileDiffItem := &types.MenuItem{
|
|
Label: self.c.Tr.CopySelectedDiff,
|
|
Tooltip: self.c.Tr.CopyFileDiffTooltip,
|
|
OnPress: func() error {
|
|
path := self.context().GetSelectedPath()
|
|
hasStaged := self.hasPathStagedChanges(node)
|
|
diff, err := self.c.Git().Diff.GetPathDiff(path, hasStaged)
|
|
if err != nil {
|
|
return self.c.Error(err)
|
|
}
|
|
if err := self.c.OS().CopyToClipboard(diff); err != nil {
|
|
return self.c.Error(err)
|
|
}
|
|
self.c.Toast(self.c.Tr.FileDiffCopiedToast)
|
|
return nil
|
|
},
|
|
DisabledReason: self.require(self.singleItemSelected(
|
|
func(file *filetree.FileNode) *types.DisabledReason {
|
|
if !node.GetHasStagedOrTrackedChanges() {
|
|
return &types.DisabledReason{Text: self.c.Tr.NoContentToCopyError}
|
|
}
|
|
return nil
|
|
},
|
|
))(),
|
|
Key: 's',
|
|
}
|
|
copyAllDiff := &types.MenuItem{
|
|
Label: self.c.Tr.CopyAllFilesDiff,
|
|
Tooltip: self.c.Tr.CopyFileDiffTooltip,
|
|
OnPress: func() error {
|
|
hasStaged := self.c.Helpers().WorkingTree.AnyStagedFiles()
|
|
diff, err := self.c.Git().Diff.GetAllDiff(hasStaged)
|
|
if err != nil {
|
|
return self.c.Error(err)
|
|
}
|
|
if err := self.c.OS().CopyToClipboard(diff); err != nil {
|
|
return self.c.Error(err)
|
|
}
|
|
self.c.Toast(self.c.Tr.AllFilesDiffCopiedToast)
|
|
return nil
|
|
},
|
|
DisabledReason: self.require(
|
|
func() *types.DisabledReason {
|
|
if !self.anyStagedOrTrackedFile() {
|
|
return &types.DisabledReason{Text: self.c.Tr.NoContentToCopyError}
|
|
}
|
|
return nil
|
|
},
|
|
)(),
|
|
Key: 'a',
|
|
}
|
|
|
|
return self.c.Menu(types.CreateMenuOptions{
|
|
Title: self.c.Tr.CopyToClipboardMenu,
|
|
Items: []*types.MenuItem{
|
|
copyNameItem,
|
|
copyPathItem,
|
|
copyFileDiffItem,
|
|
copyAllDiff,
|
|
},
|
|
})
|
|
}
|
|
|
|
func (self *FilesController) anyStagedOrTrackedFile() bool {
|
|
if !self.c.Helpers().WorkingTree.AnyStagedFiles() {
|
|
return self.c.Helpers().WorkingTree.AnyTrackedFiles()
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (self *FilesController) hasPathStagedChanges(node *filetree.FileNode) bool {
|
|
return node.SomeFile(func(t *models.File) bool {
|
|
return t.HasStagedChanges
|
|
})
|
|
}
|
|
|
|
func (self *FilesController) stash() error {
|
|
return self.handleStashSave(self.c.Git().Stash.Push, self.c.Tr.Actions.StashAllChanges)
|
|
}
|
|
|
|
func (self *FilesController) createResetToUpstreamMenu() error {
|
|
return self.c.Helpers().Refs.CreateGitResetMenu("@{upstream}")
|
|
}
|
|
|
|
func (self *FilesController) handleToggleDirCollapsed() error {
|
|
node := self.context().GetSelected()
|
|
if node == nil {
|
|
return nil
|
|
}
|
|
|
|
self.context().FileTreeViewModel.ToggleCollapsed(node.GetPath())
|
|
|
|
if err := self.c.PostRefreshUpdate(self.c.Contexts().Files); err != nil {
|
|
self.c.Log.Error(err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (self *FilesController) toggleTreeView() error {
|
|
self.context().FileTreeViewModel.ToggleShowTree()
|
|
|
|
return self.c.PostRefreshUpdate(self.context())
|
|
}
|
|
|
|
func (self *FilesController) handleStashSave(stashFunc func(message string) error, action string) error {
|
|
return self.c.Prompt(types.PromptOpts{
|
|
Title: self.c.Tr.StashChanges,
|
|
HandleConfirm: func(stashComment string) error {
|
|
self.c.LogAction(action)
|
|
|
|
if err := stashFunc(stashComment); err != nil {
|
|
return self.c.Error(err)
|
|
}
|
|
return self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.STASH, types.FILES}})
|
|
},
|
|
})
|
|
}
|
|
|
|
func (self *FilesController) onClickMain(opts gocui.ViewMouseBindingOpts) error {
|
|
return self.EnterFile(types.OnFocusOpts{ClickedWindowName: "main", ClickedViewLineIdx: opts.Y})
|
|
}
|
|
|
|
func (self *FilesController) onClickSecondary(opts gocui.ViewMouseBindingOpts) error {
|
|
return self.EnterFile(types.OnFocusOpts{ClickedWindowName: "secondary", ClickedViewLineIdx: opts.Y})
|
|
}
|
|
|
|
func (self *FilesController) fetch() error {
|
|
return self.c.WithWaitingStatus(self.c.Tr.FetchingStatus, func(task gocui.Task) error {
|
|
if err := self.fetchAux(task); err != nil {
|
|
_ = self.c.Error(err)
|
|
}
|
|
return self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC})
|
|
})
|
|
}
|
|
|
|
func (self *FilesController) fetchAux(task gocui.Task) (err error) {
|
|
self.c.LogAction("Fetch")
|
|
err = self.c.Git().Sync.Fetch(task)
|
|
|
|
if err != nil && strings.Contains(err.Error(), "exit status 128") {
|
|
_ = self.c.ErrorMsg(self.c.Tr.PassUnameWrong)
|
|
}
|
|
|
|
_ = self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.BRANCHES, types.COMMITS, types.REMOTES, types.TAGS}, Mode: types.ASYNC})
|
|
|
|
return err
|
|
}
|