226 lines
5.3 KiB
Go
226 lines
5.3 KiB
Go
//go:build js && wasm
|
|
|
|
package main
|
|
|
|
import (
|
|
"sync"
|
|
"time"
|
|
|
|
mosh "github.com/unixshells/mosh-go"
|
|
vt "github.com/unixshells/vt-go"
|
|
)
|
|
|
|
// stateTracker implements Dart-style received state management.
|
|
// Each incoming diff is applied to a copy of the base state (looked up
|
|
// by oldNum), producing a new state stored by newNum. The display
|
|
// always shows the latest received state, diffed against what's
|
|
// currently on screen. This eliminates garbled output from missed
|
|
// diffs and overlapping state transitions.
|
|
type stateTracker struct {
|
|
mu sync.Mutex
|
|
|
|
shadow *vt.Emulator
|
|
shadowState uint64
|
|
cols, rows int
|
|
|
|
// Received states: stateNum → framebuffer snapshot.
|
|
states map[uint64]*mosh.Framebuffer
|
|
latestState uint64
|
|
displayedFB *mosh.Framebuffer
|
|
|
|
// Prediction engine for local echo.
|
|
predictor *mosh.Predictor
|
|
|
|
// Output buffer: pre-diffed ANSI ready for the terminal.
|
|
output []byte
|
|
}
|
|
|
|
func newStateTracker(cols, rows int) *stateTracker {
|
|
return &stateTracker{
|
|
shadow: vt.NewEmulator(cols, rows),
|
|
cols: cols,
|
|
rows: rows,
|
|
states: make(map[uint64]*mosh.Framebuffer),
|
|
predictor: mosh.NewPredictor(),
|
|
}
|
|
}
|
|
|
|
// applyDiff processes an incoming server diff using state tracking.
|
|
// throwawayNum is the server's indication of which states it no longer references.
|
|
func (st *stateTracker) applyDiff(diff []byte, oldNum, newNum, throwawayNum uint64) {
|
|
st.mu.Lock()
|
|
defer st.mu.Unlock()
|
|
|
|
// Already have this state — skip.
|
|
if _, ok := st.states[newNum]; ok {
|
|
return
|
|
}
|
|
|
|
// Snapshot current shadow as oldNum if we don't have it.
|
|
if oldNum == st.shadowState {
|
|
if _, ok := st.states[oldNum]; !ok {
|
|
st.states[oldNum] = mosh.SnapshotEmulator(st.shadow, true)
|
|
}
|
|
}
|
|
|
|
// Need the base state to apply the diff.
|
|
base, ok := st.states[oldNum]
|
|
if !ok {
|
|
// Unknown base state. Reset the shadow emulator and start fresh.
|
|
st.resetShadow()
|
|
st.shadowState = oldNum
|
|
base = mosh.SnapshotEmulator(st.shadow, true)
|
|
st.states[oldNum] = base
|
|
} else if st.shadowState != oldNum {
|
|
// Restore shadow to the base state.
|
|
st.resetShadow()
|
|
if base != nil {
|
|
st.shadow.Write(base.Diff(nil))
|
|
}
|
|
}
|
|
|
|
// Apply diff: feed hoststrings to shadow.
|
|
instrs, err := mosh.UnmarshalHostMessage(diff)
|
|
if err != nil {
|
|
return
|
|
}
|
|
for _, hi := range instrs {
|
|
if len(hi.Hoststring) > 0 {
|
|
st.shadow.Write(hi.Hoststring)
|
|
}
|
|
}
|
|
st.shadowState = newNum
|
|
|
|
// Snapshot result.
|
|
snap := mosh.SnapshotEmulator(st.shadow, true)
|
|
st.states[newNum] = snap
|
|
if newNum > st.latestState {
|
|
st.latestState = newNum
|
|
}
|
|
|
|
// Prune states the server no longer references.
|
|
if throwawayNum > 0 {
|
|
for n := range st.states {
|
|
if n < throwawayNum {
|
|
delete(st.states, n)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Hard cap: keep at most 16 states to prevent unbounded memory growth.
|
|
if len(st.states) > 16 {
|
|
minKeep := st.latestState
|
|
if minKeep > 16 {
|
|
minKeep -= 16
|
|
} else {
|
|
minKeep = 0
|
|
}
|
|
for n := range st.states {
|
|
if n < minKeep {
|
|
delete(st.states, n)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Confirm predictions against server state.
|
|
latest := st.states[st.latestState]
|
|
if latest != nil {
|
|
st.predictor.Confirm(latest)
|
|
st.predictor.ExpireStale(time.Now())
|
|
|
|
// Apply prediction overlay for display.
|
|
displayFB := latest
|
|
if st.predictor.Active() {
|
|
displayFB = st.cloneFB(latest)
|
|
st.predictor.Overlay(displayFB)
|
|
}
|
|
|
|
ansi := displayFB.Diff(st.displayedFB)
|
|
if len(ansi) > 0 {
|
|
st.output = append(st.output, ansi...)
|
|
}
|
|
st.displayedFB = displayFB
|
|
}
|
|
}
|
|
|
|
// poll returns accumulated ANSI output, or nil.
|
|
func (st *stateTracker) poll() []byte {
|
|
st.mu.Lock()
|
|
defer st.mu.Unlock()
|
|
if len(st.output) == 0 {
|
|
return nil
|
|
}
|
|
out := st.output
|
|
st.output = nil
|
|
return out
|
|
}
|
|
|
|
// keystroke feeds user input to the predictor and returns any prediction
|
|
// overlay output that should be displayed immediately.
|
|
func (st *stateTracker) keystroke(data []byte) []byte {
|
|
st.mu.Lock()
|
|
defer st.mu.Unlock()
|
|
|
|
st.predictor.Keystroke(data)
|
|
|
|
if !st.predictor.Active() || st.displayedFB == nil {
|
|
return nil
|
|
}
|
|
|
|
// Overlay predictions onto the current display and diff.
|
|
overlaid := st.cloneFB(st.displayedFB)
|
|
|
|
// First undo previous prediction overlay by using latest server state.
|
|
var serverFB *mosh.Framebuffer
|
|
if st.latestState > 0 {
|
|
serverFB = st.states[st.latestState]
|
|
}
|
|
if serverFB == nil {
|
|
serverFB = st.displayedFB
|
|
}
|
|
|
|
displayFB := st.cloneFB(serverFB)
|
|
st.predictor.Overlay(displayFB)
|
|
|
|
ansi := displayFB.Diff(overlaid)
|
|
if len(ansi) > 0 {
|
|
st.displayedFB = displayFB
|
|
}
|
|
return ansi
|
|
}
|
|
|
|
// cloneFB creates a deep copy of a framebuffer.
|
|
func (st *stateTracker) cloneFB(fb *mosh.Framebuffer) *mosh.Framebuffer {
|
|
clone := &mosh.Framebuffer{
|
|
W: fb.W,
|
|
H: fb.H,
|
|
CurX: fb.CurX,
|
|
CurY: fb.CurY,
|
|
CurVis: fb.CurVis,
|
|
Cells: make([]mosh.Cell, len(fb.Cells)),
|
|
}
|
|
copy(clone.Cells, fb.Cells)
|
|
return clone
|
|
}
|
|
|
|
// resize resets the state tracker for new dimensions.
|
|
func (st *stateTracker) resize(cols, rows int) {
|
|
st.mu.Lock()
|
|
defer st.mu.Unlock()
|
|
st.cols = cols
|
|
st.rows = rows
|
|
// Must create a new emulator for new dimensions.
|
|
st.shadow = vt.NewEmulator(cols, rows)
|
|
st.shadowState = 0
|
|
st.states = make(map[uint64]*mosh.Framebuffer)
|
|
st.displayedFB = nil
|
|
st.predictor.Reset()
|
|
}
|
|
|
|
// resetShadow clears the shadow emulator without reallocating it.
|
|
func (st *stateTracker) resetShadow() {
|
|
st.shadow.Resize(st.cols, st.rows)
|
|
st.shadow.Write([]byte("\033[H\033[2J\033[m"))
|
|
}
|
|
|