jesseduffield.lazygit/pkg/gui/controllers/commits_files_controller.go
Stefan Haller 7f9bfaec08 Warn more prominently about resetting the patch when discarding changes
Only mention resetting the patch when there actually is one. This way users have
to read less text in the normal case, and the added note hopefully stands out
more if there is one. Also, separate the note from the previous text by a blank
line.
2026-03-07 20:47:19 +11:00

584 lines
18 KiB
Go

package controllers
import (
"errors"
"fmt"
"path/filepath"
"strings"
"github.com/jesseduffield/gocui"
"github.com/jesseduffield/lazygit/pkg/commands/git_commands"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/commands/patch"
"github.com/jesseduffield/lazygit/pkg/constants"
"github.com/jesseduffield/lazygit/pkg/gui/context"
"github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers"
"github.com/jesseduffield/lazygit/pkg/gui/filetree"
"github.com/jesseduffield/lazygit/pkg/gui/keybindings"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/samber/lo"
)
type CommitFilesController struct {
baseController
*ListControllerTrait[*filetree.CommitFileNode]
c *ControllerCommon
}
var _ types.IController = &CommitFilesController{}
func NewCommitFilesController(
c *ControllerCommon,
) *CommitFilesController {
return &CommitFilesController{
baseController: baseController{},
c: c,
ListControllerTrait: NewListControllerTrait(
c,
c.Contexts().CommitFiles,
c.Contexts().CommitFiles.GetSelected,
c.Contexts().CommitFiles.GetSelectedItems,
),
}
}
func (self *CommitFilesController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
bindings := []*types.Binding{
{
Key: opts.GetKey(opts.Config.Files.CopyFileInfoToClipboard),
Handler: self.openCopyMenu,
Description: self.c.Tr.CopyToClipboardMenu,
OpensMenu: true,
},
{
Key: opts.GetKey(opts.Config.CommitFiles.CheckoutCommitFile),
Handler: self.withItem(self.checkout),
GetDisabledReason: self.require(self.singleItemSelected()),
Description: self.c.Tr.Checkout,
Tooltip: self.c.Tr.CheckoutCommitFileTooltip,
DisplayOnScreen: true,
},
{
Key: opts.GetKey(opts.Config.Universal.Remove),
Handler: self.withItems(self.discard),
GetDisabledReason: self.require(self.itemsSelected()),
Description: self.c.Tr.Discard,
Tooltip: self.c.Tr.DiscardOldFileChangeTooltip,
DisplayOnScreen: true,
},
{
Key: opts.GetKey(opts.Config.Universal.OpenFile),
Handler: self.withItem(self.open),
GetDisabledReason: self.require(self.singleItemSelected()),
Description: self.c.Tr.OpenFile,
Tooltip: self.c.Tr.OpenFileTooltip,
},
{
Key: opts.GetKey(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,
},
{
Key: opts.GetKey(opts.Config.Universal.OpenDiffTool),
Handler: self.withItem(self.openDiffTool),
GetDisabledReason: self.require(self.singleItemSelected()),
Description: self.c.Tr.OpenDiffTool,
},
{
Key: opts.GetKey(opts.Config.Universal.Select),
Handler: self.withItems(self.toggleForPatch),
GetDisabledReason: self.require(self.itemsSelected()),
Description: self.c.Tr.ToggleAddToPatch,
Tooltip: utils.ResolvePlaceholderString(self.c.Tr.ToggleAddToPatchTooltip,
map[string]string{"doc": constants.Links.Docs.CustomPatchDemo},
),
DisplayOnScreen: true,
},
{
Key: opts.GetKey(opts.Config.Files.ToggleStagedAll),
Handler: self.withItem(self.toggleAllForPatch),
Description: self.c.Tr.ToggleAllInPatch,
Tooltip: utils.ResolvePlaceholderString(self.c.Tr.ToggleAllInPatchTooltip,
map[string]string{"doc": constants.Links.Docs.CustomPatchDemo},
),
},
{
Key: opts.GetKey(opts.Config.Universal.GoInto),
Handler: self.withItem(self.enter),
GetDisabledReason: self.require(self.singleItemSelected()),
Description: self.c.Tr.EnterCommitFile,
Tooltip: self.c.Tr.EnterCommitFileTooltip,
},
{
Key: opts.GetKey(opts.Config.Files.ToggleTreeView),
Handler: self.toggleTreeView,
Description: self.c.Tr.ToggleTreeView,
Tooltip: self.c.Tr.ToggleTreeViewTooltip,
},
{
Key: opts.GetKey(opts.Config.Files.CollapseAll),
Handler: self.collapseAll,
Description: self.c.Tr.CollapseAll,
Tooltip: self.c.Tr.CollapseAllTooltip,
GetDisabledReason: self.require(self.isInTreeMode),
},
{
Key: opts.GetKey(opts.Config.Files.ExpandAll),
Handler: self.expandAll,
Description: self.c.Tr.ExpandAll,
Tooltip: self.c.Tr.ExpandAllTooltip,
GetDisabledReason: self.require(self.isInTreeMode),
},
}
return bindings
}
func (self *CommitFilesController) context() *context.CommitFilesContext {
return self.c.Contexts().CommitFiles
}
func (self *CommitFilesController) GetOnRenderToMain() func() {
return func() {
node := self.context().GetSelected()
if node == nil {
return
}
from, to := self.context().GetFromAndToForDiff()
from, reverse := self.c.Modes().Diffing.GetFromAndReverseArgsForDiff(from)
cmdObj := self.c.Git().WorkingTree.ShowFileDiffCmdObj(from, to, reverse, node.GetPath(), false)
task := types.NewRunPtyTask(cmdObj.GetCmd())
self.c.RenderToMainViews(types.RefreshMainOpts{
Pair: self.c.MainViewPairs().Normal,
Main: &types.ViewUpdateOpts{
Title: self.c.Tr.Patch,
SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(),
Task: task,
},
Secondary: secondaryPatchPanelUpdateOpts(self.c),
})
}
}
func (self *CommitFilesController) copyDiffToClipboard(path string, toastMessage string) error {
from, to := self.context().GetFromAndToForDiff()
from, reverse := self.c.Modes().Diffing.GetFromAndReverseArgsForDiff(from)
cmdObj := self.c.Git().WorkingTree.ShowFileDiffCmdObj(from, to, reverse, path, true)
diff, err := cmdObj.RunWithOutput()
if err != nil {
return err
}
if err := self.c.OS().CopyToClipboard(diff); err != nil {
return err
}
self.c.Toast(toastMessage)
return nil
}
func (self *CommitFilesController) copyFileContentToClipboard(path string) error {
_, to := self.context().GetFromAndToForDiff()
cmdObj := self.c.Git().Commit.ShowFileContentCmdObj(to, path)
diff, err := cmdObj.RunWithOutput()
if err != nil {
return err
}
return self.c.OS().CopyToClipboard(diff)
}
func (self *CommitFilesController) 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())(),
Key: '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())(),
Key: 'p',
}
copyAbsolutePathItem := &types.MenuItem{
Label: self.c.Tr.CopyAbsoluteFilePath,
OnPress: func() error {
if err := self.c.OS().CopyToClipboard(filepath.Join(self.c.Git().RepoPaths.RepoPath(), node.GetPath())); err != nil {
return err
}
self.c.Toast(self.c.Tr.FilePathCopiedToast)
return nil
},
DisabledReason: self.require(self.singleItemSelected())(),
Key: 'P',
}
copyFileDiffItem := &types.MenuItem{
Label: self.c.Tr.CopySelectedDiff,
OnPress: func() error {
return self.copyDiffToClipboard(node.GetPath(), self.c.Tr.FileDiffCopiedToast)
},
DisabledReason: self.require(self.singleItemSelected())(),
Key: 's',
}
copyAllDiff := &types.MenuItem{
Label: self.c.Tr.CopyAllFilesDiff,
OnPress: func() error {
return self.copyDiffToClipboard(".", self.c.Tr.AllFilesDiffCopiedToast)
},
DisabledReason: self.require(self.itemsSelected())(),
Key: 'a',
}
copyFileContentItem := &types.MenuItem{
Label: self.c.Tr.CopyFileContent,
OnPress: func() error {
if err := self.copyFileContentToClipboard(node.GetPath()); err != nil {
return err
}
self.c.Toast(self.c.Tr.FileContentCopiedToast)
return nil
},
DisabledReason: self.require(self.singleItemSelected(
func(node *filetree.CommitFileNode) *types.DisabledReason {
if !node.IsFile() {
return &types.DisabledReason{
Text: self.c.Tr.ErrCannotCopyContentOfDirectory,
ShowErrorInPanel: true,
}
}
return nil
}))(),
Key: 'c',
}
return self.c.Menu(types.CreateMenuOptions{
Title: self.c.Tr.CopyToClipboardMenu,
Items: []*types.MenuItem{
copyNameItem,
copyRelativePathItem,
copyAbsolutePathItem,
copyFileDiffItem,
copyAllDiff,
copyFileContentItem,
},
})
}
func (self *CommitFilesController) checkout(node *filetree.CommitFileNode) error {
hasModifiedFiles := helpers.AnyTrackedFilesInPathExceptSubmodules(node.GetPath(),
self.c.Model().Files, self.c.Model().Submodules)
if hasModifiedFiles {
return errors.New(self.c.Tr.CannotCheckoutWithModifiedFilesErr)
}
self.c.LogAction(self.c.Tr.Actions.CheckoutFile)
_, to := self.context().GetFromAndToForDiff()
if err := self.c.Git().WorkingTree.CheckoutFile(to, node.GetPath()); err != nil {
return err
}
self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC})
return nil
}
func (self *CommitFilesController) discard(selectedNodes []*filetree.CommitFileNode) error {
parentContext := self.c.Context().Current().GetParentContext()
if parentContext == nil || parentContext.GetKey() != context.LOCAL_COMMITS_CONTEXT_KEY {
return errors.New(self.c.Tr.CanOnlyDiscardFromLocalCommits)
}
if ok, err := self.c.Helpers().PatchBuilding.ValidateNormalWorkingTreeState(); !ok {
return err
}
prompt := lo.Ternary(self.c.Git().Patch.PatchBuilder.Active(),
self.c.Tr.DiscardFileChangesPromptResetPatch,
self.c.Tr.DiscardFileChangesPrompt)
self.c.Confirm(types.ConfirmOpts{
Title: self.c.Tr.DiscardFileChangesTitle,
Prompt: prompt,
HandleConfirm: func() error {
return self.c.WithWaitingStatus(self.c.Tr.RebasingStatus, func(gocui.Task) error {
var filePaths []string
selectedNodes = normalisedSelectedCommitFileNodes(selectedNodes)
// Reset the current patch if there is one.
if self.c.Git().Patch.PatchBuilder.Active() {
self.c.Git().Patch.PatchBuilder.Reset()
}
for _, node := range selectedNodes {
_ = node.ForEachFile(func(file *models.CommitFile) error {
filePaths = append(filePaths, file.GetPath())
return nil
})
}
err := self.c.Git().Rebase.DiscardOldFileChanges(self.c.Model().Commits, self.c.Contexts().LocalCommits.GetSelectedLineIdx(), filePaths)
if err := self.c.Helpers().MergeAndRebase.CheckMergeOrRebase(err); err != nil {
return err
}
if self.context().RangeSelectEnabled() {
self.context().GetList().CancelRangeSelect()
}
return nil
})
},
})
return nil
}
func (self *CommitFilesController) open(node *filetree.CommitFileNode) error {
return self.c.Helpers().Files.OpenFile(node.GetPath())
}
func (self *CommitFilesController) edit(nodes []*filetree.CommitFileNode) error {
return self.c.Helpers().Files.EditFiles(lo.FilterMap(nodes,
func(node *filetree.CommitFileNode, _ int) (string, bool) {
return node.GetPath(), node.IsFile()
}))
}
func (self *CommitFilesController) canEditFiles(nodes []*filetree.CommitFileNode) *types.DisabledReason {
if lo.NoneBy(nodes, func(node *filetree.CommitFileNode) bool { return node.IsFile() }) {
return &types.DisabledReason{
Text: self.c.Tr.ErrCannotEditDirectory,
ShowErrorInPanel: true,
}
}
return nil
}
func (self *CommitFilesController) openDiffTool(node *filetree.CommitFileNode) error {
from, to := self.context().GetFromAndToForDiff()
from, reverse := self.c.Modes().Diffing.GetFromAndReverseArgsForDiff(from)
_, err := self.c.RunSubprocess(self.c.Git().Diff.OpenDiffToolCmdObj(
git_commands.DiffToolCmdOptions{
Filepath: node.GetPath(),
FromCommit: from,
ToCommit: to,
Reverse: reverse,
IsDirectory: !node.IsFile(),
Staged: false,
}))
return err
}
func (self *CommitFilesController) toggleForPatch(selectedNodes []*filetree.CommitFileNode) error {
if self.c.UserConfig().Git.DiffContextSize == 0 {
return fmt.Errorf(self.c.Tr.Actions.NotEnoughContextForCustomPatch,
keybindings.Label(self.c.UserConfig().Keybinding.Universal.IncreaseContextInDiffView))
}
toggle := func() error {
return self.c.WithWaitingStatus(self.c.Tr.UpdatingPatch, func(gocui.Task) error {
if !self.c.Git().Patch.PatchBuilder.Active() {
if err := self.startPatchBuilder(); err != nil {
return err
}
}
selectedNodes = normalisedSelectedCommitFileNodes(selectedNodes)
// Find if any file in the selection is unselected or partially added
adding := lo.SomeBy(selectedNodes, func(node *filetree.CommitFileNode) bool {
return node.SomeFile(func(file *models.CommitFile) bool {
fileStatus := self.c.Git().Patch.PatchBuilder.GetFileStatus(file.Path, self.context().GetRef().RefName())
return fileStatus == patch.PART || fileStatus == patch.UNSELECTED
})
})
patchOperationFunction := self.c.Git().Patch.PatchBuilder.RemoveFile
if adding {
patchOperationFunction = self.c.Git().Patch.PatchBuilder.AddFileWhole
}
for _, node := range selectedNodes {
err := node.ForEachFile(func(file *models.CommitFile) error {
return patchOperationFunction(file.Path)
})
if err != nil {
return err
}
}
if self.c.Git().Patch.PatchBuilder.IsEmpty() {
self.c.Git().Patch.PatchBuilder.Reset()
}
self.c.PostRefreshUpdate(self.context())
return nil
})
}
from, to, reverse := self.currentFromToReverseForPatchBuilding()
mustDiscardPatch := self.c.Git().Patch.PatchBuilder.Active() && self.c.Git().Patch.PatchBuilder.NewPatchRequired(from, to, reverse)
return self.c.ConfirmIf(mustDiscardPatch, types.ConfirmOpts{
Title: self.c.Tr.DiscardPatch,
Prompt: self.c.Tr.DiscardPatchConfirm,
HandleConfirm: func() error {
if mustDiscardPatch {
self.c.Git().Patch.PatchBuilder.Reset()
}
return toggle()
},
})
}
func (self *CommitFilesController) toggleAllForPatch(_ *filetree.CommitFileNode) error {
root := self.context().CommitFileTreeViewModel.GetRoot()
return self.toggleForPatch([]*filetree.CommitFileNode{root})
}
func (self *CommitFilesController) startPatchBuilder() error {
commitFilesContext := self.context()
canRebase := commitFilesContext.GetCanRebase()
from, to, reverse := self.currentFromToReverseForPatchBuilding()
self.c.Git().Patch.PatchBuilder.Start(from, to, reverse, canRebase)
return nil
}
func (self *CommitFilesController) currentFromToReverseForPatchBuilding() (string, string, bool) {
commitFilesContext := self.context()
from, to := commitFilesContext.GetFromAndToForDiff()
from, reverse := self.c.Modes().Diffing.GetFromAndReverseArgsForDiff(from)
return from, to, reverse
}
func (self *CommitFilesController) enter(node *filetree.CommitFileNode) error {
return self.enterCommitFile(node, types.OnFocusOpts{ClickedWindowName: "", ClickedViewLineIdx: -1})
}
func (self *CommitFilesController) enterCommitFile(node *filetree.CommitFileNode, opts types.OnFocusOpts) error {
if node.File == nil {
return self.handleToggleCommitFileDirCollapsed(node)
}
if self.c.UserConfig().Git.DiffContextSize == 0 {
return fmt.Errorf(self.c.Tr.Actions.NotEnoughContextForCustomPatch,
keybindings.Label(self.c.UserConfig().Keybinding.Universal.IncreaseContextInDiffView))
}
from, to, reverse := self.currentFromToReverseForPatchBuilding()
mustDiscardPatch := self.c.Git().Patch.PatchBuilder.Active() && self.c.Git().Patch.PatchBuilder.NewPatchRequired(from, to, reverse)
return self.c.ConfirmIf(mustDiscardPatch, types.ConfirmOpts{
Title: self.c.Tr.DiscardPatch,
Prompt: self.c.Tr.DiscardPatchConfirm,
HandleConfirm: func() error {
if mustDiscardPatch {
self.c.Git().Patch.PatchBuilder.Reset()
}
if !self.c.Git().Patch.PatchBuilder.Active() {
if err := self.startPatchBuilder(); err != nil {
return err
}
}
self.c.Context().Push(self.c.Contexts().CustomPatchBuilder, opts)
self.c.Helpers().PatchBuilding.ShowHunkStagingHint()
return nil
},
})
}
func (self *CommitFilesController) handleToggleCommitFileDirCollapsed(node *filetree.CommitFileNode) error {
self.context().CommitFileTreeViewModel.ToggleCollapsed(node.GetInternalPath())
self.c.PostRefreshUpdate(self.context())
return nil
}
// NOTE: this is very similar to handleToggleFileTreeView, could be DRY'd with generics
func (self *CommitFilesController) toggleTreeView() error {
self.context().CommitFileTreeViewModel.ToggleShowTree()
self.c.PostRefreshUpdate(self.context())
return nil
}
func (self *CommitFilesController) collapseAll() error {
self.context().CommitFileTreeViewModel.CollapseAll()
self.c.PostRefreshUpdate(self.context())
return nil
}
func (self *CommitFilesController) expandAll() error {
self.context().CommitFileTreeViewModel.ExpandAll()
self.c.PostRefreshUpdate(self.context())
return nil
}
func (self *CommitFilesController) 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.enterCommitFile(node, types.OnFocusOpts{ClickedWindowName: mainViewName, ClickedViewLineIdx: clickedLineIdx})
}
return nil
}
}
// NOTE: these functions are identical to those in files_controller.go (except for types) and
// could also be cleaned up with some generics
func normalisedSelectedCommitFileNodes(selectedNodes []*filetree.CommitFileNode) []*filetree.CommitFileNode {
return lo.Filter(selectedNodes, func(node *filetree.CommitFileNode, _ int) bool {
return !isDescendentOfSelectedCommitFileNodes(node, selectedNodes)
})
}
func isDescendentOfSelectedCommitFileNodes(node *filetree.CommitFileNode, selectedNodes []*filetree.CommitFileNode) bool {
for _, selectedNode := range selectedNodes {
selectedNodePath := selectedNode.GetPath()
nodePath := node.GetPath()
if strings.HasPrefix(nodePath, selectedNodePath) && nodePath != selectedNodePath {
return true
}
}
return false
}
func (self *CommitFilesController) isInTreeMode() *types.DisabledReason {
if !self.context().CommitFileTreeViewModel.InTreeMode() {
return &types.DisabledReason{Text: self.c.Tr.DisabledInFlatView}
}
return nil
}