diff --git a/pkg/gui/gui.go b/pkg/gui/gui.go index 80f73dba0..154a68b99 100644 --- a/pkg/gui/gui.go +++ b/pkg/gui/gui.go @@ -3,6 +3,7 @@ package gui import ( "fmt" "io/ioutil" + "log" "os" "runtime" "sync" @@ -89,11 +90,6 @@ type Gui struct { // recent repo with the recent repos popup showing showRecentRepos bool - // this array either includes the events that we're recording in this session - // or the events we've recorded in a prior session - RecordedEvents []RecordedEvent - StartTime time.Time - Mutexes guiStateMutexes // findSuggestions will take a string that the user has typed into a prompt @@ -110,11 +106,6 @@ type Gui struct { Views Views } -type RecordedEvent struct { - Timestamp int64 - Event *gocui.GocuiEvent -} - type listPanelState struct { SelectedLineIdx int } @@ -446,7 +437,6 @@ func NewGui(log *logrus.Entry, gitCommand *commands.GitCommand, oSCommand *oscom statusManager: &statusManager{}, viewBufferManagerMap: map[string]*tasks.ViewBufferManager{}, showRecentRepos: showRecentRepos, - RecordedEvents: []RecordedEvent{}, RepoPathStack: []string{}, RepoStateMap: map[Repo]*guiState{}, } @@ -461,16 +451,35 @@ func NewGui(log *logrus.Entry, gitCommand *commands.GitCommand, oSCommand *oscom // Run setup the gui with keybindings and start the mainloop func (gui *Gui) Run() error { recordEvents := recordingEvents() + playMode := gocui.NORMAL + if recordEvents { + playMode = gocui.RECORDING + } else if replaying() { + playMode = gocui.REPLAYING + } - g, err := gocui.NewGui(gocui.OutputTrue, OverlappingEdges, recordEvents) + g, err := gocui.NewGui(gocui.OutputTrue, OverlappingEdges, playMode) if err != nil { return err } gui.g = g // TODO: always use gui.g rather than passing g around everywhere defer g.Close() - if recordEvents { - go utils.Safe(gui.recordEvents) + if replaying() { + g.RecordingConfig = gocui.RecordingConfig{ + Speed: getRecordingSpeed(), + Leeway: 0, + } + + g.Recording, err = gui.loadRecording() + if err != nil { + return err + } + + go utils.Safe(func() { + time.Sleep(time.Second * 20) + log.Fatal("20 seconds is up, lazygit recording took too long to complete") + }) } g.OnSearchEscape = gui.onSearchEscape @@ -518,9 +527,6 @@ func (gui *Gui) Run() error { // RunAndHandleError func (gui *Gui) RunAndHandleError() error { - gui.StartTime = time.Now() - go utils.Safe(gui.replayRecordedEvents) - gui.stopChan = make(chan struct{}) return utils.SafeWithError(func() error { if err := gui.Run(); err != nil { @@ -542,7 +548,7 @@ func (gui *Gui) RunAndHandleError() error { } } - if err := gui.saveRecordedEvents(); err != nil { + if err := gui.saveRecording(gui.g.Recording); err != nil { return err } diff --git a/pkg/gui/recording.go b/pkg/gui/recording.go index 90b70c1ef..4b26f73c4 100644 --- a/pkg/gui/recording.go +++ b/pkg/gui/recording.go @@ -6,10 +6,8 @@ import ( "log" "os" "strconv" - "time" "github.com/jesseduffield/gocui" - "github.com/jesseduffield/lazygit/pkg/utils" ) func recordingEvents() bool { @@ -20,31 +18,11 @@ func recordEventsTo() string { return os.Getenv("RECORD_EVENTS_TO") } -func (gui *Gui) timeSinceStart() int64 { - return time.Since(gui.StartTime).Nanoseconds() / 1e6 +func replaying() bool { + return os.Getenv("REPLAY_EVENTS_FROM") != "" } -func (gui *Gui) replayRecordedEvents() { - gui.Log.Warn("going to replay events") - if os.Getenv("REPLAY_EVENTS_FROM") == "" { - return - } - - go utils.Safe(func() { - time.Sleep(time.Second * 20) - log.Fatal("20 seconds is up, lazygit recording took too long to complete") - }) - - events, err := gui.loadRecordedEvents() - if err != nil { - log.Fatal(err) - } - - ticker := time.NewTicker(time.Millisecond) - defer ticker.Stop() - - // might need to add leeway if this ends up flakey - var leeway int64 = 0 +func getRecordingSpeed() int { // humans are slow so this speeds things up. speed := 1 envReplaySpeed := os.Getenv("REPLAY_SPEED") @@ -55,49 +33,10 @@ func (gui *Gui) replayRecordedEvents() { log.Fatal(err) } } - - // The playback could be paused at any time because integration tests run concurrently. - // Therefore we can't just check for a given event whether we've passed its timestamp, - // or else we'll have an explosion of keypresses after the test is resumed. - // We need to check if we've waited long enough since the last event was replayed. - for i, event := range events { - var prevEventTimestamp int64 = 0 - if i > 0 { - prevEventTimestamp = events[i-1].Timestamp - } - timeToWait := (event.Timestamp - prevEventTimestamp) / int64(speed) - if i == 0 { - timeToWait += leeway - } - var timeWaited int64 = 0 - middle: - for { - select { - case <-ticker.C: - timeWaited += 1 - if gui.g != nil && timeWaited >= timeToWait { - gui.Log.Warn("replaying event") - gui.g.ReplayedEvents <- *event.Event - break middle - } - case <-gui.stopChan: - return - } - } - } - - time.Sleep(time.Second * 1) - - gui.g.Update(func(*gocui.Gui) error { - return gocui.ErrQuit - }) - - time.Sleep(time.Second * 1) - - log.Fatal("lazygit should have already exited") + return speed } -func (gui *Gui) loadRecordedEvents() ([]RecordedEvent, error) { +func (gui *Gui) loadRecording() (*gocui.Recording, error) { path := os.Getenv("REPLAY_EVENTS_FROM") data, err := ioutil.ReadFile(path) @@ -105,22 +44,22 @@ func (gui *Gui) loadRecordedEvents() ([]RecordedEvent, error) { return nil, err } - events := []RecordedEvent{} + recording := &gocui.Recording{} - err = json.Unmarshal(data, &events) + err = json.Unmarshal(data, &recording) if err != nil { return nil, err } - return events, nil + return recording, nil } -func (gui *Gui) saveRecordedEvents() error { +func (gui *Gui) saveRecording(recording *gocui.Recording) error { if !recordingEvents() { return nil } - jsonEvents, err := json.Marshal(gui.RecordedEvents) + jsonEvents, err := json.Marshal(recording) if err != nil { return err } @@ -129,14 +68,3 @@ func (gui *Gui) saveRecordedEvents() error { return ioutil.WriteFile(path, jsonEvents, 0600) } - -func (gui *Gui) recordEvents() { - for event := range gui.g.RecordedEvents { - recordedEvent := RecordedEvent{ - Timestamp: gui.timeSinceStart(), - Event: event, - } - - gui.RecordedEvents = append(gui.RecordedEvents, recordedEvent) - } -} diff --git a/vendor/github.com/jesseduffield/gocui/gui.go b/vendor/github.com/jesseduffield/gocui/gui.go index a15e5b71c..19ea5b2d6 100644 --- a/vendor/github.com/jesseduffield/gocui/gui.go +++ b/vendor/github.com/jesseduffield/gocui/gui.go @@ -75,13 +75,36 @@ type GuiMutexes struct { ViewsMutex sync.Mutex } +type PlayMode int + +const ( + NORMAL PlayMode = iota + RECORDING + REPLAYING +) + +type Recording struct { + KeyEvents []*TcellKeyEventWrapper +} + +type replayedEvents struct { + keys chan *TcellKeyEventWrapper +} + +type RecordingConfig struct { + Speed int + Leeway int +} + // Gui represents the whole User Interface, including the views, layouts // and keybindings. type Gui struct { - // ReplayedEvents is a channel for passing pre-recorded input events, for the purposes of testing - ReplayedEvents chan GocuiEvent - RecordEvents bool - RecordedEvents chan *GocuiEvent + RecordingConfig + Recording *Recording + // ReplayedEvents is for passing pre-recorded input events, for the purposes of testing + ReplayedEvents replayedEvents + PlayMode PlayMode + StartTime time.Time tabClickBindings []*tabClickBinding gEvents chan GocuiEvent @@ -135,7 +158,7 @@ type Gui struct { } // NewGui returns a new Gui object with a given output mode. -func NewGui(mode OutputMode, supportOverlaps bool, recordEvents bool) (*Gui, error) { +func NewGui(mode OutputMode, supportOverlaps bool, playMode PlayMode) (*Gui, error) { err := tcellInit() if err != nil { return nil, err @@ -147,10 +170,18 @@ func NewGui(mode OutputMode, supportOverlaps bool, recordEvents bool) (*Gui, err g.stop = make(chan struct{}) - g.ReplayedEvents = make(chan GocuiEvent) g.gEvents = make(chan GocuiEvent, 20) g.userEvents = make(chan userEvent, 20) - g.RecordedEvents = make(chan *GocuiEvent) + + if playMode == RECORDING { + g.Recording = &Recording{ + KeyEvents: []*TcellKeyEventWrapper{}, + } + } else if playMode == REPLAYING { + g.ReplayedEvents = replayedEvents{ + keys: make(chan *TcellKeyEventWrapper), + } + } if runtime.GOOS != "windows" { g.maxX, g.maxY, err = g.getTermWindowSize() @@ -173,7 +204,7 @@ func NewGui(mode OutputMode, supportOverlaps bool, recordEvents bool) (*Gui, err g.NextSearchMatchKey = 'n' g.PrevSearchMatchKey = 'N' - g.RecordEvents = recordEvents + g.PlayMode = playMode return g, nil } @@ -533,13 +564,17 @@ func (g *Gui) SetManagerFunc(manager func(*Gui) error) { // MainLoop runs the main loop until an error is returned. A successful // finish should return ErrQuit. func (g *Gui) MainLoop() error { + if g.PlayMode == REPLAYING { + g.replayRecording() + } + go func() { for { select { case <-g.stop: return default: - g.gEvents <- pollEvent() + g.gEvents <- g.pollEvent() } } }() @@ -554,10 +589,6 @@ func (g *Gui) MainLoop() error { if err := g.handleEvent(&ev); err != nil { return err } - case ev := <-g.ReplayedEvents: - if err := g.handleEvent(&ev); err != nil { - return err - } case ev := <-g.userEvents: if err := ev.f(g); err != nil { return err @@ -580,10 +611,6 @@ func (g *Gui) consumeevents() error { if err := g.handleEvent(&ev); err != nil { return err } - case ev := <-g.ReplayedEvents: - if err := g.handleEvent(&ev); err != nil { - return err - } case ev := <-g.userEvents: if err := ev.f(g); err != nil { return err @@ -597,10 +624,6 @@ func (g *Gui) consumeevents() error { // handleEvent handles an event, based on its type (key-press, error, // etc.) func (g *Gui) handleEvent(ev *GocuiEvent) error { - if g.RecordEvents { - g.RecordedEvents <- ev - } - switch ev.Type { case eventKey, eventMouse: return g.onKey(ev) diff --git a/vendor/github.com/jesseduffield/gocui/recording.go b/vendor/github.com/jesseduffield/gocui/recording.go new file mode 100644 index 000000000..2c3241b94 --- /dev/null +++ b/vendor/github.com/jesseduffield/gocui/recording.go @@ -0,0 +1,52 @@ +package gocui + +import ( + "log" + "time" +) + +func (g *Gui) replayRecording() { + ticker := time.NewTicker(time.Millisecond) + defer ticker.Stop() + + // The playback could be paused at any time because integration tests run concurrently. + // Therefore we can't just check for a given event whether we've passed its timestamp, + // or else we'll have an explosion of keypresses after the test is resumed. + // We need to check if we've waited long enough since the last event was replayed. + // Only handling key events for now. + for i, event := range g.Recording.KeyEvents { + var prevEventTimestamp int64 = 0 + if i > 0 { + prevEventTimestamp = g.Recording.KeyEvents[i-1].Timestamp + } + timeToWait := (event.Timestamp - prevEventTimestamp) / int64(g.RecordingConfig.Speed) + if i == 0 { + timeToWait += int64(g.RecordingConfig.Leeway) + } + var timeWaited int64 = 0 + middle: + for { + select { + case <-ticker.C: + timeWaited += 1 + if g != nil && timeWaited >= timeToWait { + g.ReplayedEvents.keys <- event + break middle + } + case <-g.stop: + return + } + } + } + + // leaving some time for any handlers to execute before quitting + time.Sleep(time.Second * 1) + + g.Update(func(*Gui) error { + return ErrQuit + }) + + time.Sleep(time.Second * 1) + + log.Fatal("gocui should have already exited") +} diff --git a/vendor/github.com/jesseduffield/gocui/tcell_driver.go b/vendor/github.com/jesseduffield/gocui/tcell_driver.go index 517f102dd..637d650c5 100644 --- a/vendor/github.com/jesseduffield/gocui/tcell_driver.go +++ b/vendor/github.com/jesseduffield/gocui/tcell_driver.go @@ -6,6 +6,7 @@ package gocui import ( "sync" + "time" "github.com/gdamore/tcell/v2" ) @@ -144,9 +145,43 @@ var ( lastY int = 0 ) +// this wrapper struct has public keys so we can easily serialize/deserialize to JSON +type TcellKeyEventWrapper struct { + Timestamp int64 + Mod tcell.ModMask + Key tcell.Key + Ch rune +} + +func NewTcellKeyEventWrapper(event *tcell.EventKey, timestamp int64) *TcellKeyEventWrapper { + return &TcellKeyEventWrapper{ + Timestamp: timestamp, + Mod: event.Modifiers(), + Key: event.Key(), + Ch: event.Rune(), + } +} + +func (wrapper TcellKeyEventWrapper) toTcellEvent() tcell.Event { + return tcell.NewEventKey(wrapper.Key, wrapper.Ch, wrapper.Mod) +} + +func (g *Gui) timeSinceStart() int64 { + return time.Since(g.StartTime).Nanoseconds() / 1e6 +} + // pollEvent get tcell.Event and transform it into gocuiEvent -func pollEvent() GocuiEvent { - tev := Screen.PollEvent() +func (g *Gui) pollEvent() GocuiEvent { + var tev tcell.Event + if g.PlayMode == REPLAYING { + select { + case ev := <-g.ReplayedEvents.keys: + tev = (ev).toTcellEvent() + } + } else { + tev = Screen.PollEvent() + } + switch tev := tev.(type) { case *tcell.EventInterrupt: return GocuiEvent{Type: eventInterrupt} @@ -154,6 +189,10 @@ func pollEvent() GocuiEvent { w, h := tev.Size() return GocuiEvent{Type: eventResize, Width: w, Height: h} case *tcell.EventKey: + if g.PlayMode == RECORDING { + g.Recording.KeyEvents = append(g.Recording.KeyEvents, NewTcellKeyEventWrapper(tev, g.timeSinceStart())) + } + k := tev.Key() ch := rune(0) if k == tcell.KeyRune {