mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-08-24 02:24:25 -05:00
A terminal that supports focus reporting answers with the state it is already in when we turn reporting on, so at startup we were told that we had gained focus that we never lost, and refreshed everything a second time on top of the refresh that loading the repo had just started. The two ran at once, each with its own `git status`, which made both of them slower than the one refresh needed to be. Keep track of what the reports say, then, and pass on only the ones that change it. Assuming that we start out focused costs us nothing when we don't: that same first report says so, so a lazygit started in a window that isn't in front knows it from the start. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
278 lines
7.5 KiB
Go
278 lines
7.5 KiB
Go
package gui
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gdamore/tcell/v3"
|
|
"github.com/jesseduffield/lazygit/pkg/commands/models"
|
|
"github.com/jesseduffield/lazygit/pkg/config"
|
|
"github.com/jesseduffield/lazygit/pkg/gocui"
|
|
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
|
integrationTypes "github.com/jesseduffield/lazygit/pkg/integration/types"
|
|
)
|
|
|
|
// this gives our integration test a way of interacting with the gui for sending keypresses
|
|
// and reading state.
|
|
type GuiDriver struct {
|
|
gui *Gui
|
|
toastChan chan string
|
|
headless bool
|
|
}
|
|
|
|
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()
|
|
|
|
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.waitTillIdle()
|
|
}
|
|
|
|
func (self *GuiDriver) Click(x, y int) {
|
|
self.CheckAllToastsAcknowledged()
|
|
|
|
self.replayMouseEvent(x, y, tcell.ButtonPrimary)
|
|
self.replayMouseEvent(x, y, tcell.ButtonNone)
|
|
}
|
|
|
|
func (self *GuiDriver) ClickAndHold(x, y int) {
|
|
self.CheckAllToastsAcknowledged()
|
|
self.replayMouseEvent(x, y, tcell.ButtonPrimary)
|
|
}
|
|
|
|
// MouseMove reports the mouse at a new position with the left button still
|
|
// held down, i.e. a drag movement. (No test needs pointer motion without a
|
|
// button held, so that variant doesn't exist.)
|
|
func (self *GuiDriver) MouseMove(x, y int) {
|
|
self.replayMouseEvent(x, y, tcell.ButtonPrimary)
|
|
}
|
|
|
|
func (self *GuiDriver) ScrollWheelDown(x, y int) {
|
|
self.replayMouseEvent(x, y, tcell.WheelDown)
|
|
}
|
|
|
|
func (self *GuiDriver) MouseRelease(x, y int) {
|
|
self.replayMouseEvent(x, y, tcell.ButtonNone)
|
|
}
|
|
|
|
func (self *GuiDriver) MouseReleaseWithoutWaiting(x, y int) {
|
|
self.replayMouseEventWithoutWaiting(x, y, tcell.ButtonNone)
|
|
}
|
|
|
|
func (self *GuiDriver) WaitUntilIdle() {
|
|
self.waitTillIdle()
|
|
}
|
|
|
|
func (self *GuiDriver) OnUIThreadAndWait(f func()) {
|
|
_ = self.gui.g.OnUIThreadAndWait(f)
|
|
}
|
|
|
|
func (self *GuiDriver) replayMouseEvent(x, y int, buttons tcell.ButtonMask) {
|
|
self.replayMouseEventWithoutWaiting(x, y, buttons)
|
|
self.waitTillIdle()
|
|
}
|
|
|
|
func (self *GuiDriver) replayMouseEventWithoutWaiting(x, y int, buttons tcell.ButtonMask) {
|
|
self.gui.g.ReplayMouseEvent(gocui.NewTcellMouseEventWrapper(
|
|
tcell.NewEventMouse(x, y, buttons, 0),
|
|
0,
|
|
))
|
|
}
|
|
|
|
// replayFocusIn takes the focus away before handing it back, because that's the
|
|
// only way a terminal can report regaining it, and lazygit only reacts to focus
|
|
// reports that change the focus (see gocui.Gui.IsFocused).
|
|
func (self *GuiDriver) replayFocusIn() {
|
|
self.gui.g.ReplayFocusEvent(gocui.NewTcellFocusEventWrapper(
|
|
tcell.NewEventFocus(false),
|
|
0,
|
|
))
|
|
self.gui.g.ReplayFocusEvent(gocui.NewTcellFocusEventWrapper(
|
|
tcell.NewEventFocus(true),
|
|
0,
|
|
))
|
|
}
|
|
|
|
// FocusIn simulates the terminal window regaining focus, which is how lazygit
|
|
// learns to reload changed config files. Tests use it to exercise the live
|
|
// config-reload path.
|
|
func (self *GuiDriver) FocusIn() {
|
|
self.replayFocusIn()
|
|
|
|
self.waitTillIdle()
|
|
}
|
|
|
|
func (self *GuiDriver) FocusInAndClick(x, y int) {
|
|
self.CheckAllToastsAcknowledged()
|
|
|
|
self.replayFocusIn()
|
|
self.gui.g.ReplayMouseEvent(gocui.NewTcellMouseEventWrapper(
|
|
tcell.NewEventMouse(x, y, tcell.ButtonPrimary, 0),
|
|
0,
|
|
))
|
|
self.waitTillIdle()
|
|
self.gui.g.ReplayMouseEvent(gocui.NewTcellMouseEventWrapper(
|
|
tcell.NewEventMouse(x, y, tcell.ButtonNone, 0),
|
|
0,
|
|
))
|
|
self.waitTillIdle()
|
|
}
|
|
|
|
// RefreshInBackground performs the refresh that the background routines perform
|
|
// on a timer (see BackgroundRoutineMgr). Tests drive it directly rather than
|
|
// turning those routines on, so that they neither wait for a timer nor depend on
|
|
// one firing at a particular moment.
|
|
func (self *GuiDriver) RefreshInBackground() {
|
|
self.gui.c.RefreshFromWorker(types.RefreshOptions{Background: true})
|
|
|
|
self.waitTillIdle()
|
|
}
|
|
|
|
func (self *GuiDriver) PretendMergeOrRebaseStartedInLazygit() {
|
|
self.gui.onUIThread(func() error {
|
|
self.gui.State.SetMergeOrRebaseStartedInLazygit(true)
|
|
return nil
|
|
})
|
|
|
|
self.waitTillIdle()
|
|
}
|
|
|
|
// wait until lazygit is idle (i.e. all processing is done) before continuing
|
|
func (self *GuiDriver) waitTillIdle() {
|
|
self.gui.g.WaitUntilIdle()
|
|
}
|
|
|
|
func (self *GuiDriver) CheckAllToastsAcknowledged() {
|
|
if t := self.NextToast(); t != nil {
|
|
self.Fail("Toast not acknowledged: " + *t)
|
|
}
|
|
}
|
|
|
|
func (self *GuiDriver) Keys() config.KeybindingConfig {
|
|
return self.gui.Config.GetUserConfig().Keybinding
|
|
}
|
|
|
|
func (self *GuiDriver) CurrentContext() types.Context {
|
|
// Read the context manager directly rather than through c.Context(): the
|
|
// driver runs on the test goroutine, not the UI thread, so it must bypass
|
|
// the UI-thread assertion that accessor carries.
|
|
return self.gui.State.ContextMgr.Current()
|
|
}
|
|
|
|
func (self *GuiDriver) ContextForView(viewName string) types.Context {
|
|
context, ok := self.gui.helpers.View.ContextForView(viewName)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
|
|
return context
|
|
}
|
|
|
|
func (self *GuiDriver) Fail(message string) {
|
|
currentView := self.gui.g.CurrentView()
|
|
|
|
// Check for unacknowledged toast: it may give us a hint as to why the test failed
|
|
toastMessage := ""
|
|
if t := self.NextToast(); t != nil {
|
|
toastMessage = fmt.Sprintf("Unacknowledged toast message: %s\n", *t)
|
|
}
|
|
|
|
fullMessage := fmt.Sprintf(
|
|
"%s\nFinal Lazygit state:\n%s\nUpon failure, focused view was '%s'.\n%sLog:\n%s", message,
|
|
self.gui.g.Snapshot(),
|
|
currentView.Name(),
|
|
toastMessage,
|
|
strings.Join(self.gui.GuiLog, "\n"),
|
|
)
|
|
|
|
self.gui.g.Close()
|
|
// need to give the gui time to close
|
|
time.Sleep(time.Millisecond * 100)
|
|
_, err := fmt.Fprintln(os.Stderr, fullMessage)
|
|
if err != nil {
|
|
panic("Test failed. Failed writing to stderr")
|
|
}
|
|
panic("Test failed")
|
|
}
|
|
|
|
// logs to the normal place that you log to i.e. viewable with `lazygit --logs`
|
|
func (self *GuiDriver) Log(message string) {
|
|
self.gui.c.Log.Warn(message)
|
|
}
|
|
|
|
// logs in the actual UI (in the commands panel)
|
|
func (self *GuiDriver) LogUI(message string) {
|
|
self.gui.c.LogAction(message)
|
|
}
|
|
|
|
func (self *GuiDriver) CheckedOutRef() *models.Branch {
|
|
return self.gui.helpers.Refs.GetCheckedOutRef()
|
|
}
|
|
|
|
func (self *GuiDriver) MainView() *gocui.View {
|
|
return self.gui.mainView()
|
|
}
|
|
|
|
func (self *GuiDriver) SecondaryView() *gocui.View {
|
|
return self.gui.secondaryView()
|
|
}
|
|
|
|
func (self *GuiDriver) View(viewName string) *gocui.View {
|
|
view, err := self.gui.g.View(viewName)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return view
|
|
}
|
|
|
|
// TopViewInWindow returns the frontmost visible view in the given window, i.e.
|
|
// the tab that is currently shown when a window holds several tabbed views.
|
|
func (self *GuiDriver) TopViewInWindow(windowName string) *gocui.View {
|
|
return self.gui.helpers.Window.TopViewInWindow(windowName, false)
|
|
}
|
|
|
|
func (self *GuiDriver) SetCaption(caption string) {
|
|
self.gui.setCaption(caption)
|
|
self.waitTillIdle()
|
|
}
|
|
|
|
func (self *GuiDriver) SetCaptionPrefix(prefix string) {
|
|
self.gui.setCaptionPrefix(prefix)
|
|
self.waitTillIdle()
|
|
}
|
|
|
|
func (self *GuiDriver) NextToast() *string {
|
|
select {
|
|
case t := <-self.toastChan:
|
|
return &t
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func (self *GuiDriver) Headless() bool {
|
|
return self.headless
|
|
}
|