mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-08-28 10:15:32 -05:00
450 lines
17 KiB
Go
450 lines
17 KiB
Go
package controllers
|
|
|
|
import (
|
|
"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/types"
|
|
"github.com/samber/lo"
|
|
)
|
|
|
|
// CommitDiffActions is 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. What they offer is the same for all of them, differing
|
|
// only in which diff it is — so they share this, each saying what it is showing.
|
|
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 asking pane is showing, for the given files — the
|
|
// commit's diff as in the main view, only without the commit's message and stat above it,
|
|
// or the diff the custom patch is previewed as, whose lines are the patch's own rather
|
|
// than the commit's.
|
|
//
|
|
// The patch's own diff is handed out whole: it is only ever as big as the patch, and it
|
|
// names its files under the trees the patch was materialized into rather than under the
|
|
// paths asked for.
|
|
func (self *CommitDiffActions) PlainDiff(pane types.DiffPaneContext, paths []string) string {
|
|
if self.showsCustomPatch(pane) {
|
|
return self.customPatchDiff()
|
|
}
|
|
|
|
target := self.target()
|
|
if target == nil {
|
|
return ""
|
|
}
|
|
return self.c.Helpers().Diff.PlainDiffBetweenRefs(target.from, target.to, paths)
|
|
}
|
|
|
|
// customPatchDiff is the diff the custom patch is previewed as, as git writes it — the
|
|
// diff behind what the pane previewing the patch shows, in which the lines shown there
|
|
// can be found again.
|
|
func (self *CommitDiffActions) customPatchDiff() string {
|
|
treesDir := self.c.Git().Patch.PatchBuilder.TempDir()
|
|
if treesDir == "" {
|
|
return ""
|
|
}
|
|
// An error means the two trees differ, which is what a patch with anything in it looks
|
|
// like; the diff itself is what we are after either way.
|
|
diff, _ := self.c.Git().Diff.
|
|
CustomPatchDiffCmdObj(treesDir, git_commands.DiffModePlain).
|
|
RunWithOutput()
|
|
return diff
|
|
}
|
|
|
|
// 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, firstLineIdx int, lastLineIdx int) error {
|
|
// In the pane showing the patch, the lines are the patch's own, so there they only
|
|
// come back out of it.
|
|
if self.showsCustomPatch(pane) {
|
|
return self.removePatchLines(pane, firstLineIdx, lastLineIdx)
|
|
}
|
|
|
|
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.ChangeLinesInViewRange(pane.GetView(), firstLineIdx, lastLineIdx)
|
|
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 diff on screen is the one the marks belong to, so they can be brought up
|
|
// to date at once rather than waiting for the render below.
|
|
self.c.Helpers().DiffLine.RefreshInclusionGutter()
|
|
|
|
// 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, firstLineIdx, len(lines),
|
|
func() { _ = 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
|
|
},
|
|
})
|
|
}
|
|
|
|
// removePatchLines takes the selected lines of the custom patch out of it, which is what
|
|
// the primary action does in the pane showing the patch: everything shown there is in the
|
|
// patch already, so there is nothing else it could mean.
|
|
//
|
|
// A line of the patch is named by which of its file's changes it is, counted in the diff
|
|
// the patch is shown as, which is the same place that line has among the file's changes
|
|
// the patch holds. Line numbers would not do: a patch that leaves an earlier addition out
|
|
// numbers everything after it differently from the commit's diff.
|
|
func (self *CommitDiffActions) removePatchLines(
|
|
pane types.DiffPaneContext, firstLineIdx int, lastLineIdx int,
|
|
) error {
|
|
lines := self.c.Helpers().DiffLine.ChangeLinesInViewRange(pane.GetView(), firstLineIdx, lastLineIdx)
|
|
if len(lines) == 0 {
|
|
return nil
|
|
}
|
|
|
|
patchBuilder := self.c.Git().Patch.PatchBuilder
|
|
previousPaths := self.previousPaths()
|
|
for path, ordinals := range self.c.Helpers().DiffLine.ChangeLineOrdinals(self.customPatchDiff(), lines) {
|
|
filename := self.patchBuilderPath(path)
|
|
if filename == "" {
|
|
continue
|
|
}
|
|
included := patchBuilder.IncludedChangeLineIndices(filename)
|
|
indices := []int{}
|
|
for _, ordinal := range ordinals {
|
|
if ordinal < len(included) {
|
|
indices = append(indices, included[ordinal])
|
|
}
|
|
}
|
|
if len(indices) == 0 {
|
|
continue
|
|
}
|
|
if err := patchBuilder.RemoveFileLineRange(filename, previousPaths[filename], indices); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
// Taking the last line out ends the patch rather than leaving an empty one, as it does
|
|
// in the diff beside this pane.
|
|
if patchBuilder.IsEmpty() {
|
|
patchBuilder.Reset()
|
|
}
|
|
|
|
self.c.Helpers().DiffLine.RefreshInclusionGutter()
|
|
|
|
// The lines are gone from the patch, so the selection carries on from where they were,
|
|
// as unstaging leaves it. Input is held until it has moved, so that a second press acts
|
|
// on the patch as it now is.
|
|
self.c.GocuiGui().BeginBlockingEvents()
|
|
self.c.Helpers().DiffLine.RevealSelectionAfterAction(pane, pane, firstLineIdx, 0,
|
|
func() { _ = self.c.GocuiGui().EndBlockingEvents() })
|
|
|
|
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, firstLineIdx int, lastLineIdx int) error {
|
|
target := self.target()
|
|
if target == nil {
|
|
return nil
|
|
}
|
|
lines := self.c.Helpers().DiffLine.ChangeLinesInViewRange(pane.GetView(), firstLineIdx, lastLineIdx)
|
|
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
|
|
}
|
|
|
|
// PatchInclusion says which lines of the commit's diff are in the custom patch being
|
|
// built from it. nil when there is no such patch: none is being built at all, or the one
|
|
// being built is of another diff, whose lines are not these however alike they look.
|
|
func (self *CommitDiffActions) PatchInclusion() func(types.DiffLineInfo) bool {
|
|
patchBuilder := self.c.Git().Patch.PatchBuilder
|
|
target := self.target()
|
|
if !patchBuilder.Active() || target == nil {
|
|
return nil
|
|
}
|
|
from, reverse := self.patchEndpoints(target)
|
|
if patchBuilder.NewPatchRequired(from, target.to, reverse) {
|
|
return nil
|
|
}
|
|
|
|
// Which lines of a file are in the patch is asked of the patch builder per file, and
|
|
// a diff can span many, so each is asked about when a line of it first comes up.
|
|
includedByPath := map[string]*set.Set[patch.LineIdentity]{}
|
|
return func(info types.DiffLineInfo) bool {
|
|
path := self.patchBuilderPath(info.Path)
|
|
if path == "" {
|
|
return false
|
|
}
|
|
included, asked := includedByPath[path]
|
|
if !asked {
|
|
included = set.NewFromSlice(patchBuilder.IncludedLineIdentities(path))
|
|
includedByPath[path] = included
|
|
}
|
|
return included.Includes(info.PatchLineIdentity())
|
|
}
|
|
}
|
|
|
|
// togglePatchLines takes the given lines of the commit's diff into the custom patch, or
|
|
// out of it. Which of the two it is is decided once, by the first line of the 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{}
|
|
wholeFileByPath := map[string]bool{}
|
|
for _, path := range paths {
|
|
indices, err := patchBuilder.PatchLineIndicesForLines(path, previousPaths[path], linesByPath[path])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
indicesByPath[path] = indices
|
|
|
|
wholeFile, err := patchBuilder.SelectionRepresentsWholeFile(path, previousPaths[path], linesByPath[path])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
wholeFileByPath[path] = wholeFile
|
|
}
|
|
|
|
included, err := patchBuilder.GetFileIncLineIndices(paths[0], previousPaths[paths[0]])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
removing := len(indicesByPath[paths[0]]) > 0 && lo.Contains(included, indicesByPath[paths[0]][0])
|
|
|
|
for _, path := range paths {
|
|
if len(indicesByPath[path]) == 0 {
|
|
continue
|
|
}
|
|
var err error
|
|
switch {
|
|
case wholeFileByPath[path] && removing:
|
|
err = patchBuilder.RemoveFile(path, previousPaths[path])
|
|
case wholeFileByPath[path]:
|
|
err = patchBuilder.AddFileWhole(path, previousPaths[path])
|
|
case removing:
|
|
err = patchBuilder.RemoveFileLineRange(path, previousPaths[path], indicesByPath[path])
|
|
default:
|
|
err = patchBuilder.AddFileLineRange(path, previousPaths[path], indicesByPath[path])
|
|
}
|
|
if 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()
|
|
}
|