mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-10-05 21:46:49 -04:00
The five panels that show a commit's diff now offer on it what the patch explorer offers on the one file it can show at a time: space takes the selected lines into the custom patch being built, or back out of it again, and d takes them out of the commit itself. So a patch can be built straight from the diff already on screen, over several files at once and without entering the commit's files first — and from a stash entry or a reflog entry too, whose diffs the explorer could reach but which had no patch preview of their own to show for it. The two arrive together because they are one answer to what a commit's diff offers, and because d needs exactly what space builds: a patch of the selection, which the rebase then removes from the commit. Which of the two space is doing is decided by the first selected line, as toggling a file in the commit files panel is, and the selection then moves past the lines dealt with — they are still in the diff, a patch leaving the commit alone, so a second press would otherwise take them straight back out. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
316 lines
12 KiB
Go
316 lines
12 KiB
Go
package controllers
|
|
|
|
import (
|
|
"fmt"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"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/types"
|
|
"github.com/samber/lo"
|
|
)
|
|
|
|
// CommitDiffActions implements what a panel showing a commit's diff offers on that diff
|
|
// in the focused main view. Five panels do: the commit files panel shows the diff of one
|
|
// file of a commit, and the commits, sub-commits, stash and reflog panels the whole diff
|
|
// of whatever they have selected. They all offer the same thing and differ only in which
|
|
// diff they show, so they share this, each saying which diff that is.
|
|
type CommitDiffActions struct {
|
|
c *ControllerCommon
|
|
|
|
// The panel this belongs to, and what it is showing the diff of — nil when it has
|
|
// nothing selected, and so no diff.
|
|
panel types.Context
|
|
target func() *commitDiffTarget
|
|
}
|
|
|
|
// commitDiffTarget is the diff a panel is showing: the two ends of it, and whether it
|
|
// belongs to a commit lazygit may rewrite.
|
|
type commitDiffTarget struct {
|
|
from string
|
|
to string
|
|
canRebase bool
|
|
}
|
|
|
|
var _ types.FocusedMainViewActions = &CommitDiffActions{}
|
|
|
|
func NewCommitDiffActions(
|
|
c *ControllerCommon, panel types.Context, target func() *commitDiffTarget,
|
|
) *CommitDiffActions {
|
|
return &CommitDiffActions{c: c, panel: panel, target: target}
|
|
}
|
|
|
|
// PlainDiff hands out the diff the panel is showing, for the given files — the same
|
|
// diff as in the main view, only without the commit's message and stat above it.
|
|
func (self *CommitDiffActions) PlainDiff(_ types.DiffPaneContext, paths []string) string {
|
|
target := self.target()
|
|
if target == nil {
|
|
return ""
|
|
}
|
|
return self.c.Helpers().Diff.PlainDiffBetweenRefs(target.from, target.to, paths)
|
|
}
|
|
|
|
// PrimaryAction takes the selected lines into the custom patch being built from this
|
|
// diff, or back out of it when the first of them is already in — the same toggling the
|
|
// commit files panel does to a whole file at a time.
|
|
//
|
|
// The commit is not touched, so the diff stays as it is: what changes is the patch
|
|
// beside it, and which of its lines are marked as being in that patch.
|
|
func (self *CommitDiffActions) PrimaryAction(pane types.DiffPaneContext, firstBufferLine int, lastBufferLine int) error {
|
|
// Only the diff has lines to take into the patch; the pane beside it shows the patch
|
|
// they are taken into.
|
|
if self.showsCustomPatch(pane) {
|
|
return nil
|
|
}
|
|
|
|
if self.c.UserConfig().Git.DiffContextSize == 0 {
|
|
return fmt.Errorf(self.c.Tr.Actions.NotEnoughContextForCustomPatch,
|
|
self.c.UserConfig().Keybinding.Universal.IncreaseContextInDiffView)
|
|
}
|
|
|
|
target := self.target()
|
|
if target == nil {
|
|
return nil
|
|
}
|
|
lines := self.c.Helpers().DiffLine.ChangeLinesInBufferRange(pane.GetView(), firstBufferLine, lastBufferLine)
|
|
if len(lines) == 0 {
|
|
return nil
|
|
}
|
|
|
|
patchBuilder := self.c.Git().Patch.PatchBuilder
|
|
from, reverse := self.patchEndpoints(target)
|
|
// A patch is built from one diff, so building from another one means giving up the
|
|
// patch there is — which the user is asked about, as entering the patch builder asks.
|
|
mustDiscardPatch := patchBuilder.Active() && patchBuilder.NewPatchRequired(from, target.to, reverse)
|
|
return self.c.ConfirmIf(mustDiscardPatch, types.ConfirmOpts{
|
|
Title: self.c.Tr.DiscardPatch,
|
|
Prompt: self.c.Tr.DiscardPatchConfirm,
|
|
HandleConfirm: func() error {
|
|
if mustDiscardPatch {
|
|
patchBuilder.Reset()
|
|
}
|
|
if !patchBuilder.Active() {
|
|
patchBuilder.Start(from, target.to, reverse, target.canRebase)
|
|
}
|
|
|
|
if err := self.togglePatchLines(lines); err != nil {
|
|
return err
|
|
}
|
|
// Taking the last line back out ends the patch rather than leaving an empty
|
|
// one, so that the pane previewing it and the marks over the diff go with it.
|
|
if patchBuilder.IsEmpty() {
|
|
patchBuilder.Reset()
|
|
}
|
|
|
|
// The selection moves on past the lines just toggled, to the next change of
|
|
// the diff — which is still there, a toggle leaving the diff as it was, so
|
|
// hold input back until it has moved: a second press meanwhile would toggle
|
|
// the same lines straight back.
|
|
self.c.GocuiGui().BeginBlockingEvents()
|
|
self.c.Helpers().DiffLine.RevealSelectionAfterAction(pane, pane, firstBufferLine, len(lines),
|
|
self.c.GocuiGui().EndBlockingEvents)
|
|
|
|
// The panel's own render, which is all that is needed: the marks over the diff
|
|
// and the patch previewed beside it have changed, while the commit has not.
|
|
self.c.PostRefreshUpdate(self.panel)
|
|
return nil
|
|
},
|
|
})
|
|
}
|
|
|
|
// DiscardSelection takes the selected lines out of the commit they are part of, by
|
|
// building a patch of exactly those lines and removing that patch from the commit. It is
|
|
// a rebase, so a later commit that touches the same lines can conflict with it.
|
|
//
|
|
// The patch it needs is its own, so a patch being built is given up first — which the
|
|
// prompt says, there being no way to get it back.
|
|
func (self *CommitDiffActions) DiscardSelection(pane types.DiffPaneContext, firstBufferLine int, lastBufferLine int) error {
|
|
target := self.target()
|
|
if target == nil {
|
|
return nil
|
|
}
|
|
lines := self.c.Helpers().DiffLine.ChangeLinesInBufferRange(pane.GetView(), firstBufferLine, lastBufferLine)
|
|
if len(lines) == 0 {
|
|
return nil
|
|
}
|
|
commitIndex := self.indexOfTargetCommit(target)
|
|
if commitIndex == -1 {
|
|
return nil
|
|
}
|
|
|
|
patchBuilder := self.c.Git().Patch.PatchBuilder
|
|
prompt := lo.Ternary(patchBuilder.IsEmpty(),
|
|
self.c.Tr.DiscardLinesFromCommitPrompt,
|
|
self.c.Tr.DiscardLinesFromCommitPromptWithReset)
|
|
|
|
self.c.Confirm(types.ConfirmOpts{
|
|
Title: self.c.Tr.DiscardLinesFromCommitTitle,
|
|
Prompt: prompt,
|
|
HandleConfirm: func() error {
|
|
from, reverse := self.patchEndpoints(target)
|
|
patchBuilder.Reset()
|
|
patchBuilder.Start(from, target.to, reverse, target.canRebase)
|
|
if err := self.togglePatchLines(lines); err != nil {
|
|
return err
|
|
}
|
|
if patchBuilder.IsEmpty() {
|
|
return nil
|
|
}
|
|
|
|
// The rebase runs on a worker, which may not read the model, so the commits
|
|
// it rewrites are taken here.
|
|
commits := self.c.Model().Commits
|
|
return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{
|
|
Message: self.c.Tr.RebasingStatus,
|
|
HideWorkingTreeState: true,
|
|
}, func(gocui.Task) error {
|
|
self.c.LogAction(self.c.Tr.Actions.RemovePatchFromCommit)
|
|
err := self.c.Git().Patch.DeletePatchesFromCommit(commits, commitIndex)
|
|
return self.c.Helpers().MergeAndRebase.CheckMergeOrRebase(err)
|
|
})
|
|
},
|
|
})
|
|
return nil
|
|
}
|
|
|
|
// DiscardSelectionDisabledReason says why the selected lines can't be taken out of the
|
|
// commit: doing so rewrites it, which is only ours to do for a commit of the branch we
|
|
// are on, and not while a rebase is already under way. In the pane previewing the custom
|
|
// patch there is nothing to discard from — the lines there are the patch's, and space
|
|
// takes them back out of it.
|
|
func (self *CommitDiffActions) DiscardSelectionDisabledReason(pane types.DiffPaneContext) *types.DisabledReason {
|
|
if self.showsCustomPatch(pane) {
|
|
return &types.DisabledReason{Text: self.c.Tr.CannotDiscardFromCustomPatchView, ShowErrorInPanel: true}
|
|
}
|
|
target := self.target()
|
|
if target == nil || !target.canRebase {
|
|
return &types.DisabledReason{Text: self.c.Tr.CanOnlyDiscardFromLocalCommits, ShowErrorInPanel: true}
|
|
}
|
|
if self.c.Git().Status.WorkingTreeState().Any() {
|
|
return &types.DisabledReason{Text: self.c.Tr.CantPatchWhileRebasingError, ShowErrorInPanel: true}
|
|
}
|
|
if self.c.UserConfig().Git.DiffContextSize == 0 {
|
|
return &types.DisabledReason{
|
|
Text: fmt.Sprintf(self.c.Tr.Actions.NotEnoughContextToRemoveLines,
|
|
self.c.UserConfig().Keybinding.Universal.IncreaseContextInDiffView),
|
|
ShowErrorInPanel: true,
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// togglePatchLines takes the given lines of the commit's diff into the custom patch, or
|
|
// out of it. The first line of the selection decides which of the two happens, once for
|
|
// the whole selection: pointing at a line that is already in the patch takes the whole
|
|
// selection out of it, as toggling a selection of files in the commit files panel does.
|
|
func (self *CommitDiffActions) togglePatchLines(lines []types.DiffLineInfo) error {
|
|
patchBuilder := self.c.Git().Patch.PatchBuilder
|
|
|
|
// The files the selection covers, in the order the diff shows them, and per file the
|
|
// lines of it that are selected: a patch is built a file at a time, while a selection
|
|
// can span several of them.
|
|
paths := []string{}
|
|
linesByPath := map[string][]patch.LineIdentity{}
|
|
for _, line := range lines {
|
|
path := self.patchBuilderPath(line.Path)
|
|
if path == "" {
|
|
continue
|
|
}
|
|
if _, seen := linesByPath[path]; !seen {
|
|
paths = append(paths, path)
|
|
}
|
|
linesByPath[path] = append(linesByPath[path], line.PatchLineIdentity())
|
|
}
|
|
if len(paths) == 0 {
|
|
return nil
|
|
}
|
|
|
|
previousPaths := self.previousPaths()
|
|
indicesByPath := map[string][]int{}
|
|
for _, path := range paths {
|
|
indices, err := patchBuilder.PatchLineIndicesForLines(path, previousPaths[path], linesByPath[path])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
indicesByPath[path] = indices
|
|
}
|
|
|
|
included, err := patchBuilder.GetFileIncLineIndices(paths[0], previousPaths[paths[0]])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
toggle := patchBuilder.AddFileLineRange
|
|
if first := indicesByPath[paths[0]]; len(first) > 0 && lo.Contains(included, first[0]) {
|
|
toggle = patchBuilder.RemoveFileLineRange
|
|
}
|
|
|
|
for _, path := range paths {
|
|
if len(indicesByPath[path]) == 0 {
|
|
continue
|
|
}
|
|
if err := toggle(path, previousPaths[path], indicesByPath[path]); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// previousPaths says which files of the diff were renamed, and what they were called
|
|
// before. A renamed file's diff only comes out as a rename when git is asked about both
|
|
// of its paths, and its lines are numbered in the file under its old name, so the patch
|
|
// builder has to be told the old path along with them.
|
|
func (self *CommitDiffActions) previousPaths() map[string]string {
|
|
target := self.target()
|
|
if target == nil {
|
|
return nil
|
|
}
|
|
from, reverse := self.patchEndpoints(target)
|
|
files, err := self.c.Git().Loaders.CommitFileLoader.GetFilesInDiff(from, target.to, reverse)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
previousPaths := map[string]string{}
|
|
for _, file := range files {
|
|
if file.PreviousPath != "" {
|
|
previousPaths[file.Path] = file.PreviousPath
|
|
}
|
|
}
|
|
return previousPaths
|
|
}
|
|
|
|
// patchEndpoints gives the two ends of the diff a patch is built from. They are the ends
|
|
// of the diff shown, except in diffing mode, where what is shown is a diff against
|
|
// another ref, possibly the other way around.
|
|
func (self *CommitDiffActions) patchEndpoints(target *commitDiffTarget) (string, bool) {
|
|
return self.c.Modes().Diffing.GetFromAndReverseArgsForDiff(target.from)
|
|
}
|
|
|
|
// patchBuilderPath turns the absolute path a diff line carries into the repo-relative
|
|
// one the patch builder keys a file by, and "" for a path that is no file of this repo.
|
|
func (self *CommitDiffActions) patchBuilderPath(path string) string {
|
|
relativePath, err := filepath.Rel(self.c.Git().RepoPaths.WorktreePath(), path)
|
|
if err != nil || strings.HasPrefix(relativePath, "..") {
|
|
return ""
|
|
}
|
|
return filepath.ToSlash(relativePath)
|
|
}
|
|
|
|
// indexOfTargetCommit finds the commit the diff belongs to among the commits of the
|
|
// branch we are on, which is how a rebase is told which commit to rewrite. -1 when it
|
|
// isn't one of them, in which case there is nothing we can rewrite.
|
|
func (self *CommitDiffActions) indexOfTargetCommit(target *commitDiffTarget) int {
|
|
return lo.IndexOf(
|
|
lo.Map(self.c.Model().Commits, func(commit *models.Commit, _ int) string { return commit.Hash() }),
|
|
target.to)
|
|
}
|
|
|
|
// showsCustomPatch reports whether the given main pane is the one previewing the custom
|
|
// patch being built, rather than the commit's diff — which for a commit's diff is always
|
|
// the lower one.
|
|
func (self *CommitDiffActions) showsCustomPatch(pane types.DiffPaneContext) bool {
|
|
return pane.GetKey() == self.c.Contexts().NormalSecondary.GetKey()
|
|
}
|