jesseduffield.lazygit/pkg/gui/controllers/commits_files_controller.go
Stefan Haller 67ba1899b4 Build a custom patch from the focused main view of a commit's files
Pressing space in the commit-files focused main view now toggles the
selected line(s)/hunk into or out of the custom patch, instead of being a
no-op. It's the patch-building counterpart of working-tree staging from
the main view: the selection is resolved to change-line identities the
same way (ChangeLinesInViewRange), then mapped to the patch builder's
per-file line indices and added or removed.

The crux is that the commit's diff is unchanged by a toggle — only the
inclusion set changes — so unlike staging there's no async re-render to
ride. Membership is shown by an on-demand inclusion gutter: a reserved
left column painted with a marker on every change line currently in the
patch, recomputed synchronously after each toggle and on focusing the
main view, over the existing pager output. This is what lets patch
building work over a restructuring pager at all, where the old green
first-char overlay (which rewrites the rendered bytes) can't.

space routes to staging or patch building based on which handler the
panel beneath registers, via a new onTogglePatchFocusedMainView channel
(kept separate from staging because the post-action differs: sync gutter
repaint vs async reveal). Scoped to the commit-files panel for now;
commits/sub-commits/stash and the whole-commit multi-file diff follow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00

806 lines
27 KiB
Go

package controllers
import (
"errors"
"fmt"
"path/filepath"
"strings"
"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/gocui"
"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/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{
{
Keys: opts.GetKeys(opts.Config.Files.CopyFileInfoToClipboard),
Handler: self.openCopyMenu,
Description: self.c.Tr.CopyToClipboardMenu,
OpensMenu: true,
},
{
Keys: opts.GetKeys(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,
},
{
Keys: opts.GetKeys(opts.Config.Universal.Remove),
Handler: self.withItems(self.discard),
GetDisabledReason: self.require(self.itemsSelected(self.canDiscardFileChanges)),
Description: self.c.Tr.Discard,
Tooltip: self.c.Tr.DiscardOldFileChangeTooltip,
DisplayOnScreen: true,
},
{
Keys: opts.GetKeys(opts.Config.Universal.OpenFile),
Handler: self.withItem(self.open),
GetDisabledReason: self.require(self.singleItemSelected()),
Description: self.c.Tr.OpenFile,
Tooltip: self.c.Tr.OpenFileTooltip,
},
{
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.OpenDiffTool),
Handler: self.withItem(self.openDiffTool),
GetDisabledReason: self.require(self.singleItemSelected()),
Description: self.c.Tr.OpenDiffTool,
},
{
Keys: opts.GetKeys(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,
},
{
Keys: opts.GetKeys(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},
),
},
{
Keys: opts.GetKeys(opts.Config.Universal.GoInto),
Handler: self.withItem(self.enter),
GetDisabledReason: self.require(self.singleItemSelected()),
Description: self.c.Tr.EnterCommitFile,
Tooltip: self.c.Tr.EnterCommitFileTooltip,
},
{
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.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),
},
}
return bindings
}
func (self *CommitFilesController) context() *context.CommitFilesContext {
return self.c.Contexts().CommitFiles
}
func (self *CommitFilesController) GetOnClick() func(opts gocui.ViewMouseBindingOpts) error {
return func(opts gocui.ViewMouseBindingOpts) error {
clickedIdx := self.context().GetSelectedLineIdx()
node := self.context().CommitFileTreeViewModel.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().CommitFileTreeViewModel.GetVisualDepth(clickedIdx)
arrowStartCol := visualDepth * 2
arrowEndCol := arrowStartCol + 1
if opts.X < arrowStartCol || opts.X > arrowEndCol {
return nil
}
self.context().CommitFileTreeViewModel.ToggleCollapsed(node.GetInternalPath())
self.c.PostRefreshUpdate(self.context())
return nil
}
}
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)
paths := self.pathsForDiff(node)
cmdObj := self.c.Git().WorkingTree.ShowFileDiffCmdObj(from, to, reverse, paths, false)
task := types.NewRunPtyTask(cmdObj.GetCmd())
// Keep the inclusion gutter in step with the content as this diff (re-)renders.
// It's a no-op unless the main view is focused and a patch is being built (see
// RefreshInclusionGutter); a patch toggle re-renders this same diff, so the marks
// recomputed over the current content stay valid through the swap.
self.c.Helpers().Staging.RefreshInclusionGutter()
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(paths []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, paths, 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())(),
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,
OnPress: func() error {
return self.copyDiffToClipboard(self.pathsForDiff(node), self.c.Tr.FileDiffCopiedToast)
},
DisabledReason: self.require(self.singleItemSelected())(),
Keys: menuKey('s'),
}
copyAllDiff := &types.MenuItem{
Label: self.c.Tr.CopyAllFilesDiff,
OnPress: func() error {
return self.copyDiffToClipboard([]string{"."}, self.c.Tr.AllFilesDiffCopiedToast)
},
DisabledReason: self.require(self.itemsSelected())(),
Keys: menuKey('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
}))(),
Keys: menuKey('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{})
return nil
}
func (self *CommitFilesController) discard(selectedNodes []*filetree.CommitFileNode) error {
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 {
commits := self.c.Model().Commits
selectedLineIdx := self.c.Contexts().LocalCommits.GetSelectedLineIdx()
return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{
Message: self.c.Tr.RebasingStatus,
HideWorkingTreeState: true,
}, 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 {
// For a rename we discard both the new and the old path,
// so that the new file is removed and the old one is
// restored.
filePaths = append(filePaths, file.Names()...)
return nil
})
}
err := self.c.Git().Rebase.DiscardOldFileChanges(commits, selectedLineIdx, filePaths)
if err := self.c.Helpers().MergeAndRebase.CheckMergeOrRebase(err); err != nil {
return err
}
self.c.OnUIThread(func() error {
if self.context().RangeSelectEnabled() {
self.context().GetList().CancelRangeSelect()
}
return nil
})
return nil
})
},
})
return nil
}
func (self *CommitFilesController) canDiscardFileChanges(nodes []*filetree.CommitFileNode) *types.DisabledReason {
parentContext := self.c.Context().Current().GetParentContext()
if parentContext == nil || parentContext.GetKey() != context.LOCAL_COMMITS_CONTEXT_KEY {
return &types.DisabledReason{
Text: self.c.Tr.CanOnlyDiscardFromLocalCommits,
ShowErrorInPanel: true,
}
}
if self.c.Contexts().LocalCommits.AreMultipleItemsSelected() {
return &types.DisabledReason{
Text: self.c.Tr.CannotDiscardFromMultipleCommits,
ShowErrorInPanel: true,
}
}
if self.c.Git().Status.WorkingTreeState().Any() {
return &types.DisabledReason{
Text: self.c.Tr.CantPatchWhileRebasingError,
ShowErrorInPanel: true,
}
}
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,
self.c.UserConfig().Keybinding.Universal.IncreaseContextInDiffView)
}
refName := self.context().GetRef().RefName()
toggle := func() error {
return self.c.WithWaitingStatus(self.c.Tr.UpdatingPatch, func(gocui.Task) error {
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, 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, file.PreviousPath)
})
if err != nil {
return err
}
}
if self.c.Git().Patch.PatchBuilder.IsEmpty() {
self.c.Git().Patch.PatchBuilder.Reset()
}
self.c.OnUIThread(func() error {
self.c.PostRefreshUpdate(self.context())
return nil
})
return nil
})
}
from, to, reverse := self.c.Helpers().CommitFiles.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.c.Helpers().CommitFiles.StartPatchBuilder(); err != nil {
return err
}
}
return toggle()
},
})
}
func (self *CommitFilesController) toggleAllForPatch(_ *filetree.CommitFileNode) error {
root := self.context().CommitFileTreeViewModel.GetRoot()
return self.toggleForPatch([]*filetree.CommitFileNode{root})
}
func (self *CommitFilesController) enter(node *filetree.CommitFileNode) error {
return self.c.Helpers().CommitFiles.EnterCommitFile(node, nil, types.OnFocusOpts{ClickedWindowName: "", ClickedViewLineIdx: -1, ClickedViewRealLineIdx: -1})
}
// 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 {
// 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.getSelectedItem()
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().CommitFileTreeViewModel.ExpandToPath(relativePath)
self.c.PostRefreshUpdate(self.context())
idx, ok := self.context().CommitFileTreeViewModel.GetIndexForPath(relativePath)
if ok {
self.context().SetSelectedLineIdx(idx)
self.context().GetViewTrait().FocusPoint(
self.context().ModelIndexToViewIndex(idx), false)
node = self.context().GetSelected()
}
}
// Entered from the focused main view, so escaping returns there.
return self.c.Helpers().CommitFiles.EnterCommitFile(node, snapshot, types.OnFocusOpts{ClickedWindowName: "main", ClickedViewLineIdx: line, ClickedViewRealLineIdx: line, ClickedViewRealLineIsDeletion: isDeletion, SelectLineInDefaultMode: true})
}
}
// GetOnTogglePatchFocusedMainView toggles the selected diff line(s) into or out of the
// custom patch when space is pressed in the focused main view of a commit's files. It
// is the patch-building counterpart of the files panel's staging handler: it resolves
// the selection to change-line identities (shared with staging), maps them to the patch
// builder's per-file line indices, and adds or removes them. The commit's diff is
// unchanged by the toggle, but it refreshes so the browser indicator and the secondary
// patch view update (and the layout splits); the selection is re-established afterwards
// since that re-render (and its layout re-wrap) is in view-line space.
func (self *CommitFilesController) GetOnTogglePatchFocusedMainView() func(mainViewName string, firstLineIdx int, lastLineIdx int) error {
return func(mainViewName string, firstLineIdx int, lastLineIdx int) error {
infos := self.c.Helpers().Staging.ChangeLinesInViewRange(mainViewName, firstLineIdx, lastLineIdx)
if len(infos) == 0 {
return nil
}
// Building a patch needs the patch builder started for this commit; if a patch
// for a different commit is active, confirm before discarding it (as entering
// the patch builder from the commit files panel does).
from, to, reverse := self.c.Helpers().CommitFiles.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.c.Helpers().CommitFiles.StartPatchBuilder(); err != nil {
return err
}
}
if err := self.togglePatchLines(infos); err != nil {
return err
}
// Refresh normally: this updates the file's patch-status indicator in
// the browser and the cumulative patch in the secondary view (splitting
// the layout when the patch first becomes non-empty), and re-renders this
// diff. The diff is unchanged, so the re-render keeps its scroll (same
// command) and the inclusion gutter is recomputed over it (see
// GetOnRenderToMain).
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.COMMIT_FILES}})
// The re-render — and the layout re-wrap when the secondary view first
// appears — is in view-line space, which moves the selection. Re-select
// the same content by its (unchanged, width-independent) change-line
// ordinal once the re-render lands.
self.revealSelectionAfterPatchToggle(mainViewName, firstLineIdx)
return nil
},
})
}
}
// togglePatchLines toggles the change lines identified by infos into or out of the
// custom patch. The direction is decided once, from the first selected change line (in
// view order) — already in the patch means remove the whole selection, otherwise add it
// — and applied to every file the selection spans, mirroring how the patch explorer
// toggles a range.
func (self *CommitFilesController) togglePatchLines(infos []types.DiffLineInfo) error {
patchBuilder := self.c.Git().Patch.PatchBuilder
// Group the selected change lines by file (in view order), then resolve each file's
// identities to patch-line indices in one pass.
var order []string
identitiesByFile := map[string][]patch.LineIdentity{}
for _, info := range infos {
filename := self.patchFilename(info.Path)
if filename == "" {
continue
}
if _, seen := identitiesByFile[filename]; !seen {
order = append(order, filename)
}
lineNumber, isDeletion := info.PatchSelectLine()
identitiesByFile[filename] = append(identitiesByFile[filename],
patch.LineIdentity{LineNumber: lineNumber, IsDeletion: isDeletion})
}
if len(order) == 0 {
return nil
}
indicesByFile := map[string][]int{}
for filename, identities := range identitiesByFile {
indices, err := patchBuilder.PatchLineIndicesForLines(filename, identities)
if err != nil {
return err
}
indicesByFile[filename] = indices
}
// Decide the direction from the first selected change line.
firstFile := order[0]
included, err := patchBuilder.GetFileIncLineIndices(firstFile, "")
if err != nil {
return err
}
remove := len(indicesByFile[firstFile]) > 0 && lo.Contains(included, indicesByFile[firstFile][0])
toggle := patchBuilder.AddFileLineRange
if remove {
toggle = patchBuilder.RemoveFileLineRange
}
for _, filename := range order {
indices := indicesByFile[filename]
if len(indices) == 0 {
continue
}
if err := toggle(filename, "", indices); err != nil {
return err
}
}
return nil
}
// revealSelectionAfterPatchToggle re-establishes the selection after a toggle's
// re-render. Toggling doesn't change the diff, so the same content is still there — but
// the re-render (and the layout re-wrap when the secondary view first appears) is in
// view-line space, which moves the selection. We preserve the selection's change-line
// ordinal, which is unchanged (the line isn't consumed, unlike staging) and
// width-independent, re-expanding the hunk in hunk mode. A range collapses to a line,
// as a staged range does.
func (self *CommitFilesController) revealSelectionAfterPatchToggle(mainViewName string, firstLineIdx int) {
mainContext := self.c.Contexts().Normal
if mainViewName == self.c.Contexts().NormalSecondary.GetViewName() {
mainContext = self.c.Contexts().NormalSecondary
}
view := mainContext.GetView()
sel := mainContext.DiffSelectState()
if sel.Mode == context.DiffSelectModeRange {
sel.Mode = context.DiffSelectModeLine
sel.RangeIsSticky = false
}
mode := sel.Mode
self.c.Helpers().Staging.RevealSelectionAfterStaging(view, view, firstLineIdx, func(viewLine int) {
if mode == context.DiffSelectModeHunk {
selectDiffHunk(self.c, mainContext, viewLine)
} else {
view.CancelRangeSelect()
showSelectionAtLine(view, viewLine, true)
}
})
}
// patchFilename maps a diff line's absolute path to the key the patch builder stores
// the file under — its repo-relative, slash-separated path.
func (self *CommitFilesController) patchFilename(absPath string) string {
relPath, err := filepath.Rel(self.c.Git().RepoPaths.WorktreePath(), absPath)
if err != nil {
return ""
}
return filepath.ToSlash(relPath)
}
// pathsForDiff returns the file paths to use for a diff command. When a text
// filter is active and the node is a directory, only the visible (filtered)
// file paths are returned so the diff reflects what the user sees.
func (self *CommitFilesController) pathsForDiff(node *filetree.CommitFileNode) []string {
if !node.IsFile() && self.context().IsFiltering() {
var paths []string
_ = node.ForEachFile(func(file *models.CommitFile) error {
// For a rename we need to pass both paths so that git detects it as
// a rename rather than an unrelated delete and add.
paths = append(paths, file.Names()...)
return nil
})
return paths
}
if file := node.GetFile(); file != nil {
return file.Names()
}
return []string{node.GetPath()}
}
// 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 {
nodePath := node.GetInternalPath()
for _, selectedNode := range selectedNodes {
if selectedNode.IsFile() {
continue
}
selectedNodePath := selectedNode.GetInternalPath()
if strings.HasPrefix(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
}