mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-08-24 02:24:25 -05:00
A commit that moves an entire package elsewhere renames hundreds of files, and passing every one of their old paths can push the command past the length limit the OS imposes (~32k characters on Windows). Their common parent directory does just as well whenever everything it holds ends up in the diff anyway. Deciding that needs to consider every file of the diff, not only those on display, so the paths are now derived from the model rather than from the tree; a status filter must not make a directory look emptier than it is.
1841 lines
57 KiB
Go
1841 lines
57 KiB
Go
package controllers
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/jesseduffield/generics/set"
|
|
"github.com/jesseduffield/lazygit/pkg/commands/git_commands"
|
|
"github.com/jesseduffield/lazygit/pkg/commands/models"
|
|
"github.com/jesseduffield/lazygit/pkg/gocui"
|
|
"github.com/jesseduffield/lazygit/pkg/gui/context"
|
|
"github.com/jesseduffield/lazygit/pkg/gui/filetree"
|
|
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
|
"github.com/jesseduffield/lazygit/pkg/utils"
|
|
"github.com/samber/lo"
|
|
)
|
|
|
|
type FilesController struct {
|
|
baseController
|
|
*ListControllerTrait[*filetree.FileNode]
|
|
c *ControllerCommon
|
|
}
|
|
|
|
var _ types.IController = &FilesController{}
|
|
|
|
func NewFilesController(
|
|
c *ControllerCommon,
|
|
) *FilesController {
|
|
return &FilesController{
|
|
c: c,
|
|
ListControllerTrait: NewListControllerTrait(
|
|
c,
|
|
c.Contexts().Files,
|
|
c.Contexts().Files.GetSelected,
|
|
c.Contexts().Files.GetSelectedItems,
|
|
),
|
|
}
|
|
}
|
|
|
|
func (self *FilesController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
|
|
return []*types.Binding{
|
|
{
|
|
Keys: opts.GetKeys(opts.Config.Universal.Select),
|
|
Handler: self.withItems(self.press),
|
|
GetDisabledReason: self.require(self.itemsSelected(self.canStageSelection)),
|
|
Description: self.c.Tr.Stage,
|
|
Tooltip: self.c.Tr.StageTooltip,
|
|
DisplayOnScreen: true,
|
|
},
|
|
{
|
|
Keys: opts.GetKeys(opts.Config.Files.OpenStatusFilter),
|
|
Handler: self.handleStatusFilterPressed,
|
|
Description: self.c.Tr.FileFilter,
|
|
},
|
|
{
|
|
Keys: opts.GetKeys(opts.Config.Files.CopyFileInfoToClipboard),
|
|
Handler: self.openCopyMenu,
|
|
Description: self.c.Tr.CopyToClipboardMenu,
|
|
OpensMenu: true,
|
|
},
|
|
{
|
|
Keys: opts.GetKeys(opts.Config.Files.CommitChanges),
|
|
Handler: self.c.Helpers().WorkingTree.HandleCommitPress,
|
|
Description: self.c.Tr.Commit,
|
|
Tooltip: self.c.Tr.CommitTooltip,
|
|
DisplayOnScreen: true,
|
|
},
|
|
{
|
|
Keys: opts.GetKeys(opts.Config.Files.CommitChangesWithoutHook),
|
|
Handler: self.c.Helpers().WorkingTree.HandleWIPCommitPress,
|
|
Description: self.c.Tr.CommitChangesWithoutHook,
|
|
},
|
|
{
|
|
Keys: opts.GetKeys(opts.Config.Files.AmendLastCommit),
|
|
Handler: self.handleAmendCommitPress,
|
|
Description: self.c.Tr.AmendLastCommit,
|
|
},
|
|
{
|
|
Keys: opts.GetKeys(opts.Config.Files.CommitChangesWithEditor),
|
|
Handler: self.c.Helpers().WorkingTree.HandleCommitEditorPress,
|
|
Description: self.c.Tr.CommitChangesWithEditor,
|
|
},
|
|
{
|
|
Keys: opts.GetKeys(opts.Config.Files.FindBaseCommitForFixup),
|
|
Handler: self.c.Helpers().FixupHelper.HandleFindBaseCommitForFixupPress,
|
|
Description: self.c.Tr.FindBaseCommitForFixup,
|
|
Tooltip: self.c.Tr.FindBaseCommitForFixupTooltip,
|
|
},
|
|
{
|
|
Keys: opts.GetKeys(opts.Config.Universal.Edit),
|
|
Handler: self.withItems(self.edit),
|
|
GetDisabledReason: self.require(self.itemsSelected(self.canEditFiles)),
|
|
Description: self.c.Tr.Edit,
|
|
Tooltip: self.c.Tr.EditFileTooltip,
|
|
DisplayOnScreen: true,
|
|
},
|
|
{
|
|
Keys: opts.GetKeys(opts.Config.Universal.OpenFile),
|
|
Handler: self.Open,
|
|
GetDisabledReason: self.require(self.singleItemSelected()),
|
|
Description: self.c.Tr.OpenFile,
|
|
Tooltip: self.c.Tr.OpenFileTooltip,
|
|
},
|
|
{
|
|
Keys: opts.GetKeys(opts.Config.Files.IgnoreFile),
|
|
Handler: self.withItem(self.ignoreOrExcludeMenu),
|
|
GetDisabledReason: self.require(self.singleItemSelected()),
|
|
Description: self.c.Tr.Actions.IgnoreExcludeFile,
|
|
OpensMenu: true,
|
|
},
|
|
{
|
|
Keys: opts.GetKeys(opts.Config.Files.RefreshFiles),
|
|
Handler: self.refresh,
|
|
Description: self.c.Tr.RefreshFiles,
|
|
},
|
|
{
|
|
Keys: opts.GetKeys(opts.Config.Files.StashAllChanges),
|
|
Handler: self.stash,
|
|
Description: self.c.Tr.Stash,
|
|
Tooltip: self.c.Tr.StashTooltip,
|
|
DisplayOnScreen: true,
|
|
},
|
|
{
|
|
Keys: opts.GetKeys(opts.Config.Files.ViewStashOptions),
|
|
Handler: self.createStashMenu,
|
|
Description: self.c.Tr.ViewStashOptions,
|
|
Tooltip: self.c.Tr.ViewStashOptionsTooltip,
|
|
OpensMenu: true,
|
|
},
|
|
{
|
|
Keys: opts.GetKeys(opts.Config.Files.ToggleStagedAll),
|
|
Handler: self.toggleStagedAll,
|
|
GetDisabledReason: self.require(self.anyFilesDisplayed),
|
|
Description: self.c.Tr.ToggleStagedAll,
|
|
Tooltip: self.c.Tr.ToggleStagedAllTooltip,
|
|
},
|
|
{
|
|
Keys: opts.GetKeys(opts.Config.Universal.GoInto),
|
|
Handler: self.enter,
|
|
GetDisabledReason: self.require(self.singleItemSelected()),
|
|
Description: self.c.Tr.FileEnter,
|
|
Tooltip: self.c.Tr.FileEnterTooltip,
|
|
},
|
|
{
|
|
Keys: opts.GetKeys(opts.Config.Universal.Remove),
|
|
Handler: self.withItems(self.remove),
|
|
GetDisabledReason: self.require(self.itemsSelected(self.canRemove)),
|
|
Description: self.c.Tr.Discard,
|
|
Tooltip: self.c.Tr.DiscardFileChangesTooltip,
|
|
OpensMenu: true,
|
|
DisplayOnScreen: true,
|
|
},
|
|
{
|
|
Keys: opts.GetKeys(opts.Config.Commits.ViewResetOptions),
|
|
Handler: self.createResetToUpstreamMenu,
|
|
Description: self.c.Tr.ViewResetToUpstreamOptions,
|
|
OpensMenu: true,
|
|
},
|
|
{
|
|
Keys: opts.GetKeys(opts.Config.Files.ViewResetOptions),
|
|
Handler: self.createResetMenu,
|
|
Description: self.c.Tr.Reset,
|
|
Tooltip: self.c.Tr.FileResetOptionsTooltip,
|
|
OpensMenu: true,
|
|
DisplayOnScreen: true,
|
|
},
|
|
{
|
|
Keys: opts.GetKeys(opts.Config.Files.ToggleTreeView),
|
|
Handler: self.toggleTreeView,
|
|
Description: self.c.Tr.ToggleTreeView,
|
|
Tooltip: self.c.Tr.ToggleTreeViewTooltip,
|
|
},
|
|
{
|
|
Keys: opts.GetKeys(opts.Config.Universal.OpenDiffTool),
|
|
Handler: self.withItem(self.openDiffTool),
|
|
GetDisabledReason: self.require(self.singleItemSelected()),
|
|
Description: self.c.Tr.OpenDiffTool,
|
|
},
|
|
{
|
|
Keys: opts.GetKeys(opts.Config.Files.OpenMergeOptions),
|
|
Handler: self.withItems(self.openMergeConflictMenu),
|
|
Description: self.c.Tr.ViewMergeConflictOptions,
|
|
Tooltip: self.c.Tr.ViewMergeConflictOptionsTooltip,
|
|
GetDisabledReason: self.require(self.itemsSelected(self.canOpenMergeConflictMenu)),
|
|
OpensMenu: true,
|
|
DisplayOnScreen: true,
|
|
},
|
|
{
|
|
Keys: opts.GetKeys(opts.Config.Files.Fetch),
|
|
Handler: self.fetch,
|
|
Description: self.c.Tr.Fetch,
|
|
Tooltip: self.c.Tr.FetchTooltip,
|
|
},
|
|
{
|
|
Keys: opts.GetKeys(opts.Config.Files.CollapseAll),
|
|
Handler: self.collapseAll,
|
|
Description: self.c.Tr.CollapseAll,
|
|
Tooltip: self.c.Tr.CollapseAllTooltip,
|
|
GetDisabledReason: self.require(self.isInTreeMode),
|
|
},
|
|
{
|
|
Keys: opts.GetKeys(opts.Config.Files.ExpandAll),
|
|
Handler: self.expandAll,
|
|
Description: self.c.Tr.ExpandAll,
|
|
Tooltip: self.c.Tr.ExpandAllTooltip,
|
|
GetDisabledReason: self.require(self.isInTreeMode),
|
|
},
|
|
}
|
|
}
|
|
|
|
func (self *FilesController) GetMouseKeybindings(opts types.KeybindingsOpts) []*gocui.ViewMouseBinding {
|
|
return []*gocui.ViewMouseBinding{
|
|
{
|
|
ViewName: "mergeConflicts",
|
|
Key: gocui.MouseLeft,
|
|
Handler: self.onClickMain,
|
|
FocusedView: self.context().GetViewName(),
|
|
},
|
|
}
|
|
}
|
|
|
|
func (self *FilesController) GetOnClick() func(opts gocui.ViewMouseBindingOpts) error {
|
|
return func(opts gocui.ViewMouseBindingOpts) error {
|
|
clickedIdx := self.context().GetSelectedLineIdx()
|
|
node := self.context().FileTreeViewModel.Get(clickedIdx)
|
|
if node == nil || node.File != nil {
|
|
return nil
|
|
}
|
|
|
|
// The arrow is at column visualDepth*2 (after indentation of 2 spaces per level).
|
|
// Only treat clicks on the arrow and the trailing space as arrow clicks.
|
|
visualDepth := self.context().FileTreeViewModel.GetVisualDepth(clickedIdx)
|
|
arrowStartCol := visualDepth * 2
|
|
arrowEndCol := arrowStartCol + 1
|
|
if opts.X < arrowStartCol || opts.X > arrowEndCol {
|
|
return nil
|
|
}
|
|
|
|
self.context().FileTreeViewModel.ToggleCollapsed(node.GetInternalPath())
|
|
self.c.PostRefreshUpdate(self.context())
|
|
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func (self *FilesController) GetOnRenderToMain() func() {
|
|
return func() {
|
|
self.c.Helpers().Diff.WithDiffModeCheck(func() {
|
|
node := self.context().GetSelected()
|
|
|
|
if node == nil {
|
|
self.renderToMainWithTask(types.NewRenderStringTask(self.c.Tr.NoChangedFiles))
|
|
return
|
|
}
|
|
|
|
if self.isSubmoduleCommitConflict(node.File) {
|
|
self.renderSubmoduleConflict(node)
|
|
return
|
|
}
|
|
|
|
if node.File != nil && node.File.HasInlineMergeConflicts {
|
|
if self.renderInlineMergeConflict(node) {
|
|
return
|
|
}
|
|
// The file is marked as conflicted but has no conflict markers (it
|
|
// was resolved in an editor), so fall through to show its diff.
|
|
} else if node.File != nil && node.File.HasMergeConflicts {
|
|
self.renderNonTextualConflict(node)
|
|
return
|
|
}
|
|
|
|
self.renderWorkingTreeDiff(node)
|
|
})
|
|
}
|
|
}
|
|
|
|
// renderToMainWithTask renders the given task to the main view with the standard
|
|
// diff title and subtitle.
|
|
func (self *FilesController) renderToMainWithTask(task types.UpdateTask) {
|
|
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: task,
|
|
},
|
|
})
|
|
}
|
|
|
|
// renderSubmoduleConflict shows, for a conflicted submodule, an explanation plus
|
|
// the commits each side added relative to their common ancestor as two separate,
|
|
// indented logs. If a side added nothing of its own (e.g. it was rewound to an
|
|
// ancestor of the other), the commit it points at is shown instead.
|
|
func (self *FilesController) renderSubmoduleConflict(node *filetree.FileNode) {
|
|
self.c.Helpers().MergeConflicts.ResetMergeState()
|
|
|
|
path := node.GetPath()
|
|
_, ours, theirs, err := self.c.Git().Submodule.GetConflictCommits(path)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
sideBlock := func(header string, side string, otherSide string) string {
|
|
log, err := self.c.Git().Submodule.ConflictSideLog(path, side, otherSide)
|
|
if err != nil {
|
|
return header
|
|
}
|
|
if log = strings.TrimRight(log, "\n"); log == "" {
|
|
if log, err = self.c.Git().Submodule.GetCommitSummary(path, side); err != nil {
|
|
return header
|
|
}
|
|
}
|
|
return header + "\n\n " + strings.ReplaceAll(log, "\n", "\n ")
|
|
}
|
|
|
|
message := strings.Join([]string{
|
|
self.conflictResolutionHint(utils.ResolvePlaceholderString(self.c.Tr.SubmoduleMergeConflictDescription, map[string]string{"path": path})),
|
|
sideBlock(self.c.Tr.MergeConflictCurrentDiff, ours, theirs),
|
|
sideBlock(self.c.Tr.MergeConflictIncomingDiff, theirs, ours),
|
|
}, "\n\n")
|
|
|
|
self.renderToMainWithTask(types.NewRenderStringTask(message))
|
|
}
|
|
|
|
// renderInlineMergeConflict renders the merge-conflict view for a file with
|
|
// inline conflict markers. It returns false if the file has no actual markers
|
|
// (it was resolved in an editor), in which case the caller should fall back to
|
|
// showing the file's diff.
|
|
func (self *FilesController) renderInlineMergeConflict(node *filetree.FileNode) bool {
|
|
hasConflicts, err := self.c.Helpers().MergeConflicts.SetMergeState(node.File)
|
|
if err != nil {
|
|
return true
|
|
}
|
|
|
|
if !hasConflicts {
|
|
return false
|
|
}
|
|
|
|
self.c.Helpers().MergeConflicts.Render()
|
|
return true
|
|
}
|
|
|
|
// renderNonTextualConflict shows the resolution hint for a non-textual text-file
|
|
// conflict (DD/AU/UA/UD/DU), plus the base diff for the modify/delete cases.
|
|
func (self *FilesController) renderNonTextualConflict(node *filetree.FileNode) {
|
|
message := self.conflictResolutionHint(node.File.GetMergeStateDescription(self.c.Tr))
|
|
|
|
if node.File.ShortStatus == "DU" || node.File.ShortStatus == "UD" {
|
|
cmdObj := self.c.Git().Diff.DiffCmdObj([]string{"--base", "--", node.GetPath()})
|
|
prefix := message + "\n\n"
|
|
if node.File.ShortStatus == "DU" {
|
|
prefix += self.c.Tr.MergeConflictIncomingDiff
|
|
} else {
|
|
prefix += self.c.Tr.MergeConflictCurrentDiff
|
|
}
|
|
prefix += "\n\n"
|
|
self.renderToMainWithTask(types.NewRunPtyTaskWithPrefix(cmdObj.GetCmd(), prefix))
|
|
return
|
|
}
|
|
|
|
self.renderToMainWithTask(types.NewRenderStringTask(message))
|
|
}
|
|
|
|
func (self *FilesController) renderWorkingTreeDiff(node *filetree.FileNode) {
|
|
self.c.Helpers().MergeConflicts.ResetMergeState()
|
|
|
|
split := self.c.UserConfig().Gui.SplitDiff == "always" || (node.GetHasUnstagedChanges() && node.GetHasStagedChanges())
|
|
mainShowsStaged := !split && node.GetHasStagedChanges()
|
|
|
|
paths := self.pathsForDiff(node)
|
|
cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, false, mainShowsStaged, paths)
|
|
title := self.c.Tr.UnstagedChanges
|
|
if mainShowsStaged {
|
|
title = self.c.Tr.StagedChanges
|
|
}
|
|
refreshOpts := types.RefreshMainOpts{
|
|
Pair: self.c.MainViewPairs().Normal,
|
|
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, paths)
|
|
|
|
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()),
|
|
}
|
|
}
|
|
|
|
self.c.RenderToMainViews(refreshOpts)
|
|
}
|
|
|
|
func (self *FilesController) GetOnDoubleClick() func() error {
|
|
return self.withItemGraceful(func(node *filetree.FileNode) error {
|
|
return self.press([]*filetree.FileNode{node})
|
|
})
|
|
}
|
|
|
|
func (self *FilesController) GetOnClickFocusedMainView() func(mainViewName string, clickedLineIdx int) error {
|
|
return func(mainViewName string, clickedLineIdx int) error {
|
|
node := self.getSelectedItem()
|
|
if node != nil && node.File != nil {
|
|
return self.EnterFile(types.OnFocusOpts{ClickedWindowName: mainViewName, ClickedViewLineIdx: clickedLineIdx})
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// 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",
|
|
// A submodule with both a staged commit and unstageable dirty content; the
|
|
// staged commit gets unstaged, the dirty content stays.
|
|
"MM": " M",
|
|
}
|
|
|
|
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(nodes []*filetree.FileNode, optimisticChangeFn func(*models.File) bool) error {
|
|
rerender := false
|
|
|
|
for _, node := range nodes {
|
|
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.Path == f.Path {
|
|
if optimisticChangeFn(modelFile) {
|
|
rerender = true
|
|
}
|
|
break
|
|
}
|
|
}
|
|
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if rerender {
|
|
self.c.PostRefreshUpdate(self.c.Contexts().Files)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// toggleStaged decides whether to stage or unstage the given nodes, updates the
|
|
// model optimistically, and then runs the matching git command via the supplied
|
|
// callbacks. press() (acting on the selection) and toggleStagedAll() (acting on
|
|
// the whole tree) share this; they differ only in the git commands they run,
|
|
// which is why those are passed in.
|
|
//
|
|
// If any node has unstaged changes we stage the nodes that have them (staging
|
|
// already-staged deleted files/folders would fail); otherwise we unstage all
|
|
// the nodes.
|
|
func (self *FilesController) toggleStaged(
|
|
nodes []*filetree.FileNode,
|
|
stageAction string,
|
|
unstageAction string,
|
|
stage func(unstagedNodes []*filetree.FileNode) error,
|
|
unstage func(nodes []*filetree.FileNode) error,
|
|
) error {
|
|
for _, node := range nodes {
|
|
// 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 errors.New(self.c.Tr.ErrStageDirWithInlineMergeConflicts)
|
|
}
|
|
}
|
|
|
|
nodes = normalisedSelectedNodes(nodes)
|
|
|
|
unstagedNodes := filterNodesHaveUnstagedChanges(nodes, self.c.Model().Submodules)
|
|
|
|
// Staging a submodule that only has dirty or untracked content (no new
|
|
// commit) is a no-op: the parent repo can't stage that content. When that's
|
|
// the only thing that looks stageable, don't stage; fall through to
|
|
// unstaging instead. That keeps the toggle symmetric (e.g. a fully-staged
|
|
// tree that also contains a dirty submodule still unstages on the next
|
|
// press) rather than getting stuck trying to stage the unstageable content.
|
|
shouldStage := len(unstagedNodes) > 0
|
|
if shouldStage {
|
|
noOp, err := self.stagingWouldBeNoOp(unstagedNodes)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
shouldStage = !noOp
|
|
}
|
|
|
|
if shouldStage {
|
|
self.c.LogAction(stageAction)
|
|
|
|
if err := self.optimisticChange(unstagedNodes, self.optimisticStage); err != nil {
|
|
return err
|
|
}
|
|
|
|
return stage(unstagedNodes)
|
|
}
|
|
|
|
// If there's nothing staged to unstage either, then the only thing we acted
|
|
// on was an unstageable submodule and nothing happened, so say why.
|
|
if !someNodesHaveStagedChanges(nodes) {
|
|
return errors.New(self.c.Tr.NothingToStageForSubmodule)
|
|
}
|
|
|
|
self.c.LogAction(unstageAction)
|
|
|
|
if err := self.optimisticChange(nodes, self.optimisticUnstage); err != nil {
|
|
return err
|
|
}
|
|
|
|
return unstage(nodes)
|
|
}
|
|
|
|
func (self *FilesController) pressWithLock(selectedNodes []*filetree.FileNode) error {
|
|
// When filtering, expand directory nodes to individual visible file paths
|
|
// so that only filtered files are staged/unstaged.
|
|
toPaths := func(nodes []*filetree.FileNode) []string {
|
|
if self.context().IsFiltering() {
|
|
var paths []string
|
|
for _, node := range nodes {
|
|
_ = node.ForEachFile(func(file *models.File) error {
|
|
paths = append(paths, file.Path)
|
|
return nil
|
|
})
|
|
}
|
|
return paths
|
|
}
|
|
return lo.Map(nodes, func(node *filetree.FileNode, _ int) string {
|
|
return node.GetPath()
|
|
})
|
|
}
|
|
|
|
stage := func(unstagedNodes []*filetree.FileNode) error {
|
|
var extraArgs []string
|
|
if self.context().GetStatusFilter() == filetree.DisplayTracked {
|
|
extraArgs = []string{"-u"}
|
|
}
|
|
|
|
return self.c.Git().WorkingTree.StageFiles(toPaths(unstagedNodes), extraArgs)
|
|
}
|
|
|
|
unstage := func(nodes []*filetree.FileNode) error {
|
|
if self.context().IsFiltering() {
|
|
// When filtering, only unstage visible files
|
|
return self.unstageFilteredFiles(nodes)
|
|
}
|
|
|
|
// need to partition the paths into tracked and untracked (where we assume directories are tracked). Then we'll run the commands separately.
|
|
trackedNodes, untrackedNodes := utils.Partition(nodes, func(node *filetree.FileNode) bool {
|
|
// We treat all directories as tracked. I'm not actually sure why we do this but
|
|
// it's been the existing behaviour for a while and nobody has complained
|
|
return !node.IsFile() || node.GetIsTracked()
|
|
})
|
|
|
|
if len(untrackedNodes) > 0 {
|
|
if err := self.c.Git().WorkingTree.UnstageUntrackedFiles(toPaths(untrackedNodes)); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if len(trackedNodes) > 0 {
|
|
if err := self.c.Git().WorkingTree.UnstageTrackedFiles(toPaths(trackedNodes)); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
return self.toggleStaged(selectedNodes,
|
|
self.c.Tr.Actions.StageFile, self.c.Tr.Actions.UnstageFile,
|
|
stage, unstage)
|
|
}
|
|
|
|
func (self *FilesController) press(nodes []*filetree.FileNode) error {
|
|
// A single file with a conflict that can only be resolved through a dialog
|
|
// can't be staged; route it to the same picker that `enter` uses instead.
|
|
if len(nodes) == 1 && self.conflictNeedsResolutionDialog(nodes[0].File) {
|
|
return self.openConflictResolutionMenu(nodes[0].File)
|
|
}
|
|
|
|
if err := self.pressWithLock(nodes); err != nil {
|
|
return err
|
|
}
|
|
|
|
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}})
|
|
|
|
self.context().HandleFocus(types.OnFocusOpts{})
|
|
return nil
|
|
}
|
|
|
|
func (self *FilesController) pathsForDiff(node *filetree.FileNode) []string {
|
|
return diffPathsForNode(
|
|
node.Raw(), self.context().GetRoot().Raw(), self.c.Model().Files, self.context().IsFiltering())
|
|
}
|
|
|
|
// unstageFilteredFiles unstages only the visible (filtered) files from the
|
|
// given nodes, correctly partitioning by tracked/untracked.
|
|
func (self *FilesController) unstageFilteredFiles(nodes []*filetree.FileNode) error {
|
|
var trackedPaths, untrackedPaths []string
|
|
for _, node := range nodes {
|
|
_ = node.ForEachFile(func(file *models.File) error {
|
|
if file.Tracked || file.HasStagedChanges {
|
|
trackedPaths = append(trackedPaths, file.Path)
|
|
} else {
|
|
untrackedPaths = append(untrackedPaths, file.Path)
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
if len(untrackedPaths) > 0 {
|
|
if err := self.c.Git().WorkingTree.UnstageUntrackedFiles(untrackedPaths); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if len(trackedPaths) > 0 {
|
|
if err := self.c.Git().WorkingTree.UnstageTrackedFiles(trackedPaths); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
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) collapseAll() error {
|
|
self.context().FileTreeViewModel.CollapseAll()
|
|
|
|
self.c.PostRefreshUpdate(self.context())
|
|
|
|
return nil
|
|
}
|
|
|
|
func (self *FilesController) expandAll() error {
|
|
self.context().FileTreeViewModel.ExpandAll()
|
|
|
|
self.c.PostRefreshUpdate(self.context())
|
|
|
|
return nil
|
|
}
|
|
|
|
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
|
|
|
|
if self.conflictNeedsResolutionDialog(file) {
|
|
return self.openConflictResolutionMenu(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()
|
|
}
|
|
|
|
context := lo.Ternary(opts.ClickedWindowName == "secondary", self.c.Contexts().StagingSecondary, self.c.Contexts().Staging)
|
|
self.c.Context().Push(context, opts)
|
|
self.c.Helpers().PatchBuilding.ShowHunkStagingHint()
|
|
|
|
return nil
|
|
}
|
|
|
|
// conflictResolutionHint formats a conflict description for the main view,
|
|
// appending the "press <enter> to resolve" hint and wrapping it when the view is
|
|
// wide enough that long lines would otherwise hurt readability.
|
|
func (self *FilesController) conflictResolutionHint(description string) string {
|
|
message := description + "\n\n" + fmt.Sprintf(self.c.Tr.MergeConflictPressEnterToResolve,
|
|
self.c.UserConfig().Keybinding.Universal.GoInto)
|
|
if self.c.Views().Main.InnerWidth() > 70 {
|
|
lines, _, _ := utils.WrapViewLinesToWidth(true, false, message, 70, 4)
|
|
message = strings.Join(lines, "\n")
|
|
}
|
|
return message
|
|
}
|
|
|
|
// conflictNeedsResolutionDialog reports whether a file's merge conflict can only
|
|
// be resolved through a dialog that picks one side, as opposed to editing
|
|
// conflict markers in the merge view. These are the "non-textual" conflicts:
|
|
// text files where one side modified and the other deleted/renamed the file
|
|
// (DD/AU/UA/UD/DU), and submodules where both sides moved the gitlink (UU).
|
|
func (self *FilesController) conflictNeedsResolutionDialog(file *models.File) bool {
|
|
if file == nil || !file.HasMergeConflicts {
|
|
return false
|
|
}
|
|
|
|
// A conflicted submodule has no conflict markers to edit; it's resolved by
|
|
// picking which commit to point at.
|
|
if file.IsSubmodule(self.c.Model().Submodules) {
|
|
return true
|
|
}
|
|
|
|
return !file.HasInlineMergeConflicts
|
|
}
|
|
|
|
// canStageSelection disables staging when a multiple selection includes a file
|
|
// with a conflict that must be resolved through a dialog; those have to be
|
|
// resolved one at a time.
|
|
func (self *FilesController) canStageSelection(nodes []*filetree.FileNode) *types.DisabledReason {
|
|
if len(nodes) > 1 {
|
|
for _, node := range nodes {
|
|
if node.SomeFile(self.conflictNeedsResolutionDialog) {
|
|
return &types.DisabledReason{
|
|
Text: utils.ResolvePlaceholderString(
|
|
self.c.Tr.StageConflictsRangeDisabled, map[string]string{
|
|
"goIntoKey": self.c.UserConfig().Keybinding.Universal.GoInto.String(),
|
|
},
|
|
),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// isSubmoduleCommitConflict reports whether the file is a submodule whose commit
|
|
// pointer conflicts (status UU or AA): both sides recorded a different commit,
|
|
// with no base content to merge. These are resolved by picking one side's
|
|
// commit. Other submodule conflicts (e.g. modify/delete) are handled like
|
|
// ordinary non-textual conflicts, with the keep/delete picker.
|
|
func (self *FilesController) isSubmoduleCommitConflict(file *models.File) bool {
|
|
return file != nil && file.HasInlineMergeConflicts && file.IsSubmodule(self.c.Model().Submodules)
|
|
}
|
|
|
|
func (self *FilesController) openConflictResolutionMenu(file *models.File) error {
|
|
if self.isSubmoduleCommitConflict(file) {
|
|
return self.openSubmoduleConflictMenu(file)
|
|
}
|
|
|
|
return self.openFileConflictMenu(file)
|
|
}
|
|
|
|
func (self *FilesController) openFileConflictMenu(file *models.File) error {
|
|
handle := func(command func(command string) error, logText string) error {
|
|
self.c.LogAction(logText)
|
|
if err := command(file.GetPath()); err != nil {
|
|
return err
|
|
}
|
|
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}})
|
|
return nil
|
|
}
|
|
keepItem := &types.MenuItem{
|
|
Label: self.c.Tr.MergeConflictKeepFile,
|
|
OnPress: func() error {
|
|
return handle(self.c.Git().WorkingTree.StageFile, self.c.Tr.Actions.ResolveConflictByKeepingFile)
|
|
},
|
|
Keys: menuKey('k'),
|
|
}
|
|
deleteItem := &types.MenuItem{
|
|
Label: self.c.Tr.MergeConflictDeleteFile,
|
|
OnPress: func() error {
|
|
return handle(self.c.Git().WorkingTree.RemoveConflictedFile, self.c.Tr.Actions.ResolveConflictByDeletingFile)
|
|
},
|
|
Keys: menuKey('d'),
|
|
}
|
|
items := []*types.MenuItem{}
|
|
switch file.ShortStatus {
|
|
case "DD":
|
|
// For "both deleted" conflicts, deleting the file is the only reasonable thing you can do.
|
|
// Restoring to the state before deletion is not the responsibility of a conflict resolution tool.
|
|
items = append(items, deleteItem)
|
|
case "DU", "UD":
|
|
// For these, we put the delete option first because it's the most common one,
|
|
// even if it's more destructive.
|
|
items = append(items, deleteItem, keepItem)
|
|
case "AU", "UA":
|
|
// For these, we put the keep option first because it's less destructive,
|
|
// and the chances between keep and delete are 50/50.
|
|
items = append(items, keepItem, deleteItem)
|
|
default:
|
|
panic("should only be called if there's a merge conflict")
|
|
}
|
|
return self.c.Menu(types.CreateMenuOptions{
|
|
Title: self.c.Tr.MergeConflictsTitle,
|
|
Prompt: file.GetMergeStateDescription(self.c.Tr),
|
|
Items: items,
|
|
})
|
|
}
|
|
|
|
func (self *FilesController) openSubmoduleConflictMenu(file *models.File) error {
|
|
path := file.GetPath()
|
|
_, ours, theirs, err := self.c.Git().Submodule.GetConflictCommits(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
resolve := func(sha string, logAction string) error {
|
|
self.c.LogAction(logAction)
|
|
if err := self.c.Git().Submodule.CheckoutConflictCommit(path, sha); err != nil {
|
|
return err
|
|
}
|
|
if err := self.c.Git().WorkingTree.StageFile(path); err != nil {
|
|
return err
|
|
}
|
|
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}})
|
|
return nil
|
|
}
|
|
|
|
// Append the commit summary to the label so the user can tell the two
|
|
// candidates apart, falling back to the bare label if we can't read it.
|
|
label := func(text string, sha string) string {
|
|
if summary, err := self.c.Git().Submodule.GetCommitSummary(path, sha); err == nil && summary != "" {
|
|
return fmt.Sprintf("%s (%s)", text, summary)
|
|
}
|
|
return text
|
|
}
|
|
|
|
return self.c.Menu(types.CreateMenuOptions{
|
|
Title: self.c.Tr.MergeConflictsTitle,
|
|
Prompt: utils.ResolvePlaceholderString(self.c.Tr.SubmoduleMergeConflictDescription, map[string]string{"path": path}),
|
|
Items: []*types.MenuItem{
|
|
{
|
|
Label: label(self.c.Tr.MergeConflictTakeCurrentCommit, ours),
|
|
OnPress: func() error { return resolve(ours, self.c.Tr.Actions.TakeCurrentSubmoduleCommit) },
|
|
Keys: menuKey('c'),
|
|
},
|
|
{
|
|
Label: label(self.c.Tr.MergeConflictTakeIncomingCommit, theirs),
|
|
OnPress: func() error { return resolve(theirs, self.c.Tr.Actions.TakeIncomingSubmoduleCommit) },
|
|
Keys: menuKey('i'),
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
// The stage-all command acts on the file tree as it is displayed, so there has
|
|
// to be something in it. This is also the case before the first files refresh
|
|
// has come in, when there is no tree at all yet.
|
|
func (self *FilesController) anyFilesDisplayed() *types.DisabledReason {
|
|
if self.context().FileTreeViewModel.Len() == 0 {
|
|
return &types.DisabledReason{Text: self.c.Tr.NoChangedFiles}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (self *FilesController) toggleStagedAll() error {
|
|
if err := self.toggleStagedAllWithLock(); err != nil {
|
|
return err
|
|
}
|
|
|
|
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}})
|
|
|
|
self.context().HandleFocus(types.OnFocusOpts{})
|
|
return nil
|
|
}
|
|
|
|
func (self *FilesController) toggleStagedAllWithLock() error {
|
|
root := self.context().FileTreeViewModel.GetRoot()
|
|
|
|
stage := func(unstagedNodes []*filetree.FileNode) error {
|
|
if self.context().IsFiltering() {
|
|
// When filtering, only stage visible files
|
|
var paths []string
|
|
_ = root.ForEachFile(func(file *models.File) error {
|
|
paths = append(paths, file.Path)
|
|
return nil
|
|
})
|
|
return self.c.Git().WorkingTree.StageFiles(paths, nil)
|
|
}
|
|
|
|
onlyTrackedFiles := self.context().GetStatusFilter() == filetree.DisplayTracked
|
|
return self.c.Git().WorkingTree.StageAll(onlyTrackedFiles)
|
|
}
|
|
|
|
unstage := func(nodes []*filetree.FileNode) error {
|
|
if self.context().IsFiltering() {
|
|
// When filtering, only unstage visible files
|
|
return self.unstageFilteredFiles(nodes)
|
|
}
|
|
|
|
return self.c.Git().WorkingTree.UnstageAll()
|
|
}
|
|
|
|
return self.toggleStaged([]*filetree.FileNode{root},
|
|
self.c.Tr.Actions.StageAllFiles, self.c.Tr.Actions.UnstageAllFiles,
|
|
stage, unstage)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}})
|
|
return nil
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}})
|
|
return nil
|
|
}
|
|
|
|
func (self *FilesController) ignoreOrExcludeFile(node *filetree.FileNode, trText string, trPrompt string, trAction string, f func(string) error) error {
|
|
if node.GetIsTracked() {
|
|
self.c.Confirm(types.ConfirmOpts{
|
|
Title: trText,
|
|
Prompt: trPrompt,
|
|
HandleConfirm: func() error {
|
|
return self.ignoreOrExcludeTracked(node, trAction, f)
|
|
},
|
|
})
|
|
|
|
return nil
|
|
}
|
|
return self.ignoreOrExcludeUntracked(node, trAction, f)
|
|
}
|
|
|
|
func (self *FilesController) ignore(node *filetree.FileNode) error {
|
|
if node.GetPath() == ".gitignore" {
|
|
return errors.New(self.c.Tr.Actions.IgnoreFileErr)
|
|
}
|
|
return self.ignoreOrExcludeFile(node, self.c.Tr.IgnoreTracked, self.c.Tr.IgnoreTrackedPrompt, self.c.Tr.Actions.IgnoreExcludeFile, self.c.Git().WorkingTree.Ignore)
|
|
}
|
|
|
|
func (self *FilesController) exclude(node *filetree.FileNode) error {
|
|
if node.GetPath() == ".gitignore" {
|
|
return errors.New(self.c.Tr.Actions.ExcludeGitIgnoreErr)
|
|
}
|
|
|
|
return self.ignoreOrExcludeFile(node, self.c.Tr.ExcludeTracked, self.c.Tr.ExcludeTrackedPrompt, self.c.Tr.Actions.ExcludeFile, self.c.Git().WorkingTree.Exclude)
|
|
}
|
|
|
|
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 err
|
|
}
|
|
return nil
|
|
},
|
|
Keys: menuKey('i'),
|
|
},
|
|
{
|
|
LabelColumns: []string{self.c.Tr.ExcludeFile},
|
|
OnPress: func() error {
|
|
if err := self.exclude(node); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
},
|
|
Keys: menuKey('e'),
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
func (self *FilesController) refresh() error {
|
|
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}})
|
|
return nil
|
|
}
|
|
|
|
func (self *FilesController) handleAmendCommitPress() error {
|
|
doAmend := func() error {
|
|
return self.c.Helpers().WorkingTree.WithEnsureCommittableFiles(func() error {
|
|
if len(self.c.Model().Commits) == 0 {
|
|
return errors.New(self.c.Tr.NoCommitToAmend)
|
|
}
|
|
|
|
return self.c.Helpers().AmendHelper.AmendHead()
|
|
})
|
|
}
|
|
|
|
if self.isResolvingConflicts() {
|
|
return self.c.Menu(types.CreateMenuOptions{
|
|
Title: self.c.Tr.AmendCommitTitle,
|
|
Prompt: self.c.Tr.AmendCommitWithConflictsMenuPrompt,
|
|
HideCancel: true, // We want the cancel item first, so we add one manually
|
|
Items: []*types.MenuItem{
|
|
{
|
|
Label: self.c.Tr.Cancel,
|
|
OnPress: func() error {
|
|
return nil
|
|
},
|
|
},
|
|
{
|
|
Label: self.c.Tr.AmendCommitWithConflictsContinue,
|
|
OnPress: func() error {
|
|
return self.c.Helpers().MergeAndRebase.ContinueRebase()
|
|
},
|
|
},
|
|
{
|
|
Label: self.c.Tr.AmendCommitWithConflictsAmend,
|
|
OnPress: func() error {
|
|
return doAmend()
|
|
},
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
return self.c.ConfirmIf(!self.c.UserConfig().Gui.SkipAmendWarning,
|
|
types.ConfirmOpts{
|
|
Title: self.c.Tr.AmendLastCommitTitle,
|
|
Prompt: self.c.Tr.SureToAmend,
|
|
HandleConfirm: func() error {
|
|
return doAmend()
|
|
},
|
|
},
|
|
)
|
|
}
|
|
|
|
func (self *FilesController) isResolvingConflicts() bool {
|
|
commits := self.c.Model().Commits
|
|
for _, c := range commits {
|
|
if c.Status == models.StatusConflicted {
|
|
return true
|
|
}
|
|
if !c.IsTODO() {
|
|
break
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (self *FilesController) handleStatusFilterPressed() error {
|
|
currentFilter := self.context().GetStatusFilter()
|
|
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)
|
|
},
|
|
Keys: menuKey('s'),
|
|
Widget: types.MakeMenuRadioButton(currentFilter == filetree.DisplayStaged),
|
|
},
|
|
{
|
|
Label: self.c.Tr.FilterUnstagedFiles,
|
|
OnPress: func() error {
|
|
return self.setStatusFiltering(filetree.DisplayUnstaged)
|
|
},
|
|
Keys: menuKey('u'),
|
|
Widget: types.MakeMenuRadioButton(currentFilter == filetree.DisplayUnstaged),
|
|
},
|
|
{
|
|
Label: self.c.Tr.FilterTrackedFiles,
|
|
OnPress: func() error {
|
|
return self.setStatusFiltering(filetree.DisplayTracked)
|
|
},
|
|
Keys: menuKey('t'),
|
|
Widget: types.MakeMenuRadioButton(currentFilter == filetree.DisplayTracked),
|
|
},
|
|
{
|
|
Label: self.c.Tr.FilterUntrackedFiles,
|
|
OnPress: func() error {
|
|
return self.setStatusFiltering(filetree.DisplayUntracked)
|
|
},
|
|
Keys: menuKey('T'),
|
|
Widget: types.MakeMenuRadioButton(currentFilter == filetree.DisplayUntracked),
|
|
},
|
|
{
|
|
Label: self.c.Tr.NoFilter,
|
|
OnPress: func() error {
|
|
return self.setStatusFiltering(filetree.DisplayAll)
|
|
},
|
|
Keys: menuKey('r'),
|
|
Widget: types.MakeMenuRadioButton(currentFilter == filetree.DisplayAll),
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
func (self *FilesController) filteringLabel(filter filetree.FileTreeDisplayFilter) string {
|
|
switch filter {
|
|
case filetree.DisplayAll:
|
|
return ""
|
|
case filetree.DisplayStaged:
|
|
return self.c.Tr.FilterLabelStagedFiles
|
|
case filetree.DisplayUnstaged:
|
|
return self.c.Tr.FilterLabelUnstagedFiles
|
|
case filetree.DisplayTracked:
|
|
return self.c.Tr.FilterLabelTrackedFiles
|
|
case filetree.DisplayUntracked:
|
|
return self.c.Tr.FilterLabelUntrackedFiles
|
|
case filetree.DisplayConflicted:
|
|
return self.c.Tr.FilterLabelConflictingFiles
|
|
}
|
|
|
|
panic(fmt.Sprintf("Unexpected files display filter: %d", filter))
|
|
}
|
|
|
|
func (self *FilesController) setStatusFiltering(filter filetree.FileTreeDisplayFilter) error {
|
|
previousFilter := self.context().GetStatusFilter()
|
|
|
|
self.context().FileTreeViewModel.SetStatusFilter(filter)
|
|
self.c.Contexts().Files.GetView().Subtitle = self.filteringLabel(filter)
|
|
|
|
// Whenever we switch between untracked and other filters, we need to refresh the files view
|
|
// because the untracked files filter applies when running `git status`.
|
|
if previousFilter != filter && (previousFilter == filetree.DisplayUntracked || filter == filetree.DisplayUntracked) {
|
|
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}})
|
|
} else {
|
|
self.c.PostRefreshUpdate(self.context())
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (self *FilesController) edit(nodes []*filetree.FileNode) error {
|
|
return self.c.Helpers().Files.EditFiles(lo.FilterMap(nodes,
|
|
func(node *filetree.FileNode, _ int) (string, bool) {
|
|
return node.GetPath(), node.IsFile()
|
|
}))
|
|
}
|
|
|
|
func (self *FilesController) canEditFiles(nodes []*filetree.FileNode) *types.DisabledReason {
|
|
if lo.NoneBy(nodes, func(node *filetree.FileNode) bool { return node.IsFile() }) {
|
|
return &types.DisabledReason{
|
|
Text: self.c.Tr.ErrCannotEditDirectory,
|
|
ShowErrorInPanel: true,
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
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.GetPath(),
|
|
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)
|
|
}
|
|
|
|
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.IsWorkingTreeDirtyExceptSubmodules() {
|
|
return errors.New(self.c.Tr.NoFilesToStash)
|
|
}
|
|
return self.handleStashSave(self.c.Git().Stash.Push, self.c.Tr.Actions.StashAllChanges)
|
|
},
|
|
Keys: menuKey('a'),
|
|
},
|
|
{
|
|
Label: self.c.Tr.StashAllChangesKeepIndex,
|
|
OnPress: func() error {
|
|
if !self.c.Helpers().WorkingTree.IsWorkingTreeDirtyExceptSubmodules() {
|
|
return errors.New(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)
|
|
},
|
|
Keys: menuKey('i'),
|
|
},
|
|
{
|
|
Label: self.c.Tr.StashIncludeUntrackedChanges,
|
|
OnPress: func() error {
|
|
return self.handleStashSave(self.c.Git().Stash.StashIncludeUntrackedChanges, self.c.Tr.Actions.StashIncludeUntrackedChanges)
|
|
},
|
|
Keys: menuKey('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.AnyStagedFilesExceptSubmodules() {
|
|
return errors.New(self.c.Tr.NoTrackedStagedFilesStash)
|
|
}
|
|
return self.handleStashSave(self.c.Git().Stash.SaveStagedChanges, self.c.Tr.Actions.StashStagedChanges)
|
|
},
|
|
Keys: menuKey('s'),
|
|
},
|
|
{
|
|
Label: self.c.Tr.StashUnstagedChanges,
|
|
OnPress: func() error {
|
|
if !self.c.Helpers().WorkingTree.IsWorkingTreeDirtyExceptSubmodules() {
|
|
return errors.New(self.c.Tr.NoFilesToStash)
|
|
}
|
|
if self.c.Helpers().WorkingTree.AnyStagedFilesExceptSubmodules() {
|
|
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)
|
|
},
|
|
Keys: menuKey('u'),
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
func (self *FilesController) openMergeConflictMenu(nodes []*filetree.FileNode) error {
|
|
normalizedNodes := flattenSelectedNodesToFiles(nodes)
|
|
|
|
fileNodesWithConflicts := lo.Filter(normalizedNodes, func(node *filetree.FileNode, _ int) bool {
|
|
return node.File != nil && node.File.HasInlineMergeConflicts
|
|
})
|
|
|
|
filepaths := lo.Map(fileNodesWithConflicts, func(node *filetree.FileNode, _ int) string {
|
|
return node.GetPath()
|
|
})
|
|
|
|
return self.c.Helpers().WorkingTree.CreateMergeConflictMenu(filepaths)
|
|
}
|
|
|
|
func (self *FilesController) canOpenMergeConflictMenu(nodes []*filetree.FileNode) *types.DisabledReason {
|
|
normalizedNodes := flattenSelectedNodesToFiles(nodes)
|
|
|
|
hasFileNodesWithConflicts := lo.SomeBy(normalizedNodes, func(node *filetree.FileNode) bool {
|
|
return node.File != nil && node.File.HasInlineMergeConflicts
|
|
})
|
|
|
|
if !hasFileNodesWithConflicts {
|
|
return &types.DisabledReason{Text: self.c.Tr.NoFilesWithMergeConflicts}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
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 err
|
|
}
|
|
self.c.Toast(self.c.Tr.FileNameCopiedToast)
|
|
return nil
|
|
},
|
|
DisabledReason: self.require(self.singleItemSelected())(),
|
|
Keys: menuKey('n'),
|
|
}
|
|
copyRelativePathItem := &types.MenuItem{
|
|
Label: self.c.Tr.CopyRelativeFilePath,
|
|
OnPress: func() error {
|
|
if err := self.c.OS().CopyToClipboard(node.GetPath()); err != nil {
|
|
return err
|
|
}
|
|
self.c.Toast(self.c.Tr.FilePathCopiedToast)
|
|
return nil
|
|
},
|
|
DisabledReason: self.require(self.singleItemSelected())(),
|
|
Keys: menuKey('p'),
|
|
}
|
|
copyAbsolutePathItem := &types.MenuItem{
|
|
Label: self.c.Tr.CopyAbsoluteFilePath,
|
|
OnPress: func() error {
|
|
absPath, err := filepath.Abs(node.GetPath())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := self.c.OS().CopyToClipboard(absPath); err != nil {
|
|
return err
|
|
}
|
|
self.c.Toast(self.c.Tr.FilePathCopiedToast)
|
|
return nil
|
|
},
|
|
DisabledReason: self.require(self.singleItemSelected())(),
|
|
Keys: menuKey('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.GetDiff(hasStaged, "--", path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := self.c.OS().CopyToClipboard(diff); err != nil {
|
|
return 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
|
|
},
|
|
))(),
|
|
Keys: menuKey('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.GetDiff(hasStaged, "--")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := self.c.OS().CopyToClipboard(diff); err != nil {
|
|
return 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
|
|
},
|
|
)(),
|
|
Keys: menuKey('a'),
|
|
}
|
|
|
|
return self.c.Menu(types.CreateMenuOptions{
|
|
Title: self.c.Tr.CopyToClipboardMenu,
|
|
Items: []*types.MenuItem{
|
|
copyNameItem,
|
|
copyRelativePathItem,
|
|
copyAbsolutePathItem,
|
|
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}", "@{upstream}")
|
|
}
|
|
|
|
func (self *FilesController) handleToggleDirCollapsed() error {
|
|
node := self.context().GetSelected()
|
|
if node == nil {
|
|
return nil
|
|
}
|
|
|
|
self.context().FileTreeViewModel.ToggleCollapsed(node.GetInternalPath())
|
|
|
|
self.c.PostRefreshUpdate(self.c.Contexts().Files)
|
|
|
|
return nil
|
|
}
|
|
|
|
func (self *FilesController) toggleTreeView() error {
|
|
self.context().FileTreeViewModel.ToggleShowTree()
|
|
|
|
self.c.PostRefreshUpdate(self.context())
|
|
return nil
|
|
}
|
|
|
|
func (self *FilesController) handleStashSave(stashFunc func(message string) error, action string) error {
|
|
self.c.Prompt(types.PromptOpts{
|
|
Title: self.c.Tr.StashChanges,
|
|
HandleConfirm: func(stashComment string) error {
|
|
return self.c.WithWaitingStatusBlockingInput(
|
|
types.WaitingStatusOpts{Message: self.c.Tr.StashingStatus},
|
|
func(gocui.Task) error {
|
|
self.c.LogAction(action)
|
|
|
|
if err := stashFunc(stashComment); err != nil {
|
|
return err
|
|
}
|
|
self.c.RefreshFromWorker(types.RefreshOptions{
|
|
BatchUIUpdates: true,
|
|
Scope: []types.RefreshableView{types.STASH, types.FILES},
|
|
})
|
|
return nil
|
|
})
|
|
},
|
|
AllowEmptyInput: true,
|
|
})
|
|
|
|
return nil
|
|
}
|
|
|
|
func (self *FilesController) onClickMain(opts gocui.ViewMouseBindingOpts) error {
|
|
return self.EnterFile(types.OnFocusOpts{ClickedWindowName: "main", ClickedViewLineIdx: opts.Y})
|
|
}
|
|
|
|
func (self *FilesController) fetch() error {
|
|
fetchGeneration := self.c.State().GetRepoGeneration()
|
|
return self.c.WithWaitingStatus(self.c.Tr.FetchingStatus, func(task gocui.Task) error {
|
|
self.c.LogAction("Fetch")
|
|
err := self.c.Git().Sync.Fetch(task)
|
|
|
|
if err != nil && strings.Contains(err.Error(), "exit status 128") {
|
|
return errors.New(self.c.Tr.PassUnameWrong)
|
|
}
|
|
|
|
return self.c.Helpers().BranchesHelper.PostFetchRefresh(err, false, fetchGeneration)
|
|
})
|
|
}
|
|
|
|
// Couldn't think of a better term than 'normalised'. Alas.
|
|
// The idea is that when you select a range of nodes, you will often have both
|
|
// a node and its parent node selected. If we are trying to discard changes to the
|
|
// selected nodes, we'll get an error if we try to discard the child after the parent.
|
|
// So we just need to filter out any nodes from the selection that are descendants
|
|
// of other nodes
|
|
func normalisedSelectedNodes(selectedNodes []*filetree.FileNode) []*filetree.FileNode {
|
|
return lo.Filter(selectedNodes, func(node *filetree.FileNode, _ int) bool {
|
|
return !isDescendentOfSelectedNodes(node, selectedNodes)
|
|
})
|
|
}
|
|
|
|
// NOTE: there's a duplicate of this function in commits_files_controller.go; if you make
|
|
// changes here, make them there, too. (We should unify them using generics.)
|
|
func isDescendentOfSelectedNodes(node *filetree.FileNode, selectedNodes []*filetree.FileNode) bool {
|
|
nodePath := node.GetInternalPath()
|
|
|
|
for _, selectedNode := range selectedNodes {
|
|
if selectedNode.IsFile() {
|
|
continue
|
|
}
|
|
|
|
selectedNodePath := selectedNode.GetInternalPath()
|
|
|
|
if strings.HasPrefix(nodePath, selectedNodePath+"/") {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// BFS algorithm for expanding directories into their children,
|
|
// and for collecting the unique file nodes
|
|
func flattenSelectedNodesToFiles(selectedNodes []*filetree.FileNode) []*filetree.FileNode {
|
|
queue := append(make([]*filetree.FileNode, 0, len(selectedNodes)), selectedNodes...)
|
|
visited := set.New[string]()
|
|
var files []*filetree.FileNode
|
|
|
|
for len(queue) > 0 {
|
|
// pop node from queue
|
|
node := queue[0]
|
|
queue = queue[1:]
|
|
|
|
nodeID := node.ID()
|
|
if visited.Includes(nodeID) {
|
|
continue
|
|
}
|
|
visited.Add(nodeID)
|
|
|
|
if node.File != nil {
|
|
// unique file node -> collect it
|
|
files = append(files, node)
|
|
continue
|
|
}
|
|
|
|
// directory node -> enqueue children
|
|
for _, ch := range node.Children {
|
|
queue = append(queue, &filetree.FileNode{Node: ch})
|
|
}
|
|
}
|
|
return files
|
|
}
|
|
|
|
func someNodesHaveUnstagedChanges(nodes []*filetree.FileNode) bool {
|
|
return lo.SomeBy(nodes, (*filetree.FileNode).GetHasUnstagedChanges)
|
|
}
|
|
|
|
func someNodesHaveStagedChanges(nodes []*filetree.FileNode) bool {
|
|
return lo.SomeBy(nodes, (*filetree.FileNode).GetHasStagedChanges)
|
|
}
|
|
|
|
func filterNodesHaveUnstagedChanges(nodes []*filetree.FileNode, submodules []*models.SubmoduleConfig) []*filetree.FileNode {
|
|
return lo.Filter(nodes, func(node *filetree.FileNode, _ int) bool {
|
|
return node.SomeFile(func(file *models.File) bool {
|
|
return fileHasStageableUnstagedChanges(file, submodules)
|
|
})
|
|
})
|
|
}
|
|
|
|
// For a submodule, the only thing the parent repo can stage is the
|
|
// commit-pointer change; dirty or untracked content within the submodule
|
|
// shows up as an unstaged change but can never be staged from the parent. So
|
|
// once the submodule's commit is staged (leaving it at e.g. "MM"), we mustn't
|
|
// treat the leftover unstaged change as stageable, or pressing space would
|
|
// keep trying to stage it instead of unstaging it.
|
|
func fileHasStageableUnstagedChanges(file *models.File, submodules []*models.SubmoduleConfig) bool {
|
|
if !file.HasUnstagedChanges {
|
|
return false
|
|
}
|
|
|
|
if file.IsSubmodule(submodules) {
|
|
return !file.HasStagedChanges
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// stagingWouldBeNoOp reports whether staging the given nodes would have no
|
|
// visible effect, which happens when the only things being staged are
|
|
// submodules that have dirty or untracked content but no new commit: the
|
|
// parent repo can't stage that content. If a regular file (or a submodule with
|
|
// a stageable new commit) is among them, staging does something, so this
|
|
// returns false.
|
|
func (self *FilesController) stagingWouldBeNoOp(nodes []*filetree.FileNode) (bool, error) {
|
|
submodules := self.c.Model().Submodules
|
|
|
|
var submodulePaths []string
|
|
hasOtherStageableChanges := false
|
|
for _, node := range nodes {
|
|
_ = node.ForEachFile(func(file *models.File) error {
|
|
if file.IsSubmodule(submodules) {
|
|
submodulePaths = append(submodulePaths, file.Path)
|
|
} else if file.HasUnstagedChanges {
|
|
hasOtherStageableChanges = true
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
if hasOtherStageableChanges || len(submodulePaths) == 0 {
|
|
return false, nil
|
|
}
|
|
|
|
anyStageable, err := self.c.Git().Submodule.AnyHaveStageableChanges(submodulePaths)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
return !anyStageable, nil
|
|
}
|
|
|
|
func findSubmoduleNode(nodes []*filetree.FileNode, submodules []*models.SubmoduleConfig) *models.File {
|
|
for _, node := range nodes {
|
|
submoduleNode := node.FindFirstFileBy(func(f *models.File) bool {
|
|
return f.IsSubmodule(submodules)
|
|
})
|
|
if submoduleNode != nil {
|
|
return submoduleNode
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (self *FilesController) canRemove(selectedNodes []*filetree.FileNode) *types.DisabledReason {
|
|
// Return disabled if the selection contains multiple changed items and includes a submodule change.
|
|
submodules := self.c.Model().Submodules
|
|
hasFiles := false
|
|
uniqueSelectedSubmodules := set.New[*models.SubmoduleConfig]()
|
|
|
|
for _, node := range selectedNodes {
|
|
_ = node.ForEachFile(func(f *models.File) error {
|
|
if submodule := f.SubmoduleConfig(submodules); submodule != nil {
|
|
uniqueSelectedSubmodules.Add(submodule)
|
|
} else {
|
|
hasFiles = true
|
|
}
|
|
return nil
|
|
})
|
|
if uniqueSelectedSubmodules.Len() > 0 && (hasFiles || uniqueSelectedSubmodules.Len() > 1) {
|
|
return &types.DisabledReason{Text: self.c.Tr.MultiSelectNotSupportedForSubmodules}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (self *FilesController) remove(selectedNodes []*filetree.FileNode) error {
|
|
submodules := self.c.Model().Submodules
|
|
|
|
selectedNodes = normalisedSelectedNodes(selectedNodes)
|
|
|
|
// If we have one submodule then we must only have one submodule or `canRemove` would have
|
|
// returned an error
|
|
submoduleNode := findSubmoduleNode(selectedNodes, submodules)
|
|
if submoduleNode != nil {
|
|
submodule := submoduleNode.SubmoduleConfig(submodules)
|
|
|
|
menuItems := []*types.MenuItem{
|
|
{
|
|
Label: self.c.Tr.SubmoduleStashAndReset,
|
|
OnPress: func() error {
|
|
return self.ResetSubmodule(submodule)
|
|
},
|
|
},
|
|
}
|
|
|
|
return self.c.Menu(types.CreateMenuOptions{Title: submoduleNode.GetPath(), Items: menuItems})
|
|
}
|
|
|
|
discardAllChangesItem := types.MenuItem{
|
|
Label: self.c.Tr.DiscardAllChanges,
|
|
OnPress: func() error {
|
|
self.c.LogAction(self.c.Tr.Actions.DiscardAllChangesInFile)
|
|
|
|
if self.context().IsSelectingRange() {
|
|
defer self.context().CancelRangeSelect()
|
|
}
|
|
|
|
nodes := lo.Map(selectedNodes, func(n *filetree.FileNode, _ int) git_commands.IFileNode { return n })
|
|
if err := self.c.Git().WorkingTree.DiscardAllDirChanges(nodes); err != nil {
|
|
return err
|
|
}
|
|
|
|
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES, types.WORKTREES}})
|
|
return nil
|
|
},
|
|
Keys: self.c.KeybindingsOpts().GetKeys(self.c.UserConfig().Keybinding.Files.ConfirmDiscard),
|
|
Tooltip: utils.ResolvePlaceholderString(
|
|
self.c.Tr.DiscardAllTooltip,
|
|
map[string]string{
|
|
"path": self.formattedPaths(selectedNodes),
|
|
},
|
|
),
|
|
}
|
|
|
|
discardUnstagedChangesItem := types.MenuItem{
|
|
Label: self.c.Tr.DiscardUnstagedChanges,
|
|
OnPress: func() error {
|
|
self.c.LogAction(self.c.Tr.Actions.DiscardAllUnstagedChangesInFile)
|
|
|
|
if self.context().IsSelectingRange() {
|
|
defer self.context().CancelRangeSelect()
|
|
}
|
|
|
|
nodes := lo.Map(selectedNodes, func(n *filetree.FileNode, _ int) git_commands.IFileNode { return n })
|
|
if err := self.c.Git().WorkingTree.DiscardUnstagedDirChanges(nodes); err != nil {
|
|
return err
|
|
}
|
|
|
|
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES, types.WORKTREES}})
|
|
return nil
|
|
},
|
|
Keys: menuKey('u'),
|
|
Tooltip: utils.ResolvePlaceholderString(
|
|
self.c.Tr.DiscardUnstagedTooltip,
|
|
map[string]string{
|
|
"path": self.formattedPaths(selectedNodes),
|
|
},
|
|
),
|
|
}
|
|
|
|
if !someNodesHaveStagedChanges(selectedNodes) || !someNodesHaveUnstagedChanges(selectedNodes) {
|
|
discardUnstagedChangesItem.DisabledReason = &types.DisabledReason{Text: self.c.Tr.DiscardUnstagedDisabled}
|
|
}
|
|
|
|
menuItems := []*types.MenuItem{
|
|
&discardAllChangesItem,
|
|
&discardUnstagedChangesItem,
|
|
}
|
|
|
|
return self.c.Menu(types.CreateMenuOptions{Title: self.c.Tr.DiscardChangesTitle, Items: menuItems})
|
|
}
|
|
|
|
func (self *FilesController) ResetSubmodule(submodule *models.SubmoduleConfig) error {
|
|
file := self.c.Helpers().WorkingTree.FileForSubmodule(submodule)
|
|
return self.c.WithWaitingStatus(self.c.Tr.ResettingSubmoduleStatus, func(gocui.Task) error {
|
|
self.c.LogAction(self.c.Tr.Actions.ResetSubmodule)
|
|
|
|
if file != nil {
|
|
if err := self.c.Git().WorkingTree.UnStageFile(file.Names(), file.Tracked); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if err := self.c.Git().Submodule.Stash(submodule); err != nil {
|
|
return err
|
|
}
|
|
if err := self.c.Git().Submodule.Reset(submodule); err != nil {
|
|
return err
|
|
}
|
|
|
|
self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES, types.SUBMODULES}})
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func (self *FilesController) formattedPaths(nodes []*filetree.FileNode) string {
|
|
return utils.FormatPaths(lo.Map(nodes, func(node *filetree.FileNode, _ int) string {
|
|
return node.GetPath()
|
|
}))
|
|
}
|
|
|
|
func (self *FilesController) isInTreeMode() *types.DisabledReason {
|
|
if !self.context().FileTreeViewModel.InTreeMode() {
|
|
return &types.DisabledReason{Text: self.c.Tr.DisabledInFlatView}
|
|
}
|
|
|
|
return nil
|
|
}
|