diff --git a/pkg/gui/controllers/helpers/inline_status_helper.go b/pkg/gui/controllers/helpers/inline_status_helper.go index f2ca7ec17..814a11406 100644 --- a/pkg/gui/controllers/helpers/inline_status_helper.go +++ b/pkg/gui/controllers/helpers/inline_status_helper.go @@ -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) } diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 8ae62d9dd..cf097b67e 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -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() diff --git a/pkg/gui/controllers/helpers/refs_helper.go b/pkg/gui/controllers/helpers/refs_helper.go index 675c332a0..0fcbeace3 100644 --- a/pkg/gui/controllers/helpers/refs_helper.go +++ b/pkg/gui/controllers/helpers/refs_helper.go @@ -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 }, }, { diff --git a/pkg/gui/controllers/local_commits_controller.go b/pkg/gui/controllers/local_commits_controller.go index 2da502b79..708f8fc28 100644 --- a/pkg/gui/controllers/local_commits_controller.go +++ b/pkg/gui/controllers/local_commits_controller.go @@ -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, }) diff --git a/pkg/gui/controllers/remotes_controller.go b/pkg/gui/controllers/remotes_controller.go index 76bd16bb1..cd05e5ff9 100644 --- a/pkg/gui/controllers/remotes_controller.go +++ b/pkg/gui/controllers/remotes_controller.go @@ -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 { diff --git a/pkg/gui/controllers/staging_controller.go b/pkg/gui/controllers/staging_controller.go index 8d876acda..505a07fc4 100644 --- a/pkg/gui/controllers/staging_controller.go +++ b/pkg/gui/controllers/staging_controller.go @@ -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 } diff --git a/pkg/gui/controllers/stash_controller.go b/pkg/gui/controllers/stash_controller.go index a2b7e9e97..d01fc8dbf 100644 --- a/pkg/gui/controllers/stash_controller.go +++ b/pkg/gui/controllers/stash_controller.go @@ -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, diff --git a/pkg/gui/gui.go b/pkg/gui/gui.go index 8776040e7..912d46567 100644 --- a/pkg/gui/gui.go +++ b/pkg/gui/gui.go @@ -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 diff --git a/pkg/gui/gui_common.go b/pkg/gui/gui_common.go index 80b2b9ded..e7b14ba04 100644 --- a/pkg/gui/gui_common.go +++ b/pkg/gui/gui_common.go @@ -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) } diff --git a/pkg/gui/gui_driver.go b/pkg/gui/gui_driver.go index 7bd31d93d..74a8109a7 100644 --- a/pkg/gui/gui_driver.go +++ b/pkg/gui/gui_driver.go @@ -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() } diff --git a/pkg/gui/types/common.go b/pkg/gui/types/common.go index 6d11e29db..87bd9ef50 100644 --- a/pkg/gui/types/common.go +++ b/pkg/gui/types/common.go @@ -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 diff --git a/pkg/integration/components/test_driver.go b/pkg/integration/components/test_driver.go index 376b0f4d6..42ce8ac35 100644 --- a/pkg/integration/components/test_driver.go +++ b/pkg/integration/components/test_driver.go @@ -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) diff --git a/pkg/integration/components/test_test.go b/pkg/integration/components/test_test.go index e7fd0b66a..8fd4417ea 100644 --- a/pkg/integration/components/test_test.go +++ b/pkg/integration/components/test_test.go @@ -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}) } diff --git a/pkg/integration/components/view_driver.go b/pkg/integration/components/view_driver.go index df4b9d7d8..920c610be 100644 --- a/pkg/integration/components/view_driver.go +++ b/pkg/integration/components/view_driver.go @@ -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() diff --git a/pkg/integration/tests/interactive_rebase/move_todo_down_with_rapid_keypresses.go b/pkg/integration/tests/interactive_rebase/move_todo_down_with_rapid_keypresses.go new file mode 100644 index 000000000..16e58e18e --- /dev/null +++ b/pkg/integration/tests/interactive_rebase/move_todo_down_with_rapid_keypresses.go @@ -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"), + ) + }, +}) diff --git a/pkg/integration/tests/staging/stage_hunks_with_rapid_keypresses.go b/pkg/integration/tests/staging/stage_hunks_with_rapid_keypresses.go new file mode 100644 index 000000000..5b41073e2 --- /dev/null +++ b/pkg/integration/tests/staging/stage_hunks_with_rapid_keypresses.go @@ -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"), + ) + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 8213d5159..1bb06741f 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -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, diff --git a/pkg/integration/types/types.go b/pkg/integration/types/types.go index 34ce499cc..12009315a 100644 --- a/pkg/integration/types/types.go +++ b/pkg/integration/types/types.go @@ -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)