mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-09-10 15:46:26 -04:00
We set GIT_OPTIONAL_LOCKS=0 for every git command we run. That env var only affects `git status`: it tells git not to take the optional lock it would otherwise use to write the index back after refreshing the cached stat information. The intent was to avoid contending for index.lock with git commands the user runs in a terminal. The downside is that our `git status` never persists the refreshed stat-cache. So whenever the working tree's cached stat info goes stale (e.g. editing files and discarding the changes, or a checkout), every subsequent status re-hashes the affected files to confirm they're clean, and stays slow until something else writes the index (such as the user running `git status` in a terminal). Fix this by only suppressing optional locks for refreshes that run unattended in the background; foreground refreshes triggered by a user action now run a plain `git status` that writes the refreshed index back, just like the command line does. Background refreshes keep passing --no-optional-locks so they still can't cause lock contention. RefreshOptions gains a Background flag that the background routines set, threaded down to the status command. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1666 lines
51 KiB
Go
1666 lines
51 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.withFileTreeViewModelMutex(self.itemsSelected())),
|
|
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.withFileTreeViewModelMutex(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,
|
|
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.withFileTreeViewModelMutex(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.withFileTreeViewModelMutex(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) withFileTreeViewModelMutex(callback func() *types.DisabledReason) func() *types.DisabledReason {
|
|
return func() *types.DisabledReason {
|
|
self.c.Contexts().Files.FileTreeViewModel.RWMutex.RLock()
|
|
defer self.c.Contexts().Files.FileTreeViewModel.RWMutex.RUnlock()
|
|
|
|
return callback()
|
|
}
|
|
}
|
|
|
|
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.c.RenderToMainViews(types.RefreshMainOpts{
|
|
Pair: self.c.MainViewPairs().Normal,
|
|
Main: &types.ViewUpdateOpts{
|
|
Title: self.c.Tr.DiffTitle,
|
|
SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(),
|
|
Task: types.NewRenderStringTask(self.c.Tr.NoChangedFiles),
|
|
},
|
|
})
|
|
return
|
|
}
|
|
|
|
if node.File != nil && node.File.HasInlineMergeConflicts {
|
|
hasConflicts, err := self.c.Helpers().MergeConflicts.SetMergeState(node.GetPath())
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
if hasConflicts {
|
|
self.c.Helpers().MergeConflicts.Render()
|
|
return
|
|
}
|
|
} else if node.File != nil && node.File.HasMergeConflicts {
|
|
opts := types.RefreshMainOpts{
|
|
Pair: self.c.MainViewPairs().Normal,
|
|
Main: &types.ViewUpdateOpts{
|
|
Title: self.c.Tr.DiffTitle,
|
|
SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(),
|
|
},
|
|
}
|
|
message := node.File.GetMergeStateDescription(self.c.Tr)
|
|
message += "\n\n" + fmt.Sprintf(self.c.Tr.MergeConflictPressEnterToResolve,
|
|
self.c.UserConfig().Keybinding.Universal.GoInto)
|
|
if self.c.Views().Main.InnerWidth() > 70 {
|
|
// If the main view is very wide, wrap the message to increase readability
|
|
lines, _, _ := utils.WrapViewLinesToWidth(true, false, message, 70, 4)
|
|
message = strings.Join(lines, "\n")
|
|
}
|
|
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"
|
|
opts.Main.Task = types.NewRunPtyTaskWithPrefix(cmdObj.GetCmd(), prefix)
|
|
} else {
|
|
opts.Main.Task = types.NewRenderStringTask(message)
|
|
}
|
|
self.c.RenderToMainViews(opts)
|
|
return
|
|
}
|
|
|
|
self.c.Helpers().MergeConflicts.ResetMergeState()
|
|
|
|
split := self.c.UserConfig().Gui.SplitDiff == "always" || (node.GetHasUnstagedChanges() && node.GetHasStagedChanges())
|
|
mainShowsStaged := !split && node.GetHasStagedChanges()
|
|
|
|
pathOverrides := self.pathOverridesForDiff(node)
|
|
cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, false, mainShowsStaged, pathOverrides)
|
|
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, pathOverrides)
|
|
|
|
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 {
|
|
// Obtaining this lock because optimistic rendering requires us to mutate
|
|
// the files in our model.
|
|
self.c.Mutexes().RefreshingFilesMutex.Lock()
|
|
defer self.c.Mutexes().RefreshingFilesMutex.Unlock()
|
|
|
|
// 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 {
|
|
if err := self.pressWithLock(nodes); err != nil {
|
|
return err
|
|
}
|
|
|
|
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}, Mode: types.ASYNC})
|
|
|
|
self.context().HandleFocus(types.OnFocusOpts{})
|
|
return nil
|
|
}
|
|
|
|
// pathOverridesForDiff returns file paths to override the node's path in diff
|
|
// commands when a text filter is active and the node is a directory. This
|
|
// ensures the diff only shows filtered/visible files.
|
|
func (self *FilesController) pathOverridesForDiff(node *filetree.FileNode) []string {
|
|
if !node.IsFile() && self.context().IsFiltering() {
|
|
var paths []string
|
|
_ = node.ForEachFile(func(file *models.File) error {
|
|
paths = append(paths, file.Path)
|
|
return nil
|
|
})
|
|
return paths
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// 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
|
|
|
|
submoduleConfigs := self.c.Model().Submodules
|
|
if file.IsSubmodule(submoduleConfigs) {
|
|
submoduleConfig := file.SubmoduleConfig(submoduleConfigs)
|
|
return self.c.Helpers().Repos.EnterSubmodule(submoduleConfig)
|
|
}
|
|
|
|
if file.HasInlineMergeConflicts {
|
|
return self.switchToMerge()
|
|
}
|
|
if file.HasMergeConflicts {
|
|
return self.handleNonInlineConflict(file)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
func (self *FilesController) handleNonInlineConflict(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) toggleStagedAll() error {
|
|
if err := self.toggleStagedAllWithLock(); err != nil {
|
|
return err
|
|
}
|
|
|
|
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}, Mode: types.ASYNC})
|
|
|
|
self.context().HandleFocus(types.OnFocusOpts{})
|
|
return nil
|
|
}
|
|
|
|
func (self *FilesController) toggleStagedAllWithLock() error {
|
|
self.c.Mutexes().RefreshingFilesMutex.Lock()
|
|
defer self.c.Mutexes().RefreshingFilesMutex.Unlock()
|
|
|
|
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}, Mode: types.ASYNC})
|
|
} 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.Path)
|
|
}
|
|
|
|
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 {
|
|
self.c.LogAction(action)
|
|
|
|
if err := stashFunc(stashComment); err != nil {
|
|
return err
|
|
}
|
|
self.c.Refresh(types.RefreshOptions{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 {
|
|
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)
|
|
})
|
|
}
|
|
|
|
// 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)
|
|
})
|
|
}
|
|
|
|
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{Mode: types.ASYNC, 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{Mode: types.ASYNC, 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 {
|
|
return self.c.WithWaitingStatus(self.c.Tr.ResettingSubmoduleStatus, func(gocui.Task) error {
|
|
self.c.LogAction(self.c.Tr.Actions.ResetSubmodule)
|
|
|
|
file := self.c.Helpers().WorkingTree.FileForSubmodule(submodule)
|
|
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.Refresh(types.RefreshOptions{Mode: types.ASYNC, 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
|
|
}
|