Files
lazygit/pkg/gui/controllers/files_controller.go

1837 lines
58 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
// what this panel offers on the diff it shows in the focused main view
diffActions *WorkingTreeDiffActions
}
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,
),
diffActions: NewWorkingTreeDiffActions(c),
}
}
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()}, git_commands.DiffModeRendered)
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()
// The unstaged side of a file's diff is shown in the main pane and the staged side
// in the secondary one, each only where there is a side to show — so a side is
// always in the same place, whatever the file happens to have. A file with nothing
// unstaged therefore shows its staged changes in the secondary pane, which then has
// the whole section to itself. Configured to always split, both panes are shown
// whether or not there is anything on either side.
alwaysSplit := self.c.UserConfig().Gui.SplitDiff == "always"
showStaged := node.GetHasStagedChanges() || alwaysSplit
showUnstaged := node.GetHasUnstagedChanges() || alwaysSplit || !showStaged
// While the main view is focused to act on this diff, it may have to be git's own
// rather than the diff renderer's; both panes have to agree about that.
mode := self.c.Helpers().DiffLine.MainViewDiffMode()
paths := self.pathsForDiff(node)
refreshOpts := types.RefreshMainOpts{Pair: self.c.MainViewPairs().Normal}
if showUnstaged {
cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, mode, false, paths)
refreshOpts.Main = &types.ViewUpdateOpts{
Task: types.NewMainViewDiffTask(cmdObj.GetCmd(), mode),
SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(),
Title: self.c.Tr.UnstagedChanges,
NothingToActOn: !node.GetHasUnstagedChanges(),
}
}
if showStaged {
cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, mode, true, paths)
refreshOpts.Secondary = &types.ViewUpdateOpts{
Task: types.NewMainViewDiffTask(cmdObj.GetCmd(), mode),
SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(),
Title: self.c.Tr.StagedChanges,
NothingToActOn: !node.GetHasStagedChanges(),
}
}
self.c.RenderToMainViews(refreshOpts)
}
func (self *FilesController) GetFocusedMainViewDiffSource() types.FocusedMainViewDiffSource {
return self.diffActions
}
func (self *FilesController) GetOnDoubleClick() func() error {
return self.enter
}
// 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()
}
return focusMainView(self.c, self.context(), opts.ClickedViewLineIdx)
}
// 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
}