mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-10-06 14:06:32 -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>
268 lines
8.9 KiB
Go
268 lines
8.9 KiB
Go
package controllers
|
|
|
|
import (
|
|
"fmt"
|
|
|
|
"github.com/jesseduffield/lazygit/pkg/commands/models"
|
|
"github.com/jesseduffield/lazygit/pkg/gocui"
|
|
"github.com/jesseduffield/lazygit/pkg/gui/context"
|
|
"github.com/jesseduffield/lazygit/pkg/gui/style"
|
|
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
|
"github.com/jesseduffield/lazygit/pkg/utils"
|
|
)
|
|
|
|
type StashController struct {
|
|
baseController
|
|
*ListControllerTrait[*models.StashEntry]
|
|
c *ControllerCommon
|
|
}
|
|
|
|
var _ types.IController = &StashController{}
|
|
|
|
func NewStashController(
|
|
c *ControllerCommon,
|
|
) *StashController {
|
|
return &StashController{
|
|
baseController: baseController{},
|
|
ListControllerTrait: NewListControllerTrait(
|
|
c,
|
|
c.Contexts().Stash,
|
|
c.Contexts().Stash.GetSelected,
|
|
c.Contexts().Stash.GetSelectedItems,
|
|
),
|
|
c: c,
|
|
}
|
|
}
|
|
|
|
func (self *StashController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
|
|
bindings := []*types.Binding{
|
|
{
|
|
Keys: opts.GetKeys(opts.Config.Universal.Select),
|
|
Handler: self.withItem(self.handleStashApply),
|
|
GetDisabledReason: self.require(self.singleItemSelected()),
|
|
Description: self.c.Tr.Apply,
|
|
Tooltip: self.c.Tr.StashApplyTooltip,
|
|
DisplayOnScreen: true,
|
|
},
|
|
{
|
|
Keys: opts.GetKeys(opts.Config.Stash.PopStash),
|
|
Handler: self.withItem(self.handleStashPop),
|
|
GetDisabledReason: self.require(self.singleItemSelected()),
|
|
Description: self.c.Tr.Pop,
|
|
Tooltip: self.c.Tr.StashPopTooltip,
|
|
DisplayOnScreen: true,
|
|
},
|
|
{
|
|
Keys: opts.GetKeys(opts.Config.Universal.Remove),
|
|
Handler: self.withItems(self.handleStashDrop),
|
|
GetDisabledReason: self.require(self.itemRangeSelected()),
|
|
Description: self.c.Tr.Drop,
|
|
Tooltip: self.c.Tr.StashDropTooltip,
|
|
DisplayOnScreen: true,
|
|
},
|
|
{
|
|
Keys: opts.GetKeys(opts.Config.Universal.New),
|
|
Handler: self.withItem(self.handleNewBranchOffStashEntry),
|
|
GetDisabledReason: self.require(self.singleItemSelected()),
|
|
Description: self.c.Tr.NewBranch,
|
|
Tooltip: self.c.Tr.NewBranchFromStashTooltip,
|
|
},
|
|
{
|
|
Keys: opts.GetKeys(opts.Config.Universal.NewWorktree),
|
|
Handler: self.withItem(self.c.Helpers().Worktree.NewWorktreeMenuForStash),
|
|
Description: self.c.Tr.NewWorktree,
|
|
OpensMenu: true,
|
|
},
|
|
{
|
|
Keys: opts.GetKeys(opts.Config.Stash.RenameStash),
|
|
Handler: self.withItem(self.handleRenameStashEntry),
|
|
GetDisabledReason: self.require(self.singleItemSelected()),
|
|
Description: self.c.Tr.RenameStash,
|
|
},
|
|
}
|
|
|
|
return bindings
|
|
}
|
|
|
|
func (self *StashController) GetOnRenderToMain() func() {
|
|
return func() {
|
|
self.c.Helpers().Diff.WithDiffModeCheck(func() {
|
|
var task types.UpdateTask
|
|
stashEntry := self.context().GetSelected()
|
|
if stashEntry == nil {
|
|
task = types.NewRenderStringTask(self.c.Tr.NoStashEntries)
|
|
} else {
|
|
mode := self.c.Helpers().DiffLine.MainViewDiffMode()
|
|
prefix := style.FgYellow.Sprintf("%s\n\n", stashEntry.Description())
|
|
task = types.NewMainViewDiffTaskWithPrefix(
|
|
self.c.Git().Stash.ShowStashEntryCmdObj(stashEntry.Index, mode).GetCmd(),
|
|
prefix,
|
|
mode,
|
|
)
|
|
}
|
|
|
|
self.c.RenderToMainViews(types.RefreshMainOpts{
|
|
Pair: self.c.MainViewPairs().Normal,
|
|
Main: &types.ViewUpdateOpts{
|
|
Title: "Stash",
|
|
SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(),
|
|
Task: task,
|
|
},
|
|
Secondary: secondaryPatchPanelUpdateOpts(self.c),
|
|
})
|
|
})
|
|
}
|
|
}
|
|
|
|
func (self *StashController) context() *context.StashContext {
|
|
return self.c.Contexts().Stash
|
|
}
|
|
|
|
func (self *StashController) handleStashApply(stashEntry *models.StashEntry) error {
|
|
return self.c.ConfirmIf(!self.c.UserConfig().Gui.SkipStashWarning,
|
|
types.ConfirmOpts{
|
|
Title: self.c.Tr.StashApply,
|
|
Prompt: self.c.Tr.SureApplyStashEntry,
|
|
HandleConfirm: func() error {
|
|
return self.c.WithWaitingStatusBlockingInput(
|
|
types.WaitingStatusOpts{Message: self.c.Tr.ApplyingStashStatus},
|
|
func(gocui.Task) error {
|
|
self.c.LogAction(self.c.Tr.Actions.ApplyStash)
|
|
err := self.c.Git().Stash.Apply(stashEntry.Index)
|
|
self.postStashRefresh(err == nil && self.c.UserConfig().Gui.SwitchToFilesAfterStashApply)
|
|
return err
|
|
})
|
|
},
|
|
})
|
|
}
|
|
|
|
func (self *StashController) handleStashPop(stashEntry *models.StashEntry) error {
|
|
pop := func() error {
|
|
return self.c.WithWaitingStatusBlockingInput(
|
|
types.WaitingStatusOpts{Message: self.c.Tr.PoppingStashStatus},
|
|
func(gocui.Task) error {
|
|
self.c.LogAction(self.c.Tr.Actions.PopStash)
|
|
self.c.LogCommand(fmt.Sprintf(self.c.Tr.Log.PoppingStash, stashEntry.Hash), false)
|
|
err := self.c.Git().Stash.Pop(stashEntry.Index)
|
|
self.postStashRefresh(err == nil && self.c.UserConfig().Gui.SwitchToFilesAfterStashPop)
|
|
return err
|
|
})
|
|
}
|
|
|
|
if self.c.UserConfig().Gui.SkipStashWarning {
|
|
return pop()
|
|
}
|
|
|
|
self.c.Confirm(types.ConfirmOpts{
|
|
Title: self.c.Tr.StashPop,
|
|
Prompt: self.c.Tr.SurePopStashEntry,
|
|
HandleConfirm: func() error {
|
|
return pop()
|
|
},
|
|
})
|
|
|
|
return nil
|
|
}
|
|
|
|
func (self *StashController) handleStashDrop(stashEntries []*models.StashEntry) error {
|
|
self.c.Confirm(types.ConfirmOpts{
|
|
Title: self.c.Tr.StashDrop,
|
|
Prompt: self.c.Tr.SureDropStashEntry,
|
|
HandleConfirm: func() error {
|
|
self.c.LogAction(self.c.Tr.Actions.DropStash)
|
|
// Refresh once at the end rather than after each drop: a refresh
|
|
// from the UI thread finishes in the background, so firing one per
|
|
// iteration lets the workers race and an earlier, stale result can
|
|
// land last. The indices are captured up front and we drop
|
|
// highest-first, so the remaining lower indices stay valid without
|
|
// an intervening refresh.
|
|
var dropErr error
|
|
for i := len(stashEntries) - 1; i >= 0; i-- {
|
|
self.c.LogCommand(fmt.Sprintf(self.c.Tr.Log.DroppingStash, stashEntries[i].Hash), false)
|
|
if dropErr = self.c.Git().Stash.Drop(stashEntries[i].Index); dropErr != nil {
|
|
break
|
|
}
|
|
}
|
|
// Block input until the refresh has landed, so that dropping the
|
|
// next entry in quick succession (confirming and pressing the key
|
|
// again right away) sees the refreshed list and not the stale,
|
|
// pre-drop indices.
|
|
self.c.RefreshBlockingInput(types.RefreshOptions{
|
|
Scope: []types.RefreshableView{types.STASH},
|
|
Then: func() error {
|
|
// Collapse the range selection from here, so that it lands
|
|
// in the same frame as the shortened list. The refresh has
|
|
// painted the list by the time Then runs, so the new
|
|
// selection needs a focus update of its own.
|
|
if dropErr == nil {
|
|
self.context().CollapseRangeSelectionToTop()
|
|
self.context().HandleFocus(types.OnFocusOpts{})
|
|
}
|
|
return nil
|
|
},
|
|
})
|
|
return dropErr
|
|
},
|
|
})
|
|
|
|
return nil
|
|
}
|
|
|
|
// postStashRefresh refreshes the panels that applying or popping a stash
|
|
// affects, moving the focus to the files panel if switchToFiles is set.
|
|
//
|
|
// Call it from the worker that ran the stash command, from inside a
|
|
// WithWaitingStatusBlockingInput: popping shifts the indices of the remaining
|
|
// stash entries, so acting on the next entry in quick succession (confirming
|
|
// the popup and pressing the key again right away) has to be held off until
|
|
// the refreshed list is in place, or it would target the wrong stash.
|
|
func (self *StashController) postStashRefresh(switchToFiles bool) {
|
|
self.c.RefreshFromWorker(types.RefreshOptions{
|
|
BatchUIUpdates: true,
|
|
Scope: []types.RefreshableView{types.STASH, types.FILES},
|
|
Then: func() error {
|
|
// Switch panels from here, so that the focus change lands in the
|
|
// same frame as the refreshed panel contents.
|
|
if switchToFiles {
|
|
self.c.Context().Push(self.c.Contexts().Files, types.OnFocusOpts{})
|
|
}
|
|
return nil
|
|
},
|
|
})
|
|
}
|
|
|
|
func (self *StashController) handleNewBranchOffStashEntry(stashEntry *models.StashEntry) error {
|
|
return self.c.Helpers().Refs.NewBranch(stashEntry.FullRefName(), stashEntry.Description(), "")
|
|
}
|
|
|
|
func (self *StashController) handleRenameStashEntry(stashEntry *models.StashEntry) error {
|
|
message := utils.ResolvePlaceholderString(
|
|
self.c.Tr.RenameStashPrompt,
|
|
map[string]string{
|
|
"stashName": stashEntry.RefName(),
|
|
},
|
|
)
|
|
|
|
self.c.Prompt(types.PromptOpts{
|
|
Title: message,
|
|
InitialContent: stashEntry.Name,
|
|
HandleConfirm: func(response string) error {
|
|
self.c.LogAction(self.c.Tr.Actions.RenameStash)
|
|
err := self.c.Git().Stash.Rename(stashEntry.Index, response)
|
|
if err != nil {
|
|
self.c.RefreshBlockingInput(types.RefreshOptions{Scope: []types.RefreshableView{types.STASH}})
|
|
return err
|
|
}
|
|
self.context().SetSelection(0) // Select the renamed stash
|
|
// Renaming re-creates the stash at the top, shifting the other
|
|
// entries' indices; block input so that a quick next action sees
|
|
// the refreshed list rather than the stale indices.
|
|
self.c.RefreshBlockingInput(types.RefreshOptions{Scope: []types.RefreshableView{types.STASH}})
|
|
return nil
|
|
},
|
|
AllowEmptyInput: true,
|
|
})
|
|
|
|
return nil
|
|
}
|