mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-08-24 10:13:41 -05:00
Add gocui primitives to block input during an operation
Long-running operations that lazygit drives itself (rebases, and the commit surgery built on them) can be corrupted by keys the user presses while they run: pressing e to start an interactive rebase, then up+d before it finishes, must act on the resulting todo list, not race the rebase. WithWaitingStatusSync gets this today only as a side effect of freezing the UI thread, which the rest of this branch is moving away from. Add a nestable counter, BeginBlockingEvents/EndBlockingEvents, that withholds input at the event-dispatch layer without freezing anything: while blocked, key events are buffered and replayed in order once the count returns to zero (so they act on the now-current context), mouse clicks and hover are dropped (replaying them against a changed layout would target the wrong thing), and scrolling, resize, focus and all rendering keep flowing. These are the reusable core; a gui-level helper that brackets them around a worker operation follows. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
88811e6795
commit
6893d9a759
@@ -0,0 +1,98 @@
|
||||
package gocui
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestEventWithheldWhileBlocking(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
name string
|
||||
event GocuiEvent
|
||||
withheld bool
|
||||
}{
|
||||
{"key", GocuiEvent{Type: eventKey, Key: NewKeyRune('x')}, true},
|
||||
{"mouse click", GocuiEvent{Type: eventMouse, Key: NewKeyName(MouseLeft)}, true},
|
||||
{"mouse scroll", GocuiEvent{Type: eventMouse, Key: NewKeyName(MouseWheelDown)}, false},
|
||||
{"mouse move", GocuiEvent{Type: eventMouseMove}, true},
|
||||
{"resize", GocuiEvent{Type: eventResize}, false},
|
||||
{"focus", GocuiEvent{Type: eventFocus}, false},
|
||||
{"paste", GocuiEvent{Type: eventPaste}, false},
|
||||
{"error", GocuiEvent{Type: eventError}, false},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
assert.Equal(t, s.withheld, eventWithheldWhileBlocking(&s.event))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// setupKeyRecorder wires a keybinding on a focused view that records each time
|
||||
// it fires, and returns the key event that triggers it plus the record slice.
|
||||
func setupKeyRecorder(t *testing.T, g *Gui) (GocuiEvent, *[]int) {
|
||||
t.Helper()
|
||||
|
||||
_, _ = g.SetView("main", 0, 0, 80, 22, 0)
|
||||
_, err := g.SetCurrentView("main")
|
||||
assert.NoError(t, err)
|
||||
|
||||
fired := []int{}
|
||||
callCount := 0
|
||||
key := NewKeyRune('x')
|
||||
g.SetKeybinding("main", key, func(*Gui, *View) error {
|
||||
callCount++
|
||||
fired = append(fired, callCount)
|
||||
return nil
|
||||
})
|
||||
|
||||
return GocuiEvent{Type: eventKey, Key: key}, &fired
|
||||
}
|
||||
|
||||
func TestBlockingEvents_KeysBufferedAndReplayed(t *testing.T) {
|
||||
g := newTestGui(t)
|
||||
keyEvent, fired := setupKeyRecorder(t, g)
|
||||
|
||||
// Not blocking: the key dispatches immediately.
|
||||
assert.NoError(t, g.handleEvent(&keyEvent))
|
||||
assert.Len(t, *fired, 1)
|
||||
|
||||
// While blocking: the key is buffered, not dispatched.
|
||||
g.BeginBlockingEvents()
|
||||
assert.NoError(t, g.handleEvent(&keyEvent))
|
||||
assert.NoError(t, g.handleEvent(&keyEvent))
|
||||
assert.Len(t, *fired, 1, "buffered keys must not dispatch while blocking")
|
||||
|
||||
// Unblocking replays the buffered keys.
|
||||
assert.NoError(t, g.EndBlockingEvents())
|
||||
assert.Len(t, *fired, 3, "both buffered keys should replay on unblock")
|
||||
assert.Empty(t, g.bufferedKeyEvents)
|
||||
}
|
||||
|
||||
func TestBlockingEvents_NestsWithCounter(t *testing.T) {
|
||||
g := newTestGui(t)
|
||||
keyEvent, fired := setupKeyRecorder(t, g)
|
||||
|
||||
g.BeginBlockingEvents()
|
||||
g.BeginBlockingEvents()
|
||||
assert.NoError(t, g.handleEvent(&keyEvent))
|
||||
|
||||
// The inner block ending still leaves us blocked: no replay yet.
|
||||
assert.NoError(t, g.EndBlockingEvents())
|
||||
assert.Empty(t, *fired)
|
||||
|
||||
// Only the outermost block ending replays.
|
||||
assert.NoError(t, g.EndBlockingEvents())
|
||||
assert.Len(t, *fired, 1)
|
||||
}
|
||||
|
||||
func TestBlockingEvents_MouseClicksDroppedNotBuffered(t *testing.T) {
|
||||
g := newTestGui(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.NoError(t, g.EndBlockingEvents())
|
||||
}
|
||||
@@ -210,6 +210,14 @@ type Gui struct {
|
||||
// MainLoop starts. IsUIThread compares against it. Written once, read from
|
||||
// worker goroutines, so it's atomic.
|
||||
uiThreadID atomic.Int64
|
||||
|
||||
// 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. Both fields are
|
||||
// only touched on the UI thread. See BeginBlockingEvents.
|
||||
blockInputCount int
|
||||
bufferedKeyEvents []GocuiEvent
|
||||
}
|
||||
|
||||
type NewGuiOpts struct {
|
||||
@@ -806,6 +814,42 @@ func (g *Gui) IsUIThread() bool {
|
||||
return goid.Get() == g.uiThreadID.Load()
|
||||
}
|
||||
|
||||
// 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.
|
||||
//
|
||||
// Must be called on the UI thread. Callers arrange this by beginning the block
|
||||
// synchronously from the keybinding handler, before dispatching the operation
|
||||
// to a worker — beginning it from the worker would race the next queued
|
||||
// keypress, which is exactly the input we mean to withhold.
|
||||
func (g *Gui) BeginBlockingEvents() {
|
||||
g.blockInputCount++
|
||||
}
|
||||
|
||||
// EndBlockingEvents balances a BeginBlockingEvents call. When the last nested
|
||||
// block ends, the keys buffered while blocked are replayed in order through the
|
||||
// normal dispatch path, so they act on the now-current context (a key whose
|
||||
// binding no longer exists is simply ignored, just as if it had been pressed
|
||||
// now). Must be called on the UI thread.
|
||||
func (g *Gui) EndBlockingEvents() error {
|
||||
g.blockInputCount--
|
||||
if g.blockInputCount > 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
buffered := g.bufferedKeyEvents
|
||||
g.bufferedKeyEvents = nil
|
||||
for i := range buffered {
|
||||
if err := g.handleEvent(&buffered[i]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// OnUIThreadAndWait runs f on the main event-loop goroutine and blocks the
|
||||
// caller until f has run, returning f's error. Use it to read UI-thread-owned
|
||||
// state (the model, contexts) from a worker without racing the UI thread.
|
||||
@@ -1052,6 +1096,17 @@ func (g *Gui) processRemainingEvents() (bool, error) {
|
||||
// handleEvent handles an event, based on its type (key-press, error,
|
||||
// etc.)
|
||||
func (g *Gui) handleEvent(ev *GocuiEvent) error {
|
||||
if g.blockInputCount > 0 && eventWithheldWhileBlocking(ev) {
|
||||
if ev.Type == eventKey {
|
||||
// Buffer keys so they replay against fresh state on unblock.
|
||||
g.bufferedKeyEvents = append(g.bufferedKeyEvents, *ev)
|
||||
}
|
||||
// Mouse clicks and hover fall through to here without being buffered:
|
||||
// replaying them once the operation has changed the layout underneath
|
||||
// them would target the wrong thing, so we drop them outright.
|
||||
return nil
|
||||
}
|
||||
|
||||
switch ev.Type {
|
||||
case eventKey, eventMouse, eventMouseMove:
|
||||
return g.onKey(ev)
|
||||
@@ -1070,6 +1125,24 @@ func (g *Gui) handleEvent(ev *GocuiEvent) error {
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// through as usual.
|
||||
func eventWithheldWhileBlocking(ev *GocuiEvent) bool {
|
||||
switch ev.Type {
|
||||
case eventKey:
|
||||
return true
|
||||
case eventMouse:
|
||||
return !IsMouseScrollKey(ev.Key.KeyName())
|
||||
case eventMouseMove:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (g *Gui) onResize() {
|
||||
// not sure if we actually need this
|
||||
// g.screen.Sync()
|
||||
|
||||
Reference in New Issue
Block a user