mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-09-11 16:16:28 -04:00
The focused-main-view rework made the secondary pane actionable, but the old patch-building explorer's secondary was inert, so its actions were never thought through. Pressing space there routed through the same toggle handler as the main pane, resolving the selection against the secondary's diff and mapping it to patch-builder indices by line number. But the secondary shows the *aggregated* custom patch, which renumbers included additions whenever an earlier addition in the same hunk is excluded (Transform recomputes each hunk's +start). So the shifted number resolved to the wrong line in the original diff — often adding an unrelated line instead of removing the selected one. Resolve the secondary selection by its *ordinal* among the change lines shown instead: the custom-patch view renders exactly the included change lines in order, so the k-th change line of a file is that file's k-th included change line (PatchBuilder.IncludedChangeLineIndices), independent of the renumbering. Space in the secondary now only ever removes, mirroring how space in the staging view's staged pane unstages. Discarding from the commit (the remove key) makes no sense in the custom-patch preview — it would act on lines shown only as the patch, and space already removes them — so it's disabled there. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2108 lines
70 KiB
Go
2108 lines
70 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/commands/patch"
|
|
"github.com/jesseduffield/lazygit/pkg/gocui"
|
|
"github.com/jesseduffield/lazygit/pkg/gui/context"
|
|
"github.com/jesseduffield/lazygit/pkg/gui/filetree"
|
|
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
|
"github.com/jesseduffield/lazygit/pkg/utils"
|
|
"github.com/samber/lo"
|
|
)
|
|
|
|
type FilesController struct {
|
|
baseController
|
|
*ListControllerTrait[*filetree.FileNode]
|
|
c *ControllerCommon
|
|
}
|
|
|
|
var _ types.IController = &FilesController{}
|
|
|
|
func NewFilesController(
|
|
c *ControllerCommon,
|
|
) *FilesController {
|
|
return &FilesController{
|
|
c: c,
|
|
ListControllerTrait: NewListControllerTrait(
|
|
c,
|
|
c.Contexts().Files,
|
|
c.Contexts().Files.GetSelected,
|
|
c.Contexts().Files.GetSelectedItems,
|
|
),
|
|
}
|
|
}
|
|
|
|
func (self *FilesController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
|
|
return []*types.Binding{
|
|
{
|
|
Keys: opts.GetKeys(opts.Config.Universal.Select),
|
|
Handler: self.withItems(self.press),
|
|
GetDisabledReason: self.require(self.itemsSelected(self.canStageSelection)),
|
|
Description: self.c.Tr.Stage,
|
|
Tooltip: self.c.Tr.StageTooltip,
|
|
DisplayOnScreen: true,
|
|
},
|
|
{
|
|
Keys: opts.GetKeys(opts.Config.Files.OpenStatusFilter),
|
|
Handler: self.handleStatusFilterPressed,
|
|
Description: self.c.Tr.FileFilter,
|
|
},
|
|
{
|
|
Keys: opts.GetKeys(opts.Config.Files.CopyFileInfoToClipboard),
|
|
Handler: self.openCopyMenu,
|
|
Description: self.c.Tr.CopyToClipboardMenu,
|
|
OpensMenu: true,
|
|
},
|
|
{
|
|
Keys: opts.GetKeys(opts.Config.Files.CommitChanges),
|
|
Handler: self.c.Helpers().WorkingTree.HandleCommitPress,
|
|
Description: self.c.Tr.Commit,
|
|
Tooltip: self.c.Tr.CommitTooltip,
|
|
DisplayOnScreen: true,
|
|
},
|
|
{
|
|
Keys: opts.GetKeys(opts.Config.Files.CommitChangesWithoutHook),
|
|
Handler: self.c.Helpers().WorkingTree.HandleWIPCommitPress,
|
|
Description: self.c.Tr.CommitChangesWithoutHook,
|
|
},
|
|
{
|
|
Keys: opts.GetKeys(opts.Config.Files.AmendLastCommit),
|
|
Handler: self.handleAmendCommitPress,
|
|
Description: self.c.Tr.AmendLastCommit,
|
|
},
|
|
{
|
|
Keys: opts.GetKeys(opts.Config.Files.CommitChangesWithEditor),
|
|
Handler: self.c.Helpers().WorkingTree.HandleCommitEditorPress,
|
|
Description: self.c.Tr.CommitChangesWithEditor,
|
|
},
|
|
{
|
|
Keys: opts.GetKeys(opts.Config.Files.FindBaseCommitForFixup),
|
|
Handler: self.c.Helpers().FixupHelper.HandleFindBaseCommitForFixupPress,
|
|
Description: self.c.Tr.FindBaseCommitForFixup,
|
|
Tooltip: self.c.Tr.FindBaseCommitForFixupTooltip,
|
|
},
|
|
{
|
|
Keys: opts.GetKeys(opts.Config.Universal.Edit),
|
|
Handler: self.withItems(self.edit),
|
|
GetDisabledReason: self.require(self.itemsSelected(self.canEditFiles)),
|
|
Description: self.c.Tr.Edit,
|
|
Tooltip: self.c.Tr.EditFileTooltip,
|
|
DisplayOnScreen: true,
|
|
},
|
|
{
|
|
Keys: opts.GetKeys(opts.Config.Universal.OpenFile),
|
|
Handler: self.Open,
|
|
GetDisabledReason: self.require(self.singleItemSelected()),
|
|
Description: self.c.Tr.OpenFile,
|
|
Tooltip: self.c.Tr.OpenFileTooltip,
|
|
},
|
|
{
|
|
Keys: opts.GetKeys(opts.Config.Files.IgnoreFile),
|
|
Handler: self.withItem(self.ignoreOrExcludeMenu),
|
|
GetDisabledReason: self.require(self.singleItemSelected()),
|
|
Description: self.c.Tr.Actions.IgnoreExcludeFile,
|
|
OpensMenu: true,
|
|
},
|
|
{
|
|
Keys: opts.GetKeys(opts.Config.Files.RefreshFiles),
|
|
Handler: self.refresh,
|
|
Description: self.c.Tr.RefreshFiles,
|
|
},
|
|
{
|
|
Keys: opts.GetKeys(opts.Config.Files.StashAllChanges),
|
|
Handler: self.stash,
|
|
Description: self.c.Tr.Stash,
|
|
Tooltip: self.c.Tr.StashTooltip,
|
|
DisplayOnScreen: true,
|
|
},
|
|
{
|
|
Keys: opts.GetKeys(opts.Config.Files.ViewStashOptions),
|
|
Handler: self.createStashMenu,
|
|
Description: self.c.Tr.ViewStashOptions,
|
|
Tooltip: self.c.Tr.ViewStashOptionsTooltip,
|
|
OpensMenu: true,
|
|
},
|
|
{
|
|
Keys: opts.GetKeys(opts.Config.Files.ToggleStagedAll),
|
|
Handler: self.toggleStagedAll,
|
|
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()
|
|
|
|
// Default the focused-main-view selection off; the real-diff branch below
|
|
// turns it back on. Every other outcome (no file, merge conflict) renders
|
|
// non-diff content with nothing to select.
|
|
updateFocusedMainViewSelectionVisibility(self.c, false, false)
|
|
|
|
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()}, false)
|
|
prefix := message + "\n\n"
|
|
if node.File.ShortStatus == "DU" {
|
|
prefix += self.c.Tr.MergeConflictIncomingDiff
|
|
} else {
|
|
prefix += self.c.Tr.MergeConflictCurrentDiff
|
|
}
|
|
prefix += "\n\n"
|
|
self.renderToMainWithTask(types.NewRunPtyTaskWithPrefix(cmdObj.GetCmd(), prefix))
|
|
return
|
|
}
|
|
|
|
self.renderToMainWithTask(types.NewRenderStringTask(message))
|
|
}
|
|
|
|
func (self *FilesController) renderWorkingTreeDiff(node *filetree.FileNode) {
|
|
self.c.Helpers().MergeConflicts.ResetMergeState()
|
|
|
|
split, mainShowsStaged := self.diffSplitState(node)
|
|
|
|
// When the focused main view needs to act on a diff the configured pager
|
|
// can't resolve, render it raw (no pager) so it's stageable; browsing keeps
|
|
// the pretty pager output. See StagingHelper.DiffMainViewShouldRenderRaw.
|
|
renderRaw := self.c.Helpers().Staging.DiffMainViewShouldRenderRaw()
|
|
|
|
pathOverrides := self.pathOverridesForDiff(node)
|
|
cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, false, mainShowsStaged, renderRaw, 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.NewMainViewDiffTask(renderRaw, cmdObj.GetCmd()),
|
|
SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(),
|
|
Title: title,
|
|
},
|
|
}
|
|
|
|
if split {
|
|
cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, false, true, renderRaw, 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.NewMainViewDiffTask(renderRaw, cmdObj.GetCmd()),
|
|
}
|
|
}
|
|
|
|
// A real diff is being shown, so the focused main view has something to select:
|
|
// restore the selection on whichever pane is focused (the secondary only when
|
|
// the diff is split across both).
|
|
updateFocusedMainViewSelectionVisibility(self.c, true, split)
|
|
|
|
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) GetFocusedMainViewActions() types.FocusedMainViewActions {
|
|
return self
|
|
}
|
|
|
|
func (self *FilesController) OnClick(mainViewName string, clickedLineIdx int) error {
|
|
// Capture before any mutation below that might re-render the main view.
|
|
snapshot := focusedMainViewSnapshot(self.c, mainViewName, self.context())
|
|
|
|
info, ok := self.c.Helpers().Staging.GetDiffLineInfo(mainViewName, clickedLineIdx)
|
|
line := -1
|
|
isDeletion := false
|
|
if ok {
|
|
line, isDeletion = info.PatchSelectLine()
|
|
}
|
|
|
|
node := self.context().GetSelected()
|
|
if node == nil {
|
|
return nil
|
|
}
|
|
|
|
if !node.IsFile() && ok {
|
|
relativePath, err := filepath.Rel(self.c.Git().RepoPaths.WorktreePath(), info.Path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
relativePath = "./" + relativePath
|
|
self.context().FileTreeViewModel.ExpandToPath(relativePath)
|
|
self.c.PostRefreshUpdate(self.context())
|
|
|
|
idx, ok := self.context().FileTreeViewModel.GetIndexForPath(relativePath)
|
|
if ok {
|
|
self.context().SetSelectedLineIdx(idx)
|
|
self.context().GetViewTrait().FocusPoint(
|
|
self.context().ModelIndexToViewIndex(idx), false)
|
|
}
|
|
}
|
|
|
|
return self.EnterFile(snapshot, types.OnFocusOpts{ClickedWindowName: mainViewName, ClickedViewLineIdx: line, ClickedViewRealLineIdx: line, ClickedViewRealLineIsDeletion: isDeletion, SelectLineInDefaultMode: true})
|
|
}
|
|
|
|
// diffSplitState reports, for the given file node, how the focused main view lays
|
|
// out its diff: whether it's split into unstaged (Normal) and staged
|
|
// (NormalSecondary) halves, and — when not split — whether the single Normal view
|
|
// shows the staged diff (which happens when the file has only staged changes).
|
|
// GetOnRenderToMain and PrimaryAction share this so the staging direction
|
|
// can't drift from what's on screen.
|
|
func (self *FilesController) diffSplitState(node *filetree.FileNode) (split bool, mainShowsStaged bool) {
|
|
split = self.c.UserConfig().Gui.SplitDiff == "always" || (node.GetHasUnstagedChanges() && node.GetHasStagedChanges())
|
|
mainShowsStaged = !split && node.GetHasStagedChanges()
|
|
return split, mainShowsStaged
|
|
}
|
|
|
|
// PrimaryAction stages (or unstages) the selected diff line(s) when space is pressed in
|
|
// the focused main view of the working-tree files panel.
|
|
func (self *FilesController) PrimaryAction(mainViewName string, firstLineIdx int, lastLineIdx int) error {
|
|
if self.c.UserConfig().Git.DiffContextSize == 0 {
|
|
return fmt.Errorf(self.c.Tr.Actions.NotEnoughContextToStage,
|
|
self.c.UserConfig().Keybinding.Universal.IncreaseContextInDiffView)
|
|
}
|
|
|
|
infos, onStagedSide, ok := self.diffLineSelection(mainViewName, firstLineIdx, lastLineIdx)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
|
|
// The whole diff shown in the main view is on one side; space unstages when that's
|
|
// the staged side (reverse), otherwise it stages. Either way it applies to the index.
|
|
return self.applyDiffLineSelection(mainViewName, firstLineIdx, infos, onStagedSide,
|
|
git_commands.ApplyPatchOpts{Reverse: onStagedSide, Cached: true})
|
|
}
|
|
|
|
// DiscardSelectionDisabledReason: discarding from the working tree is always available
|
|
// (a zero-context diff is reported as an error from DiscardSelection itself, matching the
|
|
// staging view).
|
|
// DiscardSelectionDisabledReason ignores which pane the action came from: discarding from
|
|
// the working tree is meaningful in both the unstaged and staged (secondary) panes (the
|
|
// staged side just unstages), unlike the commit panels' custom-patch preview.
|
|
func (self *FilesController) DiscardSelectionDisabledReason(mainViewName string) *types.DisabledReason {
|
|
return nil
|
|
}
|
|
|
|
func (self *FilesController) DiscardSelection(mainViewName string, firstLineIdx int, lastLineIdx int) error {
|
|
if self.c.UserConfig().Git.DiffContextSize == 0 {
|
|
return fmt.Errorf(self.c.Tr.Actions.NotEnoughContextToDiscard,
|
|
self.c.UserConfig().Keybinding.Universal.IncreaseContextInDiffView)
|
|
}
|
|
|
|
infos, onStagedSide, ok := self.diffLineSelection(mainViewName, firstLineIdx, lastLineIdx)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
|
|
// Discarding always reverses the change. On the staged side that's just unstaging
|
|
// (apply reverse to the index) — same as space there — so it follows the identical
|
|
// path and isn't destructive, hence no confirm. On the unstaged side it removes the
|
|
// change from the working tree (reverse, not cached), which is destructive.
|
|
return self.c.ConfirmIf(!onStagedSide && !self.c.UserConfig().Gui.SkipDiscardChangeWarning,
|
|
types.ConfirmOpts{
|
|
Title: self.c.Tr.DiscardChangeTitle,
|
|
Prompt: self.c.Tr.DiscardChangePrompt,
|
|
HandleConfirm: func() error {
|
|
return self.applyDiffLineSelection(mainViewName, firstLineIdx, infos, onStagedSide,
|
|
git_commands.ApplyPatchOpts{Reverse: true, Cached: onStagedSide})
|
|
},
|
|
})
|
|
}
|
|
|
|
// diffLineSelection resolves the change lines the user has selected in the focused main
|
|
// view and which side of the diff they're on. onStagedSide is true when the diff shown
|
|
// is the staged side — the secondary half of a split, or the main half of an only-staged
|
|
// file; it's the same for every file in a multi-file (directory) diff. ok is false when
|
|
// there's no file or the selection covers no change lines (the caller does nothing).
|
|
func (self *FilesController) diffLineSelection(mainViewName string, firstLineIdx int, lastLineIdx int) (infos []types.DiffLineInfo, onStagedSide bool, ok bool) {
|
|
node := self.context().GetSelected()
|
|
if node == nil {
|
|
return nil, false, false
|
|
}
|
|
|
|
infos = self.c.Helpers().Staging.ChangeLinesInViewRange(mainViewName, firstLineIdx, lastLineIdx)
|
|
if len(infos) == 0 {
|
|
return nil, false, false
|
|
}
|
|
|
|
_, mainShowsStaged := self.diffSplitState(node)
|
|
onStagedSide = mainShowsStaged || mainViewName == self.c.Contexts().NormalSecondary.GetViewName()
|
|
return infos, onStagedSide, true
|
|
}
|
|
|
|
// applyDiffLineSelection applies the selected change lines (grouped per file for a
|
|
// multi-file diff) with opts, then re-establishes the selection and focus the way the
|
|
// staging view does — shared by the primary action (stage/unstage) and discard, since
|
|
// discarding on the staged side is just unstaging and must follow the identical path.
|
|
// onStagedSide names which diff each file's patch is read from and drives the focus-
|
|
// follow; opts says how to apply it.
|
|
func (self *FilesController) applyDiffLineSelection(mainViewName string, firstLineIdx int, infos []types.DiffLineInfo, onStagedSide bool, opts git_commands.ApplyPatchOpts) error {
|
|
// A directory diff spans several files; group the selected change lines by
|
|
// file and apply one patch per file.
|
|
infosByFile := lo.GroupBy(infos, func(info types.DiffLineInfo) string { return info.Path })
|
|
|
|
self.c.LogAction(self.c.Tr.Actions.ApplyPatch)
|
|
for path, fileInfos := range infosByFile {
|
|
file := self.fileForDiffLinePath(path)
|
|
if file == nil {
|
|
continue
|
|
}
|
|
if err := self.applyDiffLines(file, fileInfos, onStagedSide, opts); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES, types.STAGING}})
|
|
|
|
// Focus follows the side that was acted on. The unstaged side stays in the main half
|
|
// (which always holds the unstaged side, or the staged side once the file has only
|
|
// staged changes). The staged side lives in the secondary half once the file is split
|
|
// into staged + unstaged, and moves back to the main half when the staged side empties
|
|
// and the split collapses. The model is up to date now (Refresh above is synchronous),
|
|
// so the post-op split is read from the freshly selected node.
|
|
focusViewName := self.c.Contexts().Normal.GetViewName()
|
|
if onStagedSide {
|
|
if node := self.context().GetSelected(); node != nil {
|
|
if split, _ := self.diffSplitState(node); split {
|
|
focusViewName = self.c.Contexts().NormalSecondary.GetViewName()
|
|
}
|
|
}
|
|
}
|
|
|
|
// The Refresh above queued the main-view re-render; re-establish the selection at its
|
|
// change-line ordinal once that render lands, and focus the pane now holding the
|
|
// acted-on side.
|
|
revealSelectionAfterPrimaryAction(self.c, mainViewName, focusViewName, firstLineIdx)
|
|
if focusViewName != mainViewName {
|
|
self.c.Context().Push(mainContextForViewName(self.c, focusViewName), types.OnFocusOpts{})
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// fileForDiffLinePath maps a diff line's absolute file path (as carried by the
|
|
// diff-line metadata) to the working-tree file it belongs to, or nil if it isn't a
|
|
// tracked working-tree file.
|
|
func (self *FilesController) fileForDiffLinePath(path string) *models.File {
|
|
relativePath, err := filepath.Rel(self.c.Git().RepoPaths.WorktreePath(), path)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return self.context().FileTreeViewModel.GetFile(filepath.ToSlash(relativePath))
|
|
}
|
|
|
|
// applyDiffLines applies, to the diff lines identified by infos (a single line, a
|
|
// range, or a hunk) — all belonging to file — a patch built from the file's staged or
|
|
// unstaged diff (sourceCached: the side the selection was made on) and applied per opts:
|
|
// - stage: read unstaged, apply forward to the index (sourceCached=false, Reverse=false, Cached=true)
|
|
// - unstage: read staged, apply reverse to the index (sourceCached=true, Reverse=true, Cached=true)
|
|
// - discard: read the shown side, apply reverse — not cached on the unstaged side
|
|
// removes the change from the working tree, cached on the staged side just unstages it.
|
|
//
|
|
// The read side and the apply direction are independent (they only coincide for
|
|
// staging/unstaging), so they're passed separately: the patch lines are matched in the
|
|
// diff actually shown, then Transform reverses them to match opts. A selection covering
|
|
// no change lines yields an empty patch and is a no-op. The caller logs the action and
|
|
// refreshes, once, around the (possibly several) files it touches.
|
|
//
|
|
// Each selected change line is keyed by its (file line number, deletion?) identity,
|
|
// and the freshly parsed patch is scanned for the body lines matching those
|
|
// identities. Scanning the patch and matching identities — rather than looking each
|
|
// line number up with PatchLineForLineNumber — is what makes a modified line work: a
|
|
// deletion and the addition replacing it share a position but have distinct
|
|
// identities, and the line-number lookup can't tell an addition at the start of a
|
|
// hunk from the deletion above it.
|
|
func (self *FilesController) applyDiffLines(file *models.File, infos []types.DiffLineInfo, sourceCached bool, opts git_commands.ApplyPatchOpts) error {
|
|
parsedPatch := patch.Parse(self.c.Git().WorkingTree.WorktreeFileDiff(file, true, sourceCached))
|
|
|
|
type changeLineKey struct {
|
|
lineNumber int
|
|
isDeletion bool
|
|
}
|
|
selected := make(map[changeLineKey]bool, len(infos))
|
|
for _, info := range infos {
|
|
lineNumber, isDeletion := info.PatchSelectLine()
|
|
selected[changeLineKey{lineNumber, isDeletion}] = true
|
|
}
|
|
|
|
var patchLineIndices []int
|
|
for idx, line := range parsedPatch.Lines() {
|
|
var key changeLineKey
|
|
switch {
|
|
case line.IsAddition():
|
|
key = changeLineKey{parsedPatch.LineNumberOfLine(idx), false}
|
|
case line.IsDeletion():
|
|
key = changeLineKey{parsedPatch.OldLineNumberOfLine(idx), true}
|
|
default:
|
|
continue
|
|
}
|
|
if selected[key] {
|
|
patchLineIndices = append(patchLineIndices, idx)
|
|
}
|
|
}
|
|
|
|
patchToApply := parsedPatch.
|
|
Transform(patch.TransformOpts{
|
|
Reverse: opts.Reverse,
|
|
IncludedLineIndices: patchLineIndices,
|
|
FileNameOverride: file.GetPath(),
|
|
}).
|
|
FormatPlain()
|
|
if patchToApply == "" {
|
|
return nil
|
|
}
|
|
|
|
return self.c.Git().Patch.ApplyPatch(patchToApply, opts)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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(nil, types.OnFocusOpts{ClickedWindowName: "", ClickedViewLineIdx: -1, ClickedViewRealLineIdx: -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
|
|
}
|
|
|
|
// focusedMainViewSnapshot records the focused main view to return to when
|
|
// escaping the staging view, for the case where we're entering it straight from
|
|
// there; it's nil for the normal flow that goes through the files panel. See
|
|
// types.FocusedMainViewSnapshot.
|
|
func (self *FilesController) EnterFile(focusedMainViewSnapshot *types.FocusedMainViewSnapshot, opts types.OnFocusOpts) error {
|
|
node := self.context().GetSelected()
|
|
if node == nil {
|
|
return nil
|
|
}
|
|
|
|
if node.File == nil {
|
|
return self.handleToggleDirCollapsed()
|
|
}
|
|
|
|
file := node.File
|
|
|
|
if self.conflictNeedsResolutionDialog(file) {
|
|
return self.openConflictResolutionMenu(file)
|
|
}
|
|
|
|
submoduleConfigs := self.c.Model().Submodules
|
|
if file.IsSubmodule(submoduleConfigs) {
|
|
submoduleConfig := file.SubmoduleConfig(submoduleConfigs)
|
|
return self.c.Helpers().Repos.EnterSubmodule(submoduleConfig)
|
|
}
|
|
|
|
if file.HasInlineMergeConflicts {
|
|
return self.switchToMerge()
|
|
}
|
|
|
|
context := lo.Ternary(opts.ClickedWindowName == "secondary", self.c.Contexts().StagingSecondary, self.c.Contexts().Staging)
|
|
// Record the focused-main-view return on *both* staging halves, not just the
|
|
// one we're entering: staging the last unstaged hunk (or tabbing) moves the
|
|
// selection to the other half, and escaping from there must still know to
|
|
// return to the focused main view rather than falling back to the files panel.
|
|
// Set on every entry (nil for a normal entry through the files panel) so a
|
|
// snapshot can't leak from a previous main-view entry into a subsequent normal one.
|
|
self.c.Contexts().Staging.SetFocusedMainViewSnapshot(focusedMainViewSnapshot)
|
|
self.c.Contexts().StagingSecondary.SetFocusedMainViewSnapshot(focusedMainViewSnapshot)
|
|
self.c.Context().Push(context, opts)
|
|
self.c.Helpers().PatchBuilding.ShowHunkStagingHint()
|
|
|
|
return nil
|
|
}
|
|
|
|
// conflictResolutionHint formats a conflict description for the main view,
|
|
// appending the "press <enter> to resolve" hint and wrapping it when the view is
|
|
// wide enough that long lines would otherwise hurt readability.
|
|
func (self *FilesController) conflictResolutionHint(description string) string {
|
|
message := description + "\n\n" + fmt.Sprintf(self.c.Tr.MergeConflictPressEnterToResolve,
|
|
self.c.UserConfig().Keybinding.Universal.GoInto)
|
|
if self.c.Views().Main.InnerWidth() > 70 {
|
|
lines, _, _ := utils.WrapViewLinesToWidth(true, false, message, 70, 4)
|
|
message = strings.Join(lines, "\n")
|
|
}
|
|
return message
|
|
}
|
|
|
|
// conflictNeedsResolutionDialog reports whether a file's merge conflict can only
|
|
// be resolved through a dialog that picks one side, as opposed to editing
|
|
// conflict markers in the merge view. These are the "non-textual" conflicts:
|
|
// text files where one side modified and the other deleted/renamed the file
|
|
// (DD/AU/UA/UD/DU), and submodules where both sides moved the gitlink (UU).
|
|
func (self *FilesController) conflictNeedsResolutionDialog(file *models.File) bool {
|
|
if file == nil || !file.HasMergeConflicts {
|
|
return false
|
|
}
|
|
|
|
// A conflicted submodule has no conflict markers to edit; it's resolved by
|
|
// picking which commit to point at.
|
|
if file.IsSubmodule(self.c.Model().Submodules) {
|
|
return true
|
|
}
|
|
|
|
return !file.HasInlineMergeConflicts
|
|
}
|
|
|
|
// canStageSelection disables staging when a multiple selection includes a file
|
|
// with a conflict that must be resolved through a dialog; those have to be
|
|
// resolved one at a time.
|
|
func (self *FilesController) canStageSelection(nodes []*filetree.FileNode) *types.DisabledReason {
|
|
if len(nodes) > 1 {
|
|
for _, node := range nodes {
|
|
if node.SomeFile(self.conflictNeedsResolutionDialog) {
|
|
return &types.DisabledReason{
|
|
Text: utils.ResolvePlaceholderString(
|
|
self.c.Tr.StageConflictsRangeDisabled, map[string]string{
|
|
"goIntoKey": self.c.UserConfig().Keybinding.Universal.GoInto.String(),
|
|
},
|
|
),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// isSubmoduleCommitConflict reports whether the file is a submodule whose commit
|
|
// pointer conflicts (status UU or AA): both sides recorded a different commit,
|
|
// with no base content to merge. These are resolved by picking one side's
|
|
// commit. Other submodule conflicts (e.g. modify/delete) are handled like
|
|
// ordinary non-textual conflicts, with the keep/delete picker.
|
|
func (self *FilesController) isSubmoduleCommitConflict(file *models.File) bool {
|
|
return file != nil && file.HasInlineMergeConflicts && file.IsSubmodule(self.c.Model().Submodules)
|
|
}
|
|
|
|
func (self *FilesController) openConflictResolutionMenu(file *models.File) error {
|
|
if self.isSubmoduleCommitConflict(file) {
|
|
return self.openSubmoduleConflictMenu(file)
|
|
}
|
|
|
|
return self.openFileConflictMenu(file)
|
|
}
|
|
|
|
func (self *FilesController) openFileConflictMenu(file *models.File) error {
|
|
handle := func(command func(command string) error, logText string) error {
|
|
self.c.LogAction(logText)
|
|
if err := command(file.GetPath()); err != nil {
|
|
return err
|
|
}
|
|
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}})
|
|
return nil
|
|
}
|
|
keepItem := &types.MenuItem{
|
|
Label: self.c.Tr.MergeConflictKeepFile,
|
|
OnPress: func() error {
|
|
return handle(self.c.Git().WorkingTree.StageFile, self.c.Tr.Actions.ResolveConflictByKeepingFile)
|
|
},
|
|
Keys: menuKey('k'),
|
|
}
|
|
deleteItem := &types.MenuItem{
|
|
Label: self.c.Tr.MergeConflictDeleteFile,
|
|
OnPress: func() error {
|
|
return handle(self.c.Git().WorkingTree.RemoveConflictedFile, self.c.Tr.Actions.ResolveConflictByDeletingFile)
|
|
},
|
|
Keys: menuKey('d'),
|
|
}
|
|
items := []*types.MenuItem{}
|
|
switch file.ShortStatus {
|
|
case "DD":
|
|
// For "both deleted" conflicts, deleting the file is the only reasonable thing you can do.
|
|
// Restoring to the state before deletion is not the responsibility of a conflict resolution tool.
|
|
items = append(items, deleteItem)
|
|
case "DU", "UD":
|
|
// For these, we put the delete option first because it's the most common one,
|
|
// even if it's more destructive.
|
|
items = append(items, deleteItem, keepItem)
|
|
case "AU", "UA":
|
|
// For these, we put the keep option first because it's less destructive,
|
|
// and the chances between keep and delete are 50/50.
|
|
items = append(items, keepItem, deleteItem)
|
|
default:
|
|
panic("should only be called if there's a merge conflict")
|
|
}
|
|
return self.c.Menu(types.CreateMenuOptions{
|
|
Title: self.c.Tr.MergeConflictsTitle,
|
|
Prompt: file.GetMergeStateDescription(self.c.Tr),
|
|
Items: items,
|
|
})
|
|
}
|
|
|
|
func (self *FilesController) openSubmoduleConflictMenu(file *models.File) error {
|
|
path := file.GetPath()
|
|
_, ours, theirs, err := self.c.Git().Submodule.GetConflictCommits(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
resolve := func(sha string, logAction string) error {
|
|
self.c.LogAction(logAction)
|
|
if err := self.c.Git().Submodule.CheckoutConflictCommit(path, sha); err != nil {
|
|
return err
|
|
}
|
|
if err := self.c.Git().WorkingTree.StageFile(path); err != nil {
|
|
return err
|
|
}
|
|
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}})
|
|
return nil
|
|
}
|
|
|
|
// Append the commit summary to the label so the user can tell the two
|
|
// candidates apart, falling back to the bare label if we can't read it.
|
|
label := func(text string, sha string) string {
|
|
if summary, err := self.c.Git().Submodule.GetCommitSummary(path, sha); err == nil && summary != "" {
|
|
return fmt.Sprintf("%s (%s)", text, summary)
|
|
}
|
|
return text
|
|
}
|
|
|
|
return self.c.Menu(types.CreateMenuOptions{
|
|
Title: self.c.Tr.MergeConflictsTitle,
|
|
Prompt: utils.ResolvePlaceholderString(self.c.Tr.SubmoduleMergeConflictDescription, map[string]string{"path": path}),
|
|
Items: []*types.MenuItem{
|
|
{
|
|
Label: label(self.c.Tr.MergeConflictTakeCurrentCommit, ours),
|
|
OnPress: func() error { return resolve(ours, self.c.Tr.Actions.TakeCurrentSubmoduleCommit) },
|
|
Keys: menuKey('c'),
|
|
},
|
|
{
|
|
Label: label(self.c.Tr.MergeConflictTakeIncomingCommit, theirs),
|
|
OnPress: func() error { return resolve(theirs, self.c.Tr.Actions.TakeIncomingSubmoduleCommit) },
|
|
Keys: menuKey('i'),
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
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(nil, 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
|
|
}
|