mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-09-10 15:46:26 -04:00
When loading the files of a commit we passed --no-renames, so a rename showed up as a separate delete and add rather than a single R entry. That made it impossible to work with a rename that also modifies the file: the modifications were spread across a full deletion and a full addition instead of appearing as the handful of lines that actually changed. The staging view already shows renames and lets you stage their hunks, so there was no good reason for the patch builder to differ; the flag was only there because the commit-file parser couldn't cope with the rename record format. Switch the commit-file loader and the per-file diff to --find-renames, teach the parser about the rename record (a status followed by two paths), and carry the previous path through the patch builder so the diff for a rename is loaded with both paths, which is what makes git emit the rename in the first place. A whole-file selection keeps the rename in the header, so the rename moves or is discarded together with the file's contents. A partial selection instead strips the rename metadata and points the header at the new path, so applying the patch only changes the contents and leaves the rename in place; the blob index line is kept so that a 3-way apply can still fall back to a blob merge. Discarding a renamed file from a commit now discards both the new and the old path, so the new file is removed and the old one is restored. Changing the rename similarity threshold refreshes the commit files panel too, not just the files panel, so that a rename can turn into a delete and add or back. It is disabled while building a patch, however, because the patch builder caches each file's diff by path and would desync if a rename changed into a delete and add underneath it. Finally, copying a file's diff from the commit files panel now passes both paths for a rename, so the copied diff shows the rename instead of a new-file add. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
273 lines
7.8 KiB
Go
273 lines
7.8 KiB
Go
package controllers
|
|
|
|
import (
|
|
"fmt"
|
|
|
|
"github.com/jesseduffield/lazygit/pkg/gocui"
|
|
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
|
"github.com/samber/lo"
|
|
)
|
|
|
|
type PatchBuildingController struct {
|
|
baseController
|
|
c *ControllerCommon
|
|
}
|
|
|
|
var _ types.IController = &PatchBuildingController{}
|
|
|
|
func NewPatchBuildingController(
|
|
c *ControllerCommon,
|
|
) *PatchBuildingController {
|
|
return &PatchBuildingController{
|
|
baseController: baseController{},
|
|
c: c,
|
|
}
|
|
}
|
|
|
|
func (self *PatchBuildingController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
|
|
return []*types.Binding{
|
|
{
|
|
Keys: opts.GetKeys(opts.Config.Universal.OpenFile),
|
|
Handler: self.OpenFile,
|
|
Description: self.c.Tr.OpenFile,
|
|
Tooltip: self.c.Tr.OpenFileTooltip,
|
|
},
|
|
{
|
|
Keys: opts.GetKeys(opts.Config.Universal.Edit),
|
|
Handler: self.EditFile,
|
|
Description: self.c.Tr.EditFile,
|
|
Tooltip: self.c.Tr.EditFileTooltip,
|
|
},
|
|
{
|
|
Keys: opts.GetKeys(opts.Config.Universal.Select),
|
|
Handler: self.ToggleSelectionAndRefresh,
|
|
Description: self.c.Tr.ToggleSelectionForPatch,
|
|
DisplayOnScreen: true,
|
|
},
|
|
{
|
|
Keys: opts.GetKeys(opts.Config.Universal.Remove),
|
|
Handler: self.discardSelection,
|
|
GetDisabledReason: self.getDisabledReasonForDiscard,
|
|
Description: self.c.Tr.RemoveSelectionFromPatch,
|
|
Tooltip: self.c.Tr.RemoveSelectionFromPatchTooltip,
|
|
DisplayOnScreen: true,
|
|
},
|
|
{
|
|
Keys: opts.GetKeys(opts.Config.Universal.Return),
|
|
Handler: self.Escape,
|
|
Description: self.c.Tr.ExitCustomPatchBuilder,
|
|
DescriptionFunc: self.EscapeDescription,
|
|
DisplayOnScreen: true,
|
|
},
|
|
}
|
|
}
|
|
|
|
func (self *PatchBuildingController) Context() types.Context {
|
|
return self.c.Contexts().CustomPatchBuilder
|
|
}
|
|
|
|
func (self *PatchBuildingController) context() types.IPatchExplorerContext {
|
|
return self.c.Contexts().CustomPatchBuilder
|
|
}
|
|
|
|
func (self *PatchBuildingController) GetMouseKeybindings(opts types.KeybindingsOpts) []*gocui.ViewMouseBinding {
|
|
return []*gocui.ViewMouseBinding{}
|
|
}
|
|
|
|
func (self *PatchBuildingController) GetOnFocus() func(types.OnFocusOpts) {
|
|
return func(opts types.OnFocusOpts) {
|
|
// no need to change wrap on the secondary view because it can't be interacted with
|
|
self.c.Views().PatchBuilding.Wrap = self.c.UserConfig().Gui.WrapLinesInStagingView
|
|
|
|
self.c.Helpers().PatchBuilding.RefreshPatchBuildingPanel(opts)
|
|
}
|
|
}
|
|
|
|
func (self *PatchBuildingController) GetOnFocusLost() func(types.OnFocusLostOpts) {
|
|
return func(opts types.OnFocusLostOpts) {
|
|
self.context().SetState(nil)
|
|
|
|
self.c.Views().PatchBuilding.Wrap = true
|
|
|
|
if self.c.Git().Patch.PatchBuilder.IsEmpty() {
|
|
self.c.Git().Patch.PatchBuilder.Reset()
|
|
}
|
|
}
|
|
}
|
|
|
|
func (self *PatchBuildingController) OpenFile() error {
|
|
self.context().GetMutex().Lock()
|
|
defer self.context().GetMutex().Unlock()
|
|
|
|
path := self.c.Contexts().CommitFiles.GetSelectedPath()
|
|
|
|
if path == "" {
|
|
return nil
|
|
}
|
|
|
|
return self.c.Helpers().Files.OpenFile(path)
|
|
}
|
|
|
|
func (self *PatchBuildingController) EditFile() error {
|
|
self.context().GetMutex().Lock()
|
|
defer self.context().GetMutex().Unlock()
|
|
|
|
path := self.c.Contexts().CommitFiles.GetSelectedPath()
|
|
|
|
if path == "" {
|
|
return nil
|
|
}
|
|
|
|
lineNumber := self.context().GetState().CurrentLineNumber()
|
|
lineNumber = self.c.Helpers().Diff.AdjustLineNumber(path, lineNumber, self.context().GetViewName())
|
|
return self.c.Helpers().Files.EditFileAtLine(path, lineNumber)
|
|
}
|
|
|
|
func (self *PatchBuildingController) ToggleSelectionAndRefresh() error {
|
|
if err := self.toggleSelection(); err != nil {
|
|
return err
|
|
}
|
|
|
|
self.c.Refresh(types.RefreshOptions{
|
|
Scope: []types.RefreshableView{types.PATCH_BUILDING, types.COMMIT_FILES},
|
|
})
|
|
return nil
|
|
}
|
|
|
|
func (self *PatchBuildingController) toggleSelection() error {
|
|
self.context().GetMutex().Lock()
|
|
defer self.context().GetMutex().Unlock()
|
|
|
|
file := self.c.Contexts().CommitFiles.GetSelectedFile()
|
|
if file == nil {
|
|
return nil
|
|
}
|
|
|
|
state := self.context().GetState()
|
|
|
|
// Get added/deleted lines in the selected patch range
|
|
lineIndicesToToggle := state.LineIndicesOfAddedOrDeletedLinesInSelectedPatchRange()
|
|
if len(lineIndicesToToggle) == 0 {
|
|
// Only context lines or header lines selected, so nothing to do
|
|
return nil
|
|
}
|
|
|
|
includedLineIndices, err := self.c.Git().Patch.PatchBuilder.GetFileIncLineIndices(file.Path, file.PreviousPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
toggleFunc := self.c.Git().Patch.PatchBuilder.AddFileLineRange
|
|
firstSelectedChangeLineIsStaged := lo.Contains(includedLineIndices, lineIndicesToToggle[0])
|
|
if firstSelectedChangeLineIsStaged {
|
|
toggleFunc = self.c.Git().Patch.PatchBuilder.RemoveFileLineRange
|
|
}
|
|
|
|
// add range of lines to those set for the file
|
|
if err := toggleFunc(file.Path, file.PreviousPath, lineIndicesToToggle); err != nil {
|
|
// might actually want to return an error here
|
|
self.c.Log.Error(err)
|
|
}
|
|
|
|
if state.SelectingRange() {
|
|
state.SetLineSelectMode()
|
|
}
|
|
|
|
state.SelectNextStageableLineOfSameIncludedState(self.context().GetIncludedLineIndices(), firstSelectedChangeLineIsStaged)
|
|
|
|
return nil
|
|
}
|
|
|
|
func (self *PatchBuildingController) getDisabledReasonForDiscard() *types.DisabledReason {
|
|
if !self.c.Git().Patch.PatchBuilder.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 {
|
|
text := fmt.Sprintf(self.c.Tr.Actions.NotEnoughContextToRemoveLines,
|
|
self.c.UserConfig().Keybinding.Universal.IncreaseContextInDiffView)
|
|
return &types.DisabledReason{Text: text, ShowErrorInPanel: true}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (self *PatchBuildingController) discardSelection() error {
|
|
prompt := lo.Ternary(self.c.Git().Patch.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 {
|
|
return self.discardSelectionFromCommit()
|
|
},
|
|
})
|
|
|
|
return nil
|
|
}
|
|
|
|
func (self *PatchBuildingController) discardSelectionFromCommit() error {
|
|
// Reset the current patch if there is one.
|
|
if !self.c.Git().Patch.PatchBuilder.IsEmpty() {
|
|
self.c.Git().Patch.PatchBuilder.Reset()
|
|
}
|
|
|
|
if err := self.toggleSelection(); err != nil {
|
|
return err
|
|
}
|
|
|
|
if self.c.Git().Patch.PatchBuilder.IsEmpty() {
|
|
return nil
|
|
}
|
|
|
|
return self.c.WithWaitingStatusSync(self.c.Tr.RebasingStatus, func() error {
|
|
commitIndex := self.getPatchCommitIndex()
|
|
self.c.LogAction(self.c.Tr.Actions.RemovePatchFromCommit)
|
|
err := self.c.Git().Patch.DeletePatchesFromCommit(self.c.Model().Commits, commitIndex)
|
|
self.c.Helpers().PatchBuilding.Escape()
|
|
return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions(
|
|
err, types.RefreshOptions{Mode: types.SYNC})
|
|
})
|
|
}
|
|
|
|
func (self *PatchBuildingController) getPatchCommitIndex() int {
|
|
for index, commit := range self.c.Model().Commits {
|
|
if commit.Hash() == self.c.Git().Patch.PatchBuilder.To {
|
|
return index
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
func (self *PatchBuildingController) Escape() error {
|
|
context := self.c.Contexts().CustomPatchBuilder
|
|
state := context.GetState()
|
|
|
|
if state.SelectingRange() || state.SelectingHunkEnabledByUser() {
|
|
state.SetLineSelectMode()
|
|
self.c.PostRefreshUpdate(context)
|
|
return nil
|
|
}
|
|
|
|
self.c.Helpers().PatchBuilding.Escape()
|
|
return nil
|
|
}
|
|
|
|
func (self *PatchBuildingController) EscapeDescription() string {
|
|
context := self.c.Contexts().CustomPatchBuilder
|
|
if state := context.GetState(); state != nil {
|
|
if state.SelectingRange() {
|
|
return self.c.Tr.DismissRangeSelect
|
|
}
|
|
|
|
if state.SelectingHunkEnabledByUser() {
|
|
return self.c.Tr.SelectLineByLine
|
|
}
|
|
}
|
|
|
|
return self.c.Tr.ExitCustomPatchBuilder
|
|
}
|