Block input while the refresh after a stash operation is in flight

Popping or dropping a stash shifts the indices of the entries below it,
and renaming re-creates the stash at the top, shifting all the others.
The stash model is only rebuilt by the refresh, which finishes in the
background, so acting on the next entry in quick succession — pressing
the key, confirming the popup, and pressing again right away — reads the
stale pre-operation indices and targets the wrong stash. Note that the
confirmation popup is no protection here: the race starts when the
confirm handler runs, and the next keypress can easily beat the refresh.

Use RefreshBlockingInput so a quick follow-up keypress is buffered and
replayed once the refreshed stash list is in place.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Stefan Haller 2026-07-21 19:33:39 +02:00
parent 055184f997
commit fc975f32a8

View file

@ -170,13 +170,16 @@ func (self *StashController) handleStashDrop(stashEntries []*models.StashEntry)
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: an async
// 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.
defer self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.STASH}})
// 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. 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.
defer self.c.RefreshBlockingInput(types.RefreshOptions{Scope: []types.RefreshableView{types.STASH}})
for i := len(stashEntries) - 1; i >= 0; i-- {
self.c.LogCommand(fmt.Sprintf(self.c.Tr.Log.DroppingStash, stashEntries[i].Hash), false)
if err := self.c.Git().Stash.Drop(stashEntries[i].Index); err != nil {
@ -192,7 +195,11 @@ func (self *StashController) handleStashDrop(stashEntries []*models.StashEntry)
}
func (self *StashController) postStashRefresh() {
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.STASH, types.FILES}})
// Block input until the refresh has landed: popping shifts the indices of
// the remaining stash entries, and acting on the next entry in quick
// succession (confirming the popup and pressing the key again right away)
// must see the refreshed list, or it would target the wrong stash.
self.c.RefreshBlockingInput(types.RefreshOptions{Scope: []types.RefreshableView{types.STASH, types.FILES}})
}
func (self *StashController) handleNewBranchOffStashEntry(stashEntry *models.StashEntry) error {
@ -214,12 +221,15 @@ func (self *StashController) handleRenameStashEntry(stashEntry *models.StashEntr
self.c.LogAction(self.c.Tr.Actions.RenameStash)
err := self.c.Git().Stash.Rename(stashEntry.Index, response)
if err != nil {
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.STASH}})
self.c.RefreshBlockingInput(types.RefreshOptions{Scope: []types.RefreshableView{types.STASH}})
return err
}
self.context().SetSelection(0) // Select the renamed stash
self.context().FocusLine(true)
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.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,