mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-09-13 00:56:25 -04:00
Staging a hunk from the focused main view advances the selection to the next hunk, because staging removes the acted-on lines from the diff so the preserved change-line ordinal lands on the next change. Toggling a hunk into a custom patch left the selection sitting on the just-toggled hunk instead — the toggle doesn't change the diff (only the inclusion set), so the same ordinal lands back where it was, and you had to navigate by hand to build a patch hunk by hunk. Give the toggle the same feel by advancing the reveal ordinal past the toggled change lines (RevealSelectionAfterStaging gains an advanceBy arg, fed the toggled change-line count). Staging and removal still pass 0 (their lines are consumed). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
317 lines
12 KiB
Go
317 lines
12 KiB
Go
package controllers
|
|
|
|
import (
|
|
"fmt"
|
|
"path/filepath"
|
|
|
|
"github.com/jesseduffield/lazygit/pkg/commands/patch"
|
|
"github.com/jesseduffield/lazygit/pkg/gocui"
|
|
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
|
"github.com/samber/lo"
|
|
)
|
|
|
|
// primaryPatchActionFromFocusedMainView routes the primary action (space) in a commit
|
|
// panel's focused main view by which pane it was pressed in: the main pane toggles the
|
|
// selection into or out of the custom patch, while the secondary pane — which previews the
|
|
// patch being built — removes the selection from the patch (mirroring how space in the
|
|
// staging view's staged pane unstages). Both commit-panel controllers go through here so
|
|
// the routing lives in one place.
|
|
func primaryPatchActionFromFocusedMainView(
|
|
c *ControllerCommon,
|
|
mainViewName string,
|
|
firstLineIdx int,
|
|
lastLineIdx int,
|
|
from string,
|
|
to string,
|
|
reverse bool,
|
|
canRebase bool,
|
|
refresh func(),
|
|
) error {
|
|
if mainViewName == c.Contexts().NormalSecondary.GetViewName() {
|
|
return removePatchLinesFromFocusedMainView(c, mainViewName, firstLineIdx, lastLineIdx, refresh)
|
|
}
|
|
return togglePatchFromFocusedMainView(c, mainViewName, firstLineIdx, lastLineIdx,
|
|
from, to, reverse, canRebase, refresh)
|
|
}
|
|
|
|
// removePatchLinesFromFocusedMainView removes the selected change line(s) from the custom
|
|
// patch when space is pressed in the secondary pane (the custom-patch preview). Every line
|
|
// shown there is already in the patch, so this only ever removes — unlike the main pane's
|
|
// toggle. The selection is mapped to patch-line indices by its ordinal among the shown
|
|
// change lines (see ChangeLineOrdinalsInViewRange / IncludedChangeLineIndices) rather than
|
|
// by line number, which the aggregated patch renumbers (so matching additions by number is
|
|
// unreliable). Refresh then re-renders the smaller patch and the selection is re-established
|
|
// by its change-line ordinal, like the toggle path.
|
|
func removePatchLinesFromFocusedMainView(
|
|
c *ControllerCommon,
|
|
mainViewName string,
|
|
firstLineIdx int,
|
|
lastLineIdx int,
|
|
refresh func(),
|
|
) error {
|
|
ordinalsByFile := c.Helpers().Staging.ChangeLineOrdinalsInViewRange(mainViewName, firstLineIdx, lastLineIdx)
|
|
if len(ordinalsByFile) == 0 {
|
|
return nil
|
|
}
|
|
|
|
patchBuilder := c.Git().Patch.PatchBuilder
|
|
for absPath, ordinals := range ordinalsByFile {
|
|
filename := patchFilename(c, absPath)
|
|
if filename == "" {
|
|
continue
|
|
}
|
|
included := patchBuilder.IncludedChangeLineIndices(filename)
|
|
indices := make([]int, 0, len(ordinals))
|
|
for _, ordinal := range ordinals {
|
|
if ordinal >= 0 && ordinal < len(included) {
|
|
indices = append(indices, included[ordinal])
|
|
}
|
|
}
|
|
if len(indices) == 0 {
|
|
continue
|
|
}
|
|
if err := patchBuilder.RemoveFileLineRange(filename, "", indices); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
refresh()
|
|
// A removal shrinks the patch shown in the secondary, so the removed lines are consumed
|
|
// from its diff; preserving the ordinal (advanceBy 0) lands on the next surviving line,
|
|
// like unstaging.
|
|
revealSelectionAfterPrimaryAction(c, mainViewName, mainViewName, firstLineIdx, 0)
|
|
return nil
|
|
}
|
|
|
|
// togglePatchFromFocusedMainView toggles the selected diff line(s) — a single line, a
|
|
// range, or a hunk — into or out of the custom patch being built for (from, to,
|
|
// reverse). It is the patch-building counterpart of the files panel's staging handler,
|
|
// shared by the panels that build a patch from a focused main view: the commit files
|
|
// panel (the per-file diff) and the commits / sub-commits / stash panels (the
|
|
// whole-commit diff). The selection is resolved to change-line identities (shared with
|
|
// staging) and mapped to the patch builder's per-file line indices.
|
|
//
|
|
// Building a patch needs the patch builder started for this target; if a patch for a
|
|
// different target is active, we confirm before discarding it (as entering the patch
|
|
// builder does). The diff itself is unchanged by a toggle, so refresh — supplied by the
|
|
// caller, since what needs re-rendering differs per panel — re-renders the same diff
|
|
// command (keeping its scroll) and the secondary patch view, and the inclusion gutter
|
|
// rides that re-render. That re-render (and its layout re-wrap when the secondary view
|
|
// first appears) is in view-line space, which moves the selection, so we re-establish it
|
|
// afterwards by its width-independent change-line ordinal.
|
|
func togglePatchFromFocusedMainView(
|
|
c *ControllerCommon,
|
|
mainViewName string,
|
|
firstLineIdx int,
|
|
lastLineIdx int,
|
|
from string,
|
|
to string,
|
|
reverse bool,
|
|
canRebase bool,
|
|
refresh func(),
|
|
) error {
|
|
infos := c.Helpers().Staging.ChangeLinesInViewRange(mainViewName, firstLineIdx, lastLineIdx)
|
|
if len(infos) == 0 {
|
|
return nil
|
|
}
|
|
|
|
patchBuilder := c.Git().Patch.PatchBuilder
|
|
mustDiscardPatch := patchBuilder.Active() && patchBuilder.NewPatchRequired(from, to, reverse)
|
|
return c.ConfirmIf(mustDiscardPatch, types.ConfirmOpts{
|
|
Title: c.Tr.DiscardPatch,
|
|
Prompt: c.Tr.DiscardPatchConfirm,
|
|
HandleConfirm: func() error {
|
|
if mustDiscardPatch {
|
|
patchBuilder.Reset()
|
|
}
|
|
if !patchBuilder.Active() {
|
|
patchBuilder.Start(from, to, reverse, canRebase)
|
|
}
|
|
|
|
if err := togglePatchLines(c, infos); err != nil {
|
|
return err
|
|
}
|
|
|
|
refresh()
|
|
// A toggle doesn't change the diff, so source and target are the same pane; the
|
|
// re-render (and the layout re-wrap when the secondary view first appears) still
|
|
// moves the selection in view-line space, so re-establish it — advanced past the
|
|
// just-toggled change lines to the next stageable hunk/line, since they aren't
|
|
// consumed (the diff is unchanged), unlike staging.
|
|
revealSelectionAfterPrimaryAction(c, mainViewName, mainViewName, firstLineIdx, len(infos))
|
|
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 togglePatchLines(c *ControllerCommon, infos []types.DiffLineInfo) error {
|
|
patchBuilder := 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 := patchFilename(c, 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
|
|
}
|
|
|
|
// discardFromCommitDisabledReason reports why discarding lines from the commit shown in
|
|
// the focused main view is unavailable, or nil when it's available. It mirrors the patch
|
|
// builder's own guard: the diff must be a local commit's (canRebase), no rebase may be in
|
|
// progress, and the diff context size must be non-zero (a patch can't be built from a
|
|
// zero-context diff). Stash and sub-commits of another branch are never rebaseable, so
|
|
// they always get the local-commits reason.
|
|
//
|
|
// Discard is never offered in the secondary pane: it previews the custom patch, so
|
|
// discarding "from the commit" there would act on lines shown only as the patch — and
|
|
// space already removes them from the patch — which would be confusing.
|
|
func discardFromCommitDisabledReason(c *ControllerCommon, mainViewName string, canRebase bool) *types.DisabledReason {
|
|
if mainViewName == c.Contexts().NormalSecondary.GetViewName() {
|
|
return &types.DisabledReason{Text: c.Tr.CannotDiscardFromCustomPatchView}
|
|
}
|
|
if !canRebase {
|
|
return &types.DisabledReason{Text: c.Tr.CanOnlyDiscardFromLocalCommits}
|
|
}
|
|
if c.Git().Status.WorkingTreeState().Any() {
|
|
return &types.DisabledReason{Text: c.Tr.CantPatchWhileRebasingError}
|
|
}
|
|
if c.UserConfig().Git.DiffContextSize == 0 {
|
|
return &types.DisabledReason{Text: fmt.Sprintf(c.Tr.Actions.NotEnoughContextToRemoveLines,
|
|
c.UserConfig().Keybinding.Universal.IncreaseContextInDiffView)}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// discardSelectionFromCommit discards the selected diff line(s) — a single line, a
|
|
// range, or a hunk — from the commit whose diff the focused main view shows, by building
|
|
// a one-off patch from the selection and removing it from the commit via a rebase. It is
|
|
// the patch-building counterpart of the working-tree discard the files panel does, and
|
|
// mirrors the patch builder's own "discard lines from commit": any in-progress custom
|
|
// patch is reset first (the confirm prompt warns when one exists), then a fresh patch
|
|
// holding exactly the selection is built for (from, to, reverse) and removed from the
|
|
// commit. The caller has already established (via canRebase) that the commit is on a
|
|
// local branch; the target commit is the one identified by `to`.
|
|
func discardSelectionFromCommit(
|
|
c *ControllerCommon,
|
|
mainViewName string,
|
|
firstLineIdx int,
|
|
lastLineIdx int,
|
|
from string,
|
|
to string,
|
|
reverse bool,
|
|
canRebase bool,
|
|
) error {
|
|
infos := c.Helpers().Staging.ChangeLinesInViewRange(mainViewName, firstLineIdx, lastLineIdx)
|
|
if len(infos) == 0 {
|
|
return nil
|
|
}
|
|
|
|
commitIndex := -1
|
|
for i, commit := range c.Model().Commits {
|
|
if commit.Hash() == to {
|
|
commitIndex = i
|
|
break
|
|
}
|
|
}
|
|
if commitIndex == -1 {
|
|
return nil
|
|
}
|
|
|
|
patchBuilder := c.Git().Patch.PatchBuilder
|
|
prompt := lo.Ternary(patchBuilder.Active(),
|
|
c.Tr.DiscardLinesFromCommitPromptWithReset,
|
|
c.Tr.DiscardLinesFromCommitPrompt)
|
|
|
|
c.Confirm(types.ConfirmOpts{
|
|
Title: c.Tr.DiscardLinesFromCommitTitle,
|
|
Prompt: prompt,
|
|
HandleConfirm: func() error {
|
|
// Build a fresh patch holding exactly the selection: reset any active patch,
|
|
// then add the selected lines (togglePatchLines adds them, the patch being empty).
|
|
if patchBuilder.Active() {
|
|
patchBuilder.Reset()
|
|
}
|
|
patchBuilder.Start(from, to, reverse, canRebase)
|
|
if err := togglePatchLines(c, infos); err != nil {
|
|
return err
|
|
}
|
|
if patchBuilder.IsEmpty() {
|
|
return nil
|
|
}
|
|
|
|
return c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{
|
|
Message: c.Tr.RebasingStatus,
|
|
HideWorkingTreeState: true,
|
|
}, func(gocui.Task) error {
|
|
c.LogAction(c.Tr.Actions.RemovePatchFromCommit)
|
|
err := c.Git().Patch.DeletePatchesFromCommit(c.Model().Commits, commitIndex)
|
|
// The rebase rewrites the commit, so the focused main view's selection is
|
|
// re-established on the next surviving change as the diff re-renders (see
|
|
// preserveFocusedMainViewSelectionAcrossContentChange).
|
|
return c.Helpers().MergeAndRebase.CheckMergeOrRebase(err)
|
|
})
|
|
},
|
|
})
|
|
return nil
|
|
}
|
|
|
|
// 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 patchFilename(c *ControllerCommon, absPath string) string {
|
|
relPath, err := filepath.Rel(c.Git().RepoPaths.WorktreePath(), absPath)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return filepath.ToSlash(relPath)
|
|
}
|