Add RefreshBlockingInput to buffer keypresses until a refresh has landed (#5844)

A refresh from the UI thread returns immediately and applies its model
and view updates as queued UI-thread callbacks. A key pressed before
those have run is handled against the stale, pre-refresh state. For most
keys that's harmless, but some handlers turn that state into git
commands: pressing space twice in quick succession in the staging panel
builds the second patch from the already-applied diff and fails with
'patch does not apply', because the refresh after the first press is
what moves the selection to the next stageable hunk.

Notably, this is not just a regression of the recent change that made
UI-thread refreshes non-blocking; the window was merely much narrower
before. A blocking refresh parked the UI thread while the scopes'
bounces were queued, and the event loop drains pending keyboard input
with priority over queued user events, so a key pressed during the
blocked window still beat the queued state updates. The guarantee that
the next keypress sees post-refresh state had already ended when the
scopes' state updates moved from worker-side mutex-guarded writes to
UI-thread bounces.

Fix it with the input-blocking mechanism we already use for commit
surgery, exposed as a new RefreshBlockingInput entry point: it begins
blocking events synchronously in the calling handler, and ends the block
from a callback that the finishing step queues behind the refresh's own
updates. Keys pressed while the refresh is in flight are buffered and
replayed, in order, against the fully refreshed state; since a replayed
key's handler re-enters this same path, a burst of keypresses applies
sequentially, each one seeing the previous one's refresh. Unlike the old
blocking refreshes, this doesn't freeze the UI thread: rendering,
spinners, resizing, and mouse scrolling keep working while input is
withheld.

Blocking input is opt-in per call site rather than the default for all
UI-thread refreshes, because most refreshes (the focus-in and startup
refreshes, say) don't produce state that the next keypress depends on,
and blocking on them would delay typing for no reason. It should also be
limited to quick, narrow-scoped refreshes: a full refresh, or any scope
that pulls in COMMITS, can take very long in large repos and should
usually not hold up input.

Use the new function for the staging panel's stage/discard/edit-hunk
refreshes, for moving rebase todos, and for stash operations.

Labelling as ignore-for-release because it fixes regressions introduced
after the last release.
This commit is contained in:
Stefan Haller 2026-07-22 09:05:38 +02:00 committed by GitHub
commit d8b07ee4f5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 283 additions and 46 deletions

View file

@ -139,16 +139,17 @@ func (self *InlineStatusHelper) stop(opts InlineStatusOpts) {
self.c.State().ClearItemOperation(opts.Item)
// Re-render the context to remove the inline status now that the operation
// finished. Any refresh it triggered must be synchronous, not async: by the
// time we get here a synchronous refresh has already updated the model and
// queued its own re-render, and since UI-thread callbacks run in order, the
// render we queue here runs after it and draws the up-to-date model without
// the inline status. An async refresh might not have updated the model yet,
// so this render could briefly show the stale, pre-operation model: when
// pushing a branch, for example, it would flash the old ↑3↓7 ahead/behind
// counts for a moment before the refresh replaced them with a green
// checkmark. (Operations that don't refresh at all are fine too: there's
// nothing stale to show, so this just drops the status.)
// finished. The operation must trigger its refresh via RefreshFromWorker
// before we get here: that call returns only once the refresh's model
// updates have been enqueued on the UI thread, and since UI-thread
// callbacks run in order, the render we queue here runs after them and
// draws the up-to-date model without the inline status. A refresh whose
// model updates aren't enqueued yet by this point would make this render
// briefly show the stale, pre-operation model: when pushing a branch, for
// example, it would flash the old ↑3↓7 ahead/behind counts for a moment
// before the refresh replaced them with a green checkmark. (Operations
// that don't refresh at all are fine too: there's nothing stale to show,
// so this just drops the status.)
self.renderContext(opts.ContextKey)
}

View file

@ -81,14 +81,20 @@ func NewRefreshHelper(
}
func (self *RefreshHelper) Refresh(options types.RefreshOptions) {
self.performRefresh(options, false)
self.performRefresh(options, false, false)
}
// RefreshBlockingInput is Refresh for handlers whose next keypress may depend
// on the state the refresh produces. See IGuiCommon.RefreshBlockingInput.
func (self *RefreshHelper) RefreshBlockingInput(options types.RefreshOptions) {
self.performRefresh(options, false, true)
}
// RefreshFromWorker is Refresh for callers already running on a worker
// goroutine (e.g. inside a WithWaitingStatus handler) rather than the UI
// thread. See IGuiCommon.RefreshFromWorker.
func (self *RefreshHelper) RefreshFromWorker(options types.RefreshOptions) {
self.performRefresh(options, true)
self.performRefresh(options, true, false)
}
type refreshEnv struct {
@ -159,7 +165,7 @@ func (self *refreshBounceBatch) close() []func() {
return self.funcs
}
func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFromWorker bool) {
func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFromWorker bool, blockInput bool) {
startTime := time.Now()
// A refresh from a worker blocks that worker until it's done; one from the
@ -192,6 +198,17 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr
panic("a refresh with a Then callback must not set DontBlockRepoSwitch")
}
// A RefreshBlockingInput caller wants keyboard input withheld until the
// refreshed state is in place (see IGuiCommon.RefreshBlockingInput). Begin
// the block synchronously here in the calling handler, so that no keypress
// can slip through before it; the finishing step ends it from a callback
// queued behind the refresh's own updates (see waitAndFinalize). Demos
// take the blocking inline path below and need none of this.
blockInputUntilDone := blockInput && !self.c.InDemo()
if blockInputUntilDone {
self.c.GocuiGui().BeginBlockingEvents()
}
// Capture the refresh's baseline once, here at the start: the repo
// generation that every scope's bounce is guarded against, and the git
// command instance the scopes run their commands through. The two are
@ -498,6 +515,15 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr
self.onUIThread(env.background, options.Then)
}
if blockInputUntilDone {
// Queued after the scopes' model bounces and Then, so by the time
// this runs — and the keys buffered during the refresh replay —
// the refreshed state is in place.
self.c.OnUIThread(func() error {
return self.c.GocuiGui().EndBlockingEvents()
})
}
self.c.Log.Infof("Refresh took %s", time.Since(startTime))
}
@ -1216,11 +1242,11 @@ func (self *RefreshHelper) onUIThread(background bool, f func() error) {
// runs on the UI thread (calledFromWorker is false) fn runs inline; when it runs
// on a worker, fn is dispatched to the UI thread and we block for it.
//
// The inline case matters for correctness as much as the hop: a SYNC refresh
// initiated on the UI thread parks that thread in a wg.Wait while its scope
// workers run, so a scope worker that tried to hop to the UI thread there would
// deadlock. Capturing before those workers are spawned — inline, on the UI
// thread — avoids that entirely.
// The inline case matters for correctness as much as the hop: OnUIThreadAndWait
// must not be called from the UI thread itself (it would park the thread
// waiting for a callback that only it can run), and capturing inline also
// guarantees the snapshot reflects the state at the moment Refresh was called,
// before the calling handler regains control and can mutate it.
func (self *RefreshHelper) captureOnUIThread(calledFromWorker bool, background bool, fn func()) {
if !calledFromWorker {
fn()

View file

@ -157,12 +157,17 @@ func (self *RefsHelper) CheckoutRemoteBranch(fullBranchName string, localBranchN
if err := self.c.Git().Branch.CreateWithUpstream(localBranchName, fullBranchName); err != nil {
return err
}
// Do a sync refresh to make sure the new branch is visible,
// so that we see an inline status when checking it out
// Refresh the branches and check out from Then, so that the
// new branch is already in the model when CheckoutRef looks
// it up; that's what makes it show an inline status on the
// branch rather than a global waiting status.
self.c.Refresh(types.RefreshOptions{
Scope: []types.RefreshableView{types.BRANCHES},
Then: func() error {
return checkout(localBranchName, true)
},
})
return checkout(localBranchName, true)
return nil
},
},
{

View file

@ -741,7 +741,10 @@ func (self *LocalCommitsController) moveDown(selectedCommits []*models.Commit, s
self.context().MoveSelection(1)
self.context().HandleFocus(types.OnFocusOpts{ScrollSelectionIntoView: true})
self.c.Refresh(types.RefreshOptions{
// Block input until the refresh has landed: a quick second press must
// read the moved todo from the refreshed model, not grab whatever the
// advanced selection index points at in the stale one.
self.c.RefreshBlockingInput(types.RefreshOptions{
Scope: []types.RefreshableView{types.REBASE_COMMITS},
CommitSelection: types.KeepCommitSelectionIndex,
})
@ -777,7 +780,8 @@ func (self *LocalCommitsController) moveUp(selectedCommits []*models.Commit, sta
self.context().MoveSelection(-1)
self.context().HandleFocus(types.OnFocusOpts{ScrollSelectionIntoView: true})
self.c.Refresh(types.RefreshOptions{
// Block input for the same reason as in moveDown.
self.c.RefreshBlockingInput(types.RefreshOptions{
Scope: []types.RefreshableView{types.REBASE_COMMITS},
CommitSelection: types.KeepCommitSelectionIndex,
})

View file

@ -159,8 +159,7 @@ func (self *RemotesController) addAndCheckoutRemote(remoteName string, remoteUrl
// Refresh the remotes so that we can select the new one. The remotes model
// update is bounced onto the UI thread, so the selection (which reads
// Model.Remotes) has to run in Then; reading it inline here would see the
// previous model. Loading remotes is not expensive, so a sync refresh is
// affordable.
// previous model.
self.c.Refresh(types.RefreshOptions{
Scope: []types.RefreshableView{types.REMOTES},
Then: func() error {

View file

@ -229,7 +229,10 @@ func (self *StagingController) applySelectionAndRefresh(reverse bool) error {
return err
}
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES, types.STAGING}})
// Block input until the refresh has landed: it rebuilds the staging panel
// and moves the selection to the next stageable change, and a quick second
// keypress must act on that, not on the stale pre-refresh diff.
self.c.RefreshBlockingInput(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES, types.STAGING}})
return nil
}
@ -284,7 +287,9 @@ func (self *StagingController) EditHunkAndRefresh() error {
return err
}
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES, types.STAGING}})
// Block input like applySelectionAndRefresh does; the refresh rebuilds the
// staging panel from the post-edit diff.
self.c.RefreshBlockingInput(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES, types.STAGING}})
return nil
}

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,

View file

@ -1103,12 +1103,31 @@ func (gui *Gui) runSubprocess(cmdObj *oscommands.CmdObj) error {
return err
}
var isFirstRefreshAfterStartup = true
func (gui *Gui) loadNewRepo() error {
if err := gui.updateRecentRepoList(); err != nil {
return err
}
gui.c.Refresh(types.RefreshOptions{DontBlockRepoSwitch: true})
// On startup we don't want to block input during the initial refresh (it
// should be possible to press, say, `4` to jump to the commits panel right
// after startup without a delay), and we also want panels to show their
// contents as soon as possible; it doesn't matter so much that it's not in
// sync, we go from empty to populated here. However, when switching repos
// it can be confusing that some panels that are slow to update still show
// the old repo's data while others already show the new one's data, so
// update the UI only when everything is ready, and also block input to
// prevent accidentally trying to act on the old, stale data.
options := types.RefreshOptions{DontBlockRepoSwitch: true}
refresh := gui.c.Refresh
if isFirstRefreshAfterStartup {
isFirstRefreshAfterStartup = false
} else {
options.BatchUIUpdates = true
refresh = gui.c.RefreshBlockingInput
}
refresh(options)
if err := gui.os.UpdateWindowTitle(); err != nil {
return err

View file

@ -30,6 +30,10 @@ func (self *guiCommon) Refresh(opts types.RefreshOptions) {
self.gui.helpers.Refresh.Refresh(opts)
}
func (self *guiCommon) RefreshBlockingInput(opts types.RefreshOptions) {
self.gui.helpers.Refresh.RefreshBlockingInput(opts)
}
func (self *guiCommon) RefreshFromWorker(opts types.RefreshOptions) {
self.gui.helpers.Refresh.RefreshFromWorker(opts)
}

View file

@ -25,17 +25,27 @@ type GuiDriver struct {
var _ integrationTypes.GuiDriver = &GuiDriver{}
func (self *GuiDriver) PressKey(keyStr string) {
self.PressKeysRapidly(keyStr)
}
// PressKeysRapidly presses the given keys in immediate succession, waiting for
// lazygit to become idle only after the last one. Keys pressed this way can
// arrive while the previous key's processing is still in flight, like a user
// typing faster than lazygit handles the input.
func (self *GuiDriver) PressKeysRapidly(keyStrs ...string) {
self.CheckAllToastsAcknowledged()
key, ok := config.KeyFromLabel(keyStr)
if !ok {
self.Fail("Unrecognized key: " + keyStr)
}
for _, keyStr := range keyStrs {
key, ok := config.KeyFromLabel(keyStr)
if !ok {
self.Fail("Unrecognized key: " + keyStr)
}
self.gui.g.ReplayKeyEvent(gocui.NewTcellKeyEventWrapper(
tcell.NewEventKey(tcell.Key(key.KeyName()), key.Str(), tcell.ModMask(key.Mod())),
0,
))
self.gui.g.ReplayKeyEvent(gocui.NewTcellKeyEventWrapper(
tcell.NewEventKey(tcell.Key(key.KeyName()), key.Str(), tcell.ModMask(key.Mod())),
0,
))
}
self.waitTillIdle()
}

View file

@ -29,6 +29,17 @@ type IGuiCommon interface {
LogCommand(cmdStr string, isCommandLine bool)
// we call this when we want to refetch some models and render the result. Internally calls PostRefreshUpdate
Refresh(RefreshOptions)
// Like Refresh, but withholds keyboard input until the refreshed state is
// in place: keys pressed while the refresh is in flight are buffered and
// replayed once its model and view updates have run, instead of being
// handled against the stale, pre-refresh state. Use it when the very next
// keypress may depend on what the refresh produces — e.g. staging a hunk,
// where the refresh moves the selection to the next stageable hunk that
// the next press is meant to stage. Keep it to quick, narrow-scoped
// refreshes: one that includes COMMITS (or refreshes everything) can take
// very long in large repos and should usually not block input unless
// there's a very good reason (switching repos is one such example).
RefreshBlockingInput(RefreshOptions)
// Like Refresh, but for callers running on a worker goroutine (e.g. inside
// a WithWaitingStatus handler) rather than the UI thread. The refresh
// captures the model/context state it needs on the UI thread before doing

View file

@ -2,6 +2,7 @@ package components
import (
"fmt"
"strings"
"time"
"github.com/jesseduffield/lazygit/pkg/config"
@ -42,6 +43,15 @@ func (self *TestDriver) pressFast(keyStr string) {
self.Wait(self.inputDelay / 5)
}
// presses the keys in immediate succession, without waiting for lazygit to
// become idle in between, to simulate a user typing faster than lazygit
// processes the input
func (self *TestDriver) pressRapidly(keyStrs []string) {
self.SetCaption(fmt.Sprintf("Pressing %s", strings.Join(keyStrs, ", ")))
self.gui.PressKeysRapidly(keyStrs...)
self.Wait(self.inputDelay)
}
func (self *TestDriver) click(x, y int) {
self.SetCaption(fmt.Sprintf("Clicking %d, %d", x, y))
self.gui.Click(x, y)

View file

@ -30,6 +30,10 @@ func (self *fakeGuiDriver) PressKey(key string) {
self.pressedKeys = append(self.pressedKeys, key)
}
func (self *fakeGuiDriver) PressKeysRapidly(keys ...string) {
self.pressedKeys = append(self.pressedKeys, keys...)
}
func (self *fakeGuiDriver) Click(x, y int) {
self.clickedCoordinates = append(self.clickedCoordinates, coordinate{x: x, y: y})
}

View file

@ -454,6 +454,19 @@ func (self *ViewDriver) PressFast(key config.Keybinding) *ViewDriver {
return self
}
// Presses the given keys in immediate succession, without waiting for lazygit
// to become idle in between (Press waits after every key). Use this to
// simulate a user typing faster than lazygit processes the input.
func (self *ViewDriver) PressRapidly(keys ...config.Keybinding) *ViewDriver {
self.IsFocused()
self.t.pressRapidly(lo.Map(keys, func(key config.Keybinding, _ int) string {
return key[0]
}))
return self
}
func (self *ViewDriver) Click(x, y int) *ViewDriver {
offsetX, offsetY, _, _ := self.getView().Dimensions()

View file

@ -0,0 +1,60 @@
package interactive_rebase
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
// The second keypress arrives before the refresh triggered by the first one
// has rebuilt the commits model. The handler reads the selected todo from the
// model at the already-advanced selection index, so with the stale, pre-move
// model it grabs the todo the first move swapped with and moves that one back
// down — turning the two presses into a net no-op instead of moving the
// selected todo down two slots. This is what happens when holding down the
// move-down key to move a todo several slots.
//
// We continue the rebase and assert the resulting commit order rather than
// asserting the todo list, because the two presses also spawn two racing
// refreshes whose updates can land in either order, so what the todo list
// shows in the broken state is not deterministic (it can even disagree with
// the todo file). The rebase replays what's in the file.
var MoveTodoDownWithRapidKeypresses = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Move a todo down two slots with two keypresses in rapid succession",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
shell.CreateNCommits(4)
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Commits().
Focus().
Lines(
Contains("commit-04").IsSelected(),
Contains("commit-03"),
Contains("commit-02"),
Contains("commit-01"),
).
NavigateToLine(Contains("commit-01")).
Press(keys.Universal.Edit).
Lines(
Contains("--- Pending rebase todos ---"),
Contains("commit-04"),
Contains("commit-03"),
Contains("commit-02"),
Contains("--- Commits ---"),
Contains("commit-01").IsSelected(),
).
NavigateToLine(Contains("commit-04")).
PressRapidly(keys.Commits.MoveDownCommit, keys.Commits.MoveDownCommit).
Tap(func() {
t.Common().ContinueRebase()
}).
Lines(
Contains("commit-03"),
Contains("commit-02"),
Contains("commit-04"),
Contains("commit-01"),
)
},
})

View file

@ -0,0 +1,50 @@
package staging
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
// The second space is pressed before the refresh triggered by the first one
// has updated the staging panel. That refresh is what moves the selection to
// the next hunk, so the second press must not be handled until it has landed;
// handling it earlier would try to stage the first hunk a second time.
var StageHunksWithRapidKeypresses = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Stage two hunks with two space presses in rapid succession",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {
config.GetUserConfig().Gui.UseHunkModeInStagingView = true
},
SetupRepo: func(shell *Shell) {
// Use 7 context lines between the two change blocks so that git creates
// two separate hunks.
shell.CreateFileAndAdd("file1", "1\n2\na\nb\nc\nd\ne\nf\ng\n3\n4\n")
shell.Commit("one")
shell.UpdateFile("file1", "1b\n2b\na\nb\nc\nd\ne\nf\ng\n3b\n4b\n")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Files().
IsFocused().
Lines(
Contains("file1").IsSelected(),
).
PressEnter()
t.Views().Staging().
IsFocused().
PressRapidly(keys.Universal.Select, keys.Universal.Select)
t.Views().StagingSecondary().
IsFocused().
ContainsLines(
Contains("+1b"),
Contains("+2b"),
).
ContainsLines(
Contains("+3b"),
Contains("+4b"),
)
},
})

View file

@ -310,6 +310,7 @@ var tests = []*components.IntegrationTest{
interactive_rebase.Move,
interactive_rebase.MoveAcrossBranchBoundaryOutsideRebase,
interactive_rebase.MoveInRebase,
interactive_rebase.MoveTodoDownWithRapidKeypresses,
interactive_rebase.MoveUpdateRefTodo,
interactive_rebase.MoveWithCustomCommentChar,
interactive_rebase.OutsideRebaseRangeSelect,
@ -403,6 +404,7 @@ var tests = []*components.IntegrationTest{
staging.SelectNextLineAfterStagingInTwoHunkDiff,
staging.SelectNextLineAfterStagingIsolatedAddedLine,
staging.StageHunks,
staging.StageHunksWithRapidKeypresses,
staging.StageLines,
staging.StagePartialBlockOfChangesFirstLines,
staging.StagePartialBlockOfChangesLastLines,

View file

@ -23,6 +23,10 @@ type IntegrationTest interface {
// this is the interface through which our integration tests interact with the lazygit gui
type GuiDriver interface {
PressKey(string)
// Like PressKey, but presses several keys in immediate succession, waiting
// for lazygit to become idle only after the last one. Use it to simulate a
// user typing faster than lazygit processes the input.
PressKeysRapidly(...string)
Click(int, int)
// Simulate the terminal window regaining focus (which triggers a reload of
// changed config files)