Files
lazygit/pkg/gui/controllers/patch_building_controller.go
T
Stefan Haller 352883c52b Run the sync commit-surgery ops on a worker with input blocked
Move, revert, squash-fixups, create-fixup and cherry-pick paste ran
their rebase synchronously on the UI thread via WithWaitingStatusSync,
which froze the UI for the duration but kept the user from disrupting the
operation with a stray keypress. Switch them to
WithWaitingStatusBlockingInput so the git work runs on a worker — the UI
keeps rendering and the spinner animates — while input stays blocked for
the whole operation, as before.

discard-patch-from-commit also moves off WithWaitingStatusSync, but as a
plain WithWaitingStatus: it's a custom-patch command, and those don't
block input.

The bodies now follow the worker conventions: model state they need is
captured on the UI thread before dispatching, self.c.Refresh becomes
RefreshFromWorker, and CheckMergeOrRebase uses the worker variant. An
operation that moves the selection does so in the refresh's Then, so it
lands in the same frame as the refreshed commit list; squash sets it as
an absolute index there, because the shorter list would clamp a relative
move.
2026-07-17 12:32:32 +02:00

279 lines
7.9 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
}
commits := self.c.Model().Commits
commitIndex := self.getPatchCommitIndex()
return self.c.WithWaitingStatus(self.c.Tr.RebasingStatus, func(gocui.Task) error {
self.c.LogAction(self.c.Tr.Actions.RemovePatchFromCommit)
err := self.c.Git().Patch.DeletePatchesFromCommit(commits, commitIndex)
// Escape pops the patch-building context, so run it on the UI thread
// before the refresh below.
_ = self.c.GocuiGui().OnUIThreadAndWait(func() error {
self.c.Helpers().PatchBuilding.Escape()
return nil
})
return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions(
err, types.RefreshOptions{})
})
}
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
}