Withhold paste events together with their keys while blocking the UI (#6208)

While a long-running operation blocks input, lazygit buffers key events
and replays them when the operation ends. Paste events weren't buffered,
so they took effect right away. If the user pasted text during the
block, the paste has ended by the time its keys are replayed, and
lazygit treated them as typed keys. In a list panel, every character of
the text then ran the command bound to it, rather than ignoring them as
it would when the UI is not blocked.

To fix this, buffer the paste start/stop events in the same way as the
key events themselves, and replay them after unblocking along with
those.
This commit is contained in:
Stefan Haller
2026-11-11 19:50:54 +01:00
committed by GitHub
2 changed files with 53 additions and 29 deletions
+26 -3
View File
@@ -18,7 +18,7 @@ func TestEventWithheldWhileBlocking(t *testing.T) {
{"mouse move", GocuiEvent{Type: eventMouseMove}, true},
{"resize", GocuiEvent{Type: eventResize}, false},
{"focus", GocuiEvent{Type: eventFocus}, false},
{"paste", GocuiEvent{Type: eventPaste}, false},
{"paste", GocuiEvent{Type: eventPaste}, true},
{"error", GocuiEvent{Type: eventError}, false},
}
@@ -86,7 +86,7 @@ func TestBlockingEvents_KeysBufferedAndReplayed(t *testing.T) {
runQueuedWork(t, g)
assert.Len(t, *fired, 3, "both buffered keys should replay on unblock")
assert.Empty(t, g.bufferedKeyEvents)
assert.Empty(t, g.bufferedInputEvents)
}
func TestBlockingEvents_KeysArrivingBeforeTheReplayGoBehindIt(t *testing.T) {
@@ -111,6 +111,29 @@ func TestBlockingEvents_KeysArrivingBeforeTheReplayGoBehindIt(t *testing.T) {
assert.Equal(t, []int{1, 0}, *fired, "the keys should arrive in the order they were pressed")
}
func TestBlockingEvents_PastedKeysReplayedAsPasted(t *testing.T) {
g := newTestGui(t)
keyEvent, fired := setupKeyRecorder(t, g)
pasteStart := GocuiEvent{Type: eventPaste, Start: true}
pasteEnd := GocuiEvent{Type: eventPaste, Start: false}
// Pasting into a view that isn't editable doesn't run its keybindings.
assert.NoError(t, g.handleEvent(&pasteStart))
assert.NoError(t, g.handleEvent(&keyEvent))
assert.NoError(t, g.handleEvent(&pasteEnd))
assert.Empty(t, *fired)
// The same must hold for a paste that arrives while input is blocked.
g.BeginBlockingEvents()
assert.NoError(t, g.handleEvent(&pasteStart))
assert.NoError(t, g.handleEvent(&keyEvent))
assert.NoError(t, g.handleEvent(&pasteEnd))
g.EndBlockingEvents()
runQueuedWork(t, g)
assert.Empty(t, *fired)
assert.False(t, g.IsPasting)
}
func TestBlockingEvents_NestsWithCounter(t *testing.T) {
g := newTestGui(t)
keyEvent, fired := setupKeyRecorder(t, g)
@@ -136,7 +159,7 @@ func TestBlockingEvents_MouseClicksDroppedNotBuffered(t *testing.T) {
g.BeginBlockingEvents()
click := GocuiEvent{Type: eventMouse, Key: NewKeyName(MouseLeft)}
assert.NoError(t, g.handleEvent(&click))
assert.Empty(t, g.bufferedKeyEvents, "mouse clicks must be dropped, not buffered")
assert.Empty(t, g.bufferedInputEvents, "mouse clicks must be dropped, not buffered")
g.EndBlockingEvents()
runQueuedWork(t, g)
}
+27 -26
View File
@@ -242,15 +242,15 @@ type Gui struct {
colorSchemeHandler func(DetectedColorScheme) error
// blockInputCount, when greater than zero, withholds keyboard input from
// the handlers: key events are buffered into bufferedKeyEvents and replayed
// once the count drops back to zero, while mouse clicks and hover are
// dropped outright. It's a counter so blocking can nest. replayPending says
// that the replay is queued but hasn't run yet, and input is withheld until
// it has. All three fields are only touched on the UI thread. See
// BeginBlockingEvents.
blockInputCount int
bufferedKeyEvents []GocuiEvent
replayPending bool
// the handlers: key and paste events are buffered into bufferedInputEvents
// and replayed once the count drops back to zero, while mouse clicks and
// hover are dropped outright. It's a counter so blocking can nest.
// replayPending says that the replay is queued but hasn't run yet, and
// input is withheld until it has. All three fields are only touched on the
// UI thread. See BeginBlockingEvents.
blockInputCount int
bufferedInputEvents []GocuiEvent
replayPending bool
}
type NewGuiOpts struct {
@@ -929,9 +929,10 @@ func (g *Gui) IsUIThread() bool {
// BeginBlockingEvents starts withholding keyboard input from the handlers, so a
// long-running operation can't be disrupted by keys the user presses while it
// runs. Keys are buffered and replayed once EndBlockingEvents balances this
// call; mouse clicks and hover are dropped for the duration. Scrolling,
// resizing, focus changes and all rendering keep working throughout. It's a
// counter, so blocking nests; every call must be paired with EndBlockingEvents.
// call, together with the start and end of a paste around them; mouse clicks
// and hover are dropped for the duration. Scrolling, resizing, focus changes
// and all rendering keep working throughout. It's a counter, so blocking nests;
// every call must be paired with EndBlockingEvents.
//
// Must be called on the UI thread. Callers arrange this by beginning the block
// synchronously from the keybinding handler, before dispatching the operation
@@ -963,18 +964,18 @@ func (g *Gui) EndBlockingEvents() {
// before the replay gets its turn would otherwise be handled ahead of the keys
// buffered before it.
g.replayPending = true
g.Update(func(*Gui) error { return g.replayBufferedKeys() })
g.Update(func(*Gui) error { return g.replayBufferedInput() })
}
// replayBufferedKeys dispatches the keys withheld while input was blocked, and
// replayBufferedInput dispatches the keys withheld while input was blocked, and
// lets input through again. One of their handlers may block input afresh, and
// then the keys after it are withheld in their turn, to be replayed when that
// block ends.
func (g *Gui) replayBufferedKeys() error {
func (g *Gui) replayBufferedInput() error {
g.replayPending = false
buffered := g.bufferedKeyEvents
g.bufferedKeyEvents = nil
buffered := g.bufferedInputEvents
g.bufferedInputEvents = nil
for i := range buffered {
if err := g.handleEvent(&buffered[i]); err != nil {
return err
@@ -1251,9 +1252,11 @@ func (g *Gui) processRemainingEvents() (bool, error) {
// etc.)
func (g *Gui) handleEvent(ev *GocuiEvent) error {
if g.withholdingInput() && eventWithheldWhileBlocking(ev) {
if ev.Type == eventKey {
// Buffer keys so they replay against fresh state on unblock.
g.bufferedKeyEvents = append(g.bufferedKeyEvents, *ev)
if ev.Type == eventKey || ev.Type == eventPaste {
// Buffer keys so they replay against fresh state on unblock. Buffer
// the start and end of a paste with them, so that pasted keys are
// still handled as pasted when they replay.
g.bufferedInputEvents = append(g.bufferedInputEvents, *ev)
}
// Mouse clicks and hover fall through to here without being buffered:
// replaying them once the operation has changed the layout underneath
@@ -1287,13 +1290,13 @@ func (g *Gui) withholdingInput() bool {
}
// eventWithheldWhileBlocking reports whether an event must not reach the
// handlers while input is blocked (see BeginBlockingEvents). Key events are
// withheld (buffered for replay); mouse clicks and hover are withheld (dropped).
// Everything else — mouse scrolling, resize, focus, paste, errors — flows
// handlers while input is blocked (see BeginBlockingEvents). Key and paste
// events are withheld (buffered for replay); mouse clicks and hover are withheld
// (dropped). Everything else — mouse scrolling, resize, focus, errors — flows
// through as usual.
func eventWithheldWhileBlocking(ev *GocuiEvent) bool {
switch ev.Type {
case eventKey:
case eventKey, eventPaste:
return true
case eventMouse:
return !IsMouseScrollKey(ev.Key.KeyName())
@@ -1752,8 +1755,6 @@ func (g *Gui) draw(v *View) {
func (g *Gui) onKey(ev *GocuiEvent) error {
switch ev.Type {
case eventKey:
// newlines. I actually don't quite understand why, because from reading
// When pasting text in Ghostty, it sends us '\r' (which is delivered as
// ctrl-j by tcell) instead of '\n' for newlines. I actually don't quite
// understand why, because from reading Ghostty's source code (e.g.