Pad/internal/editor/session.go
Greg Pomerantz 2eb18b5c70 Persist edits immediately so a fast app-kill loses no state
Reproduced on device: open a file, scroll, long-press-highlight a
word, then recents-wipe the app ~1s after the highlight. The session
file held the file but lost the selection and cursor: the 1s
rate-limit had not elapsed since the previous save, so the final
changes never flushed before the process died (a recents-wipe is a
kill, not a clean shutdown, so Shutdown's final save never runs).

Two changes in saveSessionIfChanged:

- Urgent path: save immediately when the file changes or a selection
  appears/vanishes. These are low-frequency, high-value changes
  (the user just opened a file or highlighted/cleared text), and they
  are exactly what 'where I left off' means.
- sessionSaveInterval 1s -> 250ms: bounds how stale scroll/cursor can
  be at an unlucky kill. The file is a few hundred bytes, so a few
  small writes a second during active scrolling is negligible.

TestRestore_UrgentSaveOnSelection drives the kill race: a selection
made right after the file-open save must land in the saver without
waiting out the interval (fails with the urgent path disabled).
2026-08-20 18:06:29 -04:00

254 lines
9.3 KiB
Go

// State restoration on relaunch (spec §7).
//
// The restorable state is a small snapshot (SessionState) the logic
// goroutine takes on demand: the last opened file, the cursor byte offset,
// the editor scroll offset, the live selection, and the find bar (query,
// open/closed, current match). The search RESULTS themselves are not part
// of the snapshot: on relaunch they are regenerated by re-scanning the
// restored query, and the current match is re-selected by byte offset
// (FindState.Restoring/RestoreMatch), without re-scrolling the restored
// viewport.
//
// Ownership (architecture.md §1): the logic goroutine owns the snapshot
// content. The cmd layer (cmd/pad/main.go) owns the tiny JSON file that
// stores it and registers the writer (Logic.SetSessionSaver); the logic
// calls it rate-limited from emitFrame and unconditionally at Shutdown, so
// the file is current even if the process is killed shortly after a change.
// On startup the cmd layer reads the file and hands it to Logic.BeginRestore
// (before Run, single-threaded), which re-opens the file and shows the
// editor immediately; the cursor/selection/find state land as the file's
// stat and content arrive (handleWorkerResult). A restore whose file is
// gone falls back to the browser page.
package editor
import (
"log"
"time"
"pad/internal/ui"
)
// sessionSaveInterval rate-limits the periodic session save. The file is
// tiny (a few hundred bytes), so a few writes a second at most is
// negligible. The window bounds how stale the persisted state can be if
// the process is killed (recents-wipe) right after a change: at 1s, a
// kill less than a second after the last edit lost the cursor, selection
// and final scroll. See saveSessionIfChanged for the urgent path.
const sessionSaveInterval = 250 * time.Millisecond
// SessionState is the relaunch snapshot (see the file doc above). All
// positions are absolute file byte offsets; Scroll is in Dp. It is a plain
// comparable value (used with == for change detection).
type SessionState struct {
File string // last opened file ("" = nothing to restore)
Cursor int // cursor byte offset
Scroll float64 // editor scroll offset, Dp
SelStart int // selection start byte (-1 = no selection)
SelEnd int // selection end byte, exclusive
FindQuery string // find bar query ("" = none)
FindVisible bool // find bar was open
FindCurByte int // start byte of the current find match (-1 = none)
}
// SetSessionSaver registers the callback that persists snapshots (the cmd
// layer's JSON file writer). It is invoked from the logic goroutine:
// rate-limited from emitFrame, and unconditionally at Shutdown. The
// callback must be fast (a tiny file write).
func (l *Logic) SetSessionSaver(f func(SessionState)) {
l.sessionSaver = f
}
// SnapshotSession captures the current relaunch snapshot. The cursor is
// clamped to the file length; the selection and current find match are -1/
// -1 when inactive. Must be called on the logic goroutine (or after it has
// fully exited, as Shutdown does).
func (l *Logic) SnapshotSession() SessionState {
s := l.state
e := &s.Editor
cur := e.CursorPosition
if cb := e.ChunkedBuffer; cb != nil {
if fl := int(cb.FileLen()); cur > fl {
cur = fl
}
}
f := &e.Find
curByte := -1
if f.Cur >= 0 && f.Cur < len(f.Matches) {
curByte = f.Matches[f.Cur][0]
}
return SessionState{
File: e.Filename,
Cursor: cur,
Scroll: float64(s.ScrollOffset),
SelStart: e.SelectionStart,
SelEnd: e.SelectionEnd,
FindQuery: f.Query,
FindVisible: f.Visible,
FindCurByte: curByte,
}
}
// saveSessionIfChanged persists the snapshot when it differs from the last
// saved one and the rate limit has elapsed. Must be called on the logic
// goroutine (it is called from emitFrame).
//
// While a restore is still pending (l.session set, not yet landed or
// abandoned) saves are suppressed: the pre-land snapshot has zeroed
// cursor/selection, and persisting it would clobber the positions being
// restored if the process is killed during startup.
func (l *Logic) saveSessionIfChanged() {
if l.sessionSaver == nil {
return
}
if l.session.File != "" {
return
}
now := time.Now()
s := l.SnapshotSession()
if s == l.lastSession {
return
}
// Save immediately when the file changes or a selection appears or
// vanishes — low-frequency, high-value changes (the user just opened a
// file or highlighted/cleared text). Everything else (scroll ticks,
// cursor moves, selection-handle drags) is high-frequency and stays
// rate-limited; a kill within the window then loses at most a fraction
// of a second of motion, not the edit state.
urgent := s.File != l.lastSession.File ||
(s.SelStart >= 0) != (l.lastSession.SelStart >= 0)
if !urgent && now.Sub(l.lastSessionSave) < sessionSaveInterval {
return
}
l.sessionSaver(s)
l.lastSession = s
l.lastSessionSave = now
}
// BeginRestore prepares the state for relaunch restoration: the last file
// is re-opened and the editor page shown immediately. The cursor,
// selection, find state and scroll land as the file's stat/content arrive
// (handleWorkerResult); the scroll offset is applied now and clamped by
// EditorLayout once the line index sets the real MaxScroll. Must be called
// after NewLogic and before Run (the cmd layer does both before starting
// the logic goroutine), so it touches state single-threaded.
func (l *Logic) BeginRestore(s SessionState) {
if s.File == "" {
return
}
l.session = s
e := &l.state.Editor
e.Filename = s.File
e.CursorPosition = 0 // the restored cursor lands with the content
e.TooLarge = false
e.TooLargeSize = 0
e.SelectionAnchor = -1
e.SelectionStart = -1
e.SelectionEnd = -1
// Fresh per-file find state; the snapshot's query and visibility land
// with the stat (the file must exist first).
e.findReset()
// The scroll offset is armed, not applied: it lands in the emitFrame
// hook, once the layout pass has a trustworthy viewport (see the Logic
// struct).
l.restoreScroll = ui.Dp(s.Scroll)
l.restoreScrollArmed = s.Scroll > 0
l.state.page = EditorPage
l.state.FocusedElementID = "editor_text"
l.state.justOpenedAt = time.Now()
l.emitFrame() // show the editor page now; the content loads async
}
// abortRestore ends a restore without landing it (the user opened another
// file, the read failed, or the file is too large): clears the pending
// snapshot so session saving resumes (see saveSessionIfChanged). The editor
// state itself is left alone. Must be called on the logic goroutine.
func (l *Logic) abortRestore() {
l.restoreFile = ""
l.restoreScrollArmed = false
l.restoreContentLanded = false
l.session = SessionState{}
}
// maybeApplyRestoreScroll lands the armed restore scroll offset once the
// viewport is trustworthy, clamped to the current MaxScroll (the layout
// clamps again if the line index shrinks it later). It is called from
// emitFrame AFTER the layout pass, because that pass is where MaxScroll is
// computed for the current viewport — applying the offset before it (or in a
// pass with an unknown viewport) would hit the one-way clamp and lose the
// offset. On device the first ScaleEvent can precede the size ConfigEvent
// (and both can precede the restored content), so the offset stays armed
// until scale, size and editor content are all known; it is applied on the
// first such emitFrame. restoreContentLanded (not FileLen, which the stat
// result sets before the content arrives) marks that the buffer holds the
// restored file; clearing restoreFile any earlier would make the read
// result's path guard miss the just-loaded file. Returns true if it
// applied. Must be called on the logic goroutine.
func (l *Logic) maybeApplyRestoreScroll() bool {
if !l.restoreScrollArmed {
return false
}
if !l.scaleSeen {
return false
}
if l.state.PixelWidth <= 0 || l.state.scale <= 0 {
return false
}
if l.state.Page() != EditorPage || !l.restoreContentLanded {
return false
}
if l.restoreScroll > l.state.MaxScroll {
l.restoreScroll = l.state.MaxScroll
}
l.state.ScrollOffset = l.restoreScroll
l.restoreScrollArmed = false
// The snapshot has fully landed (cursor/selection/find with the
// content, scroll now): lift the save suppression.
l.session = SessionState{}
return true
}
// applyRestorePositions clamps the restored snapshot's cursor and selection
// to a file of n bytes and applies them. Must be called on the logic
// goroutine.
func (l *Logic) applyRestorePositions(n int) {
s := l.session
e := &l.state.Editor
if c := s.Cursor; c >= 0 {
if c > n {
c = n
}
e.CursorPosition = c
}
if ss, se := s.SelStart, s.SelEnd; ss >= 0 && se > ss {
if se > n {
se = n
}
if ss < se {
e.SelectionAnchor = ss
e.SelectionStart = ss
e.SelectionEnd = se
}
}
}
// abandonRestore gives up on the relaunch restore (the file's stat failed:
// it was deleted or moved since the last session): reset the editor and
// land on the browser page, as if nothing had been restored. Must be called
// on the logic goroutine.
func (l *Logic) abandonRestore() {
l.abortRestore()
e := &l.state.Editor
log.Printf("Logic: cannot restore %q; starting in the browser", e.Filename)
e.Filename = ""
e.ChunkedBuffer = nil
e.CursorPosition = 0
e.SelectionAnchor = -1
e.SelectionStart = -1
e.SelectionEnd = -1
e.Find = FindState{SettleByte: -1}
l.state.page = BrowserPage
l.state.ScrollOffset = 0
l.state.FocusedElementID = ""
}