Restore last file, cursor, scroll, selection and find state on relaunch (spec §7)

Persist a tiny JSON snapshot (SessionState) written by the cmd layer
($HOME/.pad/session.json off-Android, /storage/emulated/0/Pad/ on
Android) and call the logic-owned snapshot rate-limited (<=1/s, on
change) from emitFrame plus unconditionally at Shutdown. On launch the
cmd layer hands the snapshot to Logic.BeginRestore before Run; the
file re-opens through the normal openFile path and lands straight on
the editor page.

- Cursor/selection land with the content, clamped to a shrunk file
  (path-guarded so a late result for a replaced file cannot apply the
  snapshot to the wrong buffer); a missing file falls back to the
  browser.
- Scroll is applied only after the first ScaleEvent has been laid out:
  the size ConfigEvent precedes it and the one-way MaxScroll clamp in a
  wrong-unit layout would corrupt the offset (found by e2e).
- Find: query + open/closed + current match are persisted; results are
  regenerated by re-scanning and the saved current match is re-selected
  by byte offset (Find.Restoring/RestoreMatch) without re-scrolling the
  restored viewport. A closed-bar query re-scans on the next bar open
  instead (an eager scan would be dropped and leave Scanning stuck).
- The main-owned find_bar widget is seeded with the restored query so
  its first frame matches the logic-side query.

Docs: spec.md gains §2.4 and drops the §7 row; invariant 5 updated;
architecture.md gains §6.7. Tests: 7 e2e tests covering cursor/scroll/
selection restore, clamping, missing-file fallback, find-bar restore
(open/closed), and the saver/shutdown persist paths.
This commit is contained in:
Greg Pomerantz 2026-08-20 16:01:02 -04:00
parent f31f849665
commit e3690d6b98
10 changed files with 961 additions and 23 deletions

View File

@ -87,6 +87,13 @@ func SetGestureExclusions(rects [][4]int) {
})
}
// sessionFilePath is where the relaunch session file (spec §7) lives. The
// primary storage dir already carries the app's caches (.pad browser index
// dirs, PadPerf), and the app holds the storage permission.
func sessionFilePath() string {
return "/storage/emulated/0/Pad/session.json"
}
func OpenFile(path string) {
var env *C.JNIEnv
var detach bool

View File

@ -3,6 +3,9 @@
package main
import (
"os"
"path/filepath"
"gioui.org/io/event"
)
@ -10,6 +13,16 @@ var (
startpath="."
)
// sessionFilePath is where the relaunch session file (spec §7) lives, off
// Android: $HOME/.pad/session.json, falling back to the temp dir when there
// is no home.
func sessionFilePath() string {
if d, err := os.UserHomeDir(); err == nil && d != "" {
return filepath.Join(d, ".pad", "session.json")
}
return filepath.Join(os.TempDir(), "pad", "session.json")
}
func handleEvent(e event.Event) { }
func OpenFile(path string) { }

View File

@ -1,6 +1,7 @@
package main
import (
"encoding/json"
"flag"
"io"
"log"
@ -106,6 +107,25 @@ func run(w *app.Window) error {
var findEditor widget.Editor
renderer.RegisterGioEditor("find_bar", &findEditor)
// Relaunch state restoration (spec §7): a tiny JSON file holds the last
// file, cursor, scroll, selection and find bar state. The cmd layer owns
// the file (sessionFilePath, per platform); the logic layer owns the
// snapshot (editor.SessionState) and calls the saver rate-limited and at
// Shutdown. Restoring re-opens the last file straight into the editor.
sessPath := sessionFilePath()
logic.SetSessionSaver(newSessionSaver(sessPath))
if sess, ok := loadSession(sessPath); ok {
log.Printf("restoring session: file=%s cursor=%d scroll=%v find=%q", sess.File, sess.Cursor, sess.Scroll, sess.FindQuery)
logic.BeginRestore(sess)
// The find bar's input is a main-owned widget and the input source of
// truth: seed it with the restored query so its first frame matches
// the logic-side query (an empty widget would forward "" and clear
// the restored query).
if sess.FindQuery != "" {
findEditor.SetText(sess.FindQuery)
}
}
// Clipboard plumbing (architecture.md §6.3): the logic goroutine only
// REQUESTS clipboard operations through channels (copy/cut write, paste
// read); the main goroutine executes the Gio ops during a frame and
@ -360,6 +380,58 @@ func run(w *app.Window) error {
}
// exclEqual reports whether two exclusion-rect sets are identical.
// newSessionSaver builds the relaunch-session file writer registered with
// the logic goroutine (spec §7). The file is a tiny JSON snapshot; a torn
// write is rejected by loadSession's parse on the next launch, so a plain
// write is safe (no temp+rename needed).
func newSessionSaver(path string) func(editor.SessionState) {
return func(s editor.SessionState) {
b, err := json.Marshal(s)
if err != nil {
log.Printf("session: marshal: %v", err)
return
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
log.Printf("session: mkdir: %v", err)
return
}
if err := os.WriteFile(path, b, 0o600); err != nil {
log.Printf("session: write: %v", err)
}
}
}
// loadSession reads and sanity-checks the relaunch session file (spec §7).
// ok=false when there is no session, it is corrupt, or nothing is restorable
// — in which case the app starts in the browser as before.
func loadSession(path string) (editor.SessionState, bool) {
b, err := os.ReadFile(path)
if err != nil {
return editor.SessionState{}, false
}
var s editor.SessionState
if err := json.Unmarshal(b, &s); err != nil {
return editor.SessionState{}, false
}
// A partial/corrupt file must not restore garbage positions.
if s.Cursor < 0 {
s.Cursor = 0
}
if s.Scroll < 0 {
s.Scroll = 0
}
if s.SelStart < -1 || s.SelEnd < -1 || s.SelEnd <= s.SelStart {
s.SelStart, s.SelEnd = -1, -1
}
if s.FindCurByte < -1 {
s.FindCurByte = -1
}
if s.File == "" {
return editor.SessionState{}, false
}
return s, true
}
func exclEqual(a, b [][4]int) bool {
if len(a) != len(b) {
return false

View File

@ -457,11 +457,45 @@ Only the visible byte range is shaped and drawn each frame:
- A browser tap sends the path on `OpenFileChan`. The logic goroutine creates
the `ChunkedBuffer`, dispatches `StatFile` (size guard) and the read +
`BuildLineIndex` tasks, switches `page` to the editor, and sets
`justOpenedAt`.
`justOpenedAt`. The relaunch restore (below) reuses the same `openFile`
path.
- **Opening-tap swallow:** the tap that opens the file is also delivered as an
editor tap in the same frame. A short time window (`justOpenedAt`) swallows
it so the viewport does not jump to the tapped (often EOF) position.
### 6.7 State restoration on relaunch (`session.go`)
- The restorable state is a plain comparable value, `SessionState` (last
file, cursor byte, scroll Dp, selection range, find query/visibility/
current-match byte). The search results themselves are not stored; they are
re-scanned on restore.
- **Ownership:** the logic goroutine owns snapshot content. The cmd layer
(`cmd/pad/main.go`) owns the JSON file and registers the writer via
`Logic.SetSessionSaver`; the owner invokes it rate-limited (≤ 1/s, only on
change) from `emitFrame` and unconditionally at `Shutdown` (post-exit,
single-threaded, next to `FlushAll`). Satisfies the single-owner rule:
the callback receives a value copy, and the file I/O is a tiny synchronous
write in the cmd layer's closure.
- **Restore:** the cmd layer reads the file at startup and calls
`Logic.BeginRestore(s)` BEFORE `Run()` (single-threaded window, like
`NewLogic` itself): it sets `Filename`, shows the editor page, and arms the
scroll. `Run()` then re-opens the file through the normal `openFile` path.
- **Stat success:** the find state lands (query, visibility, current-match
byte via `Find.Restoring`/`Find.RestoreMatch`, so the re-scan's first
result re-selects the saved match WITHOUT re-scrolling the restored
viewport).
- **Content arrival:** cursor/selection land, clamped to the file length
(path-guarded, so a late result for a replaced file cannot apply the
snapshot to the wrong buffer); the query re-scans only if the find bar
was open (a closed-bar scan would be dropped and leave `Scanning` stuck).
- **Scroll offset:** applied only after the first `ScaleEvent` has been
laid out: the size `ConfigEvent` precedes it, and a layout in between
computes the viewport in the wrong unit, whose one-way `MaxScroll` clamp
would corrupt the offset.
- **File gone** (stat failure): the restore is abandoned and the app lands
on the browser page with a clean editor. Any user open of another file
cancels an in-flight restore for the same reason as the path-guard.
## 7. Browser internals (`internal/browser`)
- `BrowserState` is **embedded by value** in `State` (single owner; no

View File

@ -95,7 +95,8 @@ elsewhere.
the newer content when it completes ("latest state wins"). Writes stage to
a unique per-call temp file and rename into place, so a crash or a
concurrent reader never observes a partial file. Failed writes are retried.
This is the only persistence mechanism.
This is the only document persistence mechanism (the app's own state is a
separate tiny session file — §2.4).
### 2.3 Large files (measured)
@ -110,6 +111,25 @@ elsewhere.
in-range files); edits splice only affected chunks. Details:
`architecture.md` §6.
### 2.4 State restoration on relaunch
- On launch, Pad re-opens the file from the last session and lands straight
on the editor page, restoring the cursor, scroll offset, live selection,
and the find bar (query, open/closed, current match). A relaunch therefore
never requires re-browsing to the last file.
- The snapshot is a tiny JSON file (a few hundred bytes) written by the
cmd layer: `$HOME/.pad/session.json` off-Android, and
`/storage/emulated/0/Pad/session.json` on Android (the dir that already
carries the browser's `.pad` index caches). Search results are NOT stored:
they are regenerated by re-scanning the restored query, and the current
match is re-selected by byte offset.
- The snapshot is written rate-limited (≤ 1/s, only on change) while the app
runs and unconditionally at shutdown, so a kill shortly after a change
loses at most ~1 s of state.
- If the restored file no longer exists (deleted/moved, e.g. by Syncthing),
the app falls back to the browser page; positions that exceed a shrunk
file are clamped to its new end.
## 3. Code organization (actual)
```
@ -119,7 +139,8 @@ internal/
browser/ # BrowserState, BrowserManager, sort, search,
# pagination, layout, handlers
editor/ # Logic goroutine, State, ChunkedBuffer, LineIndex,
# IME handling, autosave, Frame handoff (frame.go)
# IME handling, autosave, relaunch session (session.go),
# Frame handoff (frame.go)
io/pool/ # Worker pool (priority lanes), task types,
# real/ — real filesystem (rooted at /)
# mock/ — in-memory FS for tests
@ -153,9 +174,12 @@ Full details, channel topology, and ownership rules: [`architecture.md`](./archi
inflates memory (this caused a 1 GB leak, fixed in Phase 3).
4. **The IME snippet is the visible window**, and rune↔byte conversion happens
in one place (`HandleReplaceRange` / `RuneIndexToByte`).
5. **Autosave is the only persistence.** If state persistence (last file,
cursor, scroll) is added later, it must go through the same
owner-dispatches-a-task pattern.
5. **Persistence goes through the owner.** Autosave dispatches `WriteFile`
tasks for document content; the relaunch session (last file, cursor,
scroll, selection, find state) is marshaled by the owner and written by
the cmd layer's saver callback (tiny JSON, synchronous, torn writes
rejected on load). If session state ever grows beyond a few KB, move the
write to a worker-pool task (autosave pattern).
6. **Logic work stays < 16 ms.** Anything that can block or scan more than the
viewport goes to the worker pool.
7. **Scroll offset is always clamped to `[0, maxScroll]`,** where
@ -183,7 +207,6 @@ recorded here so future rounds don't mistake doc text for behavior:
| Feature | Status | Notes |
|---|---|---|
| Undo (any) | **not implemented** | No undo stack exists; the old `SaveUndoTask` is dead code. |
| State restoration on relaunch | **not implemented** | Last file / cursor / scroll are not persisted. |
| External change detection | **not implemented** | No mtime compare on open/resume, no watcher, no Keep/Reload prompt. |
| Syncthing conflict handling | **not implemented** | No `.sync-conflict-*` file detection or merging. |
| File-system watcher | **not implemented** | Browser does not live-refresh; it re-scans on navigation. |

View File

@ -91,6 +91,26 @@ type Logic struct {
saveTimer *time.Timer // auto-save debounce timer; non-nil while pending
lastEmit time.Time // time of the last frame emission (profiler cadence)
debugCmdC chan string // one-shot debug commands from the cmd-file poller; nil = disabled
// Relaunch state restoration (spec §7, see session.go): session is the
// snapshot handed in by the cmd layer via BeginRestore (zero = none),
// restoreFile names the open whose stat/read results are the restore's
// ("" = none; any other open cancels it). sessionSaver is the cmd layer's
// file writer; lastSession/lastSessionSave drive the rate-limited
// change-detected save from emitFrame. All touched only on the owner.
session SessionState
restoreFile string
// restoreScroll/restoreScrollArmed hold the snapshot's scroll offset
// until it is safe to apply it: only once the scale is known (the first
// ScaleEvent — the size ConfigEvent arrives before it, and a layout in
// between computes the viewport in the wrong unit, which would clamp
// the offset, and the clamp is one-way). scaleSeen tracks that event.
restoreScroll ui.Dp
restoreScrollArmed bool
scaleSeen bool
sessionSaver func(SessionState)
lastSession SessionState
lastSessionSave time.Time
}
// NewLogic creates a new Logic instance, accepting an optional mockFS.
@ -211,6 +231,14 @@ func (l *Logic) Run() {
// Dispatch initial directory index build on startup
l.workerPool.Dispatch(pool.NewBuildIndexTask(l.state.Browser.CurrentPath, l.mockFS))
// Relaunch restoration (spec §7): re-open the last file if a session
// was handed in before Run (BeginRestore). The browser index is still
// built: the back button must land on a populated browser.
if l.session.File != "" {
l.restoreFile = l.session.File
l.openFile(l.session.File)
}
for {
select {
case <-l.done:
@ -222,7 +250,18 @@ func (l *Logic) Run() {
return
case update := <-l.configChan:
update.apply(l.state)
if _, ok := update.(ScaleEvent); ok {
l.scaleSeen = true
}
l.emitFrame()
// The restored scroll lands only AFTER a layout at the current
// scale: its clamp uses MaxScroll, which must reflect the same
// viewport units (the size ConfigEvent precedes the first
// ScaleEvent, so a layout between them is in the wrong unit).
if l.scaleSeen && l.restoreScrollArmed {
l.maybeApplyRestoreScroll()
l.emitFrame()
}
case fb := <-l.layoutChan:
// Store the full GlyphLayout on editor state.
// Derive LastLineY from it for scroll clamping.
@ -266,17 +305,7 @@ func (l *Logic) Run() {
l.state.Editor.findSetQuery(q)
l.emitFrame()
case path := <-l.openFileChan:
// Discard find results for the previous file; the query is kept
// (see EditorState.findReset) and re-scanned against the new file.
TheState.Editor.findReset()
// Create chunked buffer for virtual scrolling
chunkSize := DefaultChunkSize
cb := NewChunkedBuffer(path, chunkSize, l.mockFS, "")
cb.SetWorkerPool(l.workerPool)
TheState.Editor.ChunkedBuffer = cb
// Dispatch stat task to get file size
l.workerPool.Dispatch(pool.NewStatFileTask(path, l.mockFS))
l.openFile(path)
case filename := <-l.retryChan:
log.Printf("Logic: Retrying save for %s", filename)
delete(l.retryScheduled, filename)
@ -304,11 +333,36 @@ func (l *Logic) Run() {
}
}
// openFile starts loading path in the editor (a browser row tap or the
// relaunch restore). Any open of a file that is not the in-flight restore
// cancels the restore: its late stat/read results must not re-apply the
// snapshot's positions to a different file. Must be called on the logic
// goroutine.
func (l *Logic) openFile(path string) {
if l.restoreFile != "" && l.restoreFile != path {
l.abortRestore() // a different open cancels the in-flight one
}
// Discard find results for the previous file; the query is kept
// (see EditorState.findReset) and re-scanned against the new file.
TheState.Editor.findReset()
// Create chunked buffer for virtual scrolling
chunkSize := DefaultChunkSize
cb := NewChunkedBuffer(path, chunkSize, l.mockFS, "")
cb.SetWorkerPool(l.workerPool)
TheState.Editor.ChunkedBuffer = cb
// Dispatch stat task to get file size
l.workerPool.Dispatch(pool.NewStatFileTask(path, l.mockFS))
}
// emitFrame computes the current frame, records a profiler probe (if enabled),
// and hands it to the main goroutine. Centralizing emission here ensures the
// in-app profiler (PerfRecord) sees every frame exactly once, on the owner
// goroutine. Must be called on the logic goroutine.
func (l *Logic) emitFrame() {
// Relaunch snapshot (spec §7): persist when the state has changed and
// the rate limit elapsed (tiny JSON file, see session.go).
l.saveSessionIfChanged()
elems := l.state.layout(l.browserManager)
now := time.Now()
if PerfRecord != nil {
@ -518,7 +572,28 @@ func (l *Logic) handleWorkerResult(res pool.Result) {
// Fallback: populate the deprecated Buffer field
l.state.Editor.Buffer = string(content)
}
// Relaunch restoration (spec §7): the content is in memory, so
// land the snapshot's cursor/selection (clamped to the file)
// and re-scan the restored find query. Path-guarded: a read
// result for a file the user has since replaced must not apply
// the snapshot to the replacement's buffer.
if res.FilePath == l.state.Editor.Filename && l.restoreFile == res.FilePath {
l.applyRestorePositions(len(content))
l.maybeApplyRestoreScroll()
l.abortRestore()
// Re-scan the restored query only when the bar was open:
// with the bar closed, the result would be dropped (the apply
// gate requires Visible) and Scanning would stay stuck true;
// findShow re-scans on the next open instead.
if f := &l.state.Editor.Find; f.Visible && f.Query != "" && !f.Scanning {
l.state.Editor.findDispatchScan()
}
}
}
} else if res.FilePath == l.restoreFile {
// The restored file's content could not be read: drop the
// restore (the editor keeps the empty file view it has).
l.abortRestore()
}
} else if res.TaskType == pool.TypeReadChunk {
// In-range files load fully via SetContent, so this is only a fallback.
@ -539,9 +614,22 @@ func (l *Logic) handleWorkerResult(res pool.Result) {
} else if res.TaskType == pool.TypeStatFile {
if res.Success {
if stat, ok := res.Data.(*pool.FileStat); ok {
// Relaunch restoration (spec §7): the file exists, so land
// the snapshot's find state now (the cursor/selection land
// with the content, the query re-scans against the new file).
if res.FilePath == l.restoreFile && l.restoreFile != "" {
f := &l.state.Editor.Find
f.Query = l.session.FindQuery
f.Visible = l.session.FindVisible
f.RestoreMatch = l.session.FindCurByte
f.Restoring = l.session.FindVisible
}
// Size guard: refuse to edit files above the limit. The browser can
// still list them; the editor shows a "too large to edit" notice.
if stat.Size > MaxEditableFileSize {
if l.restoreFile != "" {
l.abortRestore() // no content load follows; nothing lands
}
l.state.Editor.TooLarge = true
l.state.Editor.TooLargeSize = stat.Size
log.Printf("Logic: %s is %d bytes, exceeds the %d-byte edit limit", stat.Path, stat.Size, MaxEditableFileSize)
@ -555,6 +643,11 @@ func (l *Logic) handleWorkerResult(res pool.Result) {
l.workerPool.Dispatch(pool.NewBuildLineIndexTask(stat.Path, l.mockFS))
}
}
} else if res.FilePath == l.restoreFile && l.restoreFile != "" {
// The restored file is gone (deleted/moved since the last
// session): fall back to the browser instead of showing an
// empty editor.
l.abandonRestore()
}
} else if res.TaskType == pool.TypeBuildLineIndex {
if res.Success {
@ -768,5 +861,11 @@ func (l *Logic) Shutdown() {
l.Done()
l.WaitForExit()
l.FlushAll()
// Final relaunch snapshot (spec §7): after exit the caller owns the
// state single-threaded, like FlushAll above, so one unconditional
// save makes the file exact for the next launch.
if l.sessionSaver != nil {
l.sessionSaver(l.SnapshotSession())
}
l.workerPool.Stop()
}

View File

@ -55,6 +55,16 @@ type FindState struct {
// and the logic storing it (a round-trip away), wiping the input while
// typing.
ClearSeq int
// --- Relaunch restoration (spec §7, see session.go) ---
// Restoring is set while a restored session's find scan is in flight:
// its first result selects the restored current match (RestoreMatch) but
// does NOT scroll — the viewport stays where the restored scroll offset
// put it. applySearchResult consumes both; findReset drops them.
Restoring bool
// RestoreMatch is the byte offset of the pre-launch current find match
// (the snapshot's FindCurByte); -1 = none.
RestoreMatch int
}
// ToggleFind opens the find bar (or closes it when open). It is the tap
@ -205,18 +215,41 @@ func (e *EditorState) applySearchResult(res pool.Result) {
f.Cur = i
}
}
// Relaunch restore: select the pre-launch current match by byte
// offset (spec §7) — the one containing the byte, else the first
// match after it, else the last (its text may have been edited
// away, so a fallback is always defined).
if f.Cur < 0 && f.RestoreMatch >= 0 {
i := sort.Search(len(f.Matches), func(i int) bool {
return f.Matches[i][0] > f.RestoreMatch
})
switch {
case i > 0 && f.Matches[i-1][1] > f.RestoreMatch:
f.Cur = i - 1
case i < len(f.Matches):
f.Cur = i
default:
f.Cur = len(f.Matches) - 1
}
}
if f.Cur < 0 {
f.Cur = 0
}
// The first discovery of matches (matches went 0 -> N) selects and
// scrolls the view to the current match; while the user keeps
// typing, results update in place and the view moves only on
// explicit next/prev.
// The first discovery of matches (matches went 0 -> N) selects the
// current match and scrolls the view to it — except during a
// relaunch restore (Restoring), where the viewport must stay where
// the restored scroll offset put it. While the user keeps typing,
// results update in place and the view moves only on explicit
// next/prev.
if wasEmpty {
SetSelection(f.Matches[f.Cur][0], f.Matches[f.Cur][1])
scrollToFindMatch(f.Matches[f.Cur][0])
if !f.Restoring {
scrollToFindMatch(f.Matches[f.Cur][0])
}
}
}
f.Restoring = false
f.RestoreMatch = -1
}
// findStep selects the next (dir>0) or previous (dir<0) match, wrapping

219
internal/editor/session.go Normal file
View File

@ -0,0 +1,219 @@
// 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 one write a second at most is negligible.
const sessionSaveInterval = time.Second
// 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()
if now.Sub(l.lastSessionSave) < sessionSaveInterval {
return
}
s := l.SnapshotSession()
if s == l.lastSession {
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 must not be clamped by a
// layout that runs before the scale is known (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.session = SessionState{}
}
// maybeApplyRestoreScroll lands the armed restore scroll offset once the
// viewport unit is trustworthy (the first ScaleEvent has arrived), clamped
// to the current MaxScroll (the layout clamps again if the line index
// shrinks it later). Must be called on the logic goroutine.
func (l *Logic) maybeApplyRestoreScroll() {
if !l.restoreScrollArmed {
return
}
if !l.scaleSeen {
return
}
if l.restoreScroll > l.state.MaxScroll {
l.restoreScroll = l.state.MaxScroll
}
l.state.ScrollOffset = l.restoreScroll
l.restoreScrollArmed = false
}
// 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 = ""
}

View File

@ -260,6 +260,11 @@ func (s *State) SetFontScale(fs float32) {
s.fontScale = fs
}
// Page returns the current page (BrowserPage or EditorPage).
func (s *State) Page() Page {
return s.page
}
func (s *State) Scale() float32 {
return s.scale
}

View File

@ -0,0 +1,433 @@
package e2e_test
// Relaunch state restoration (spec §7): the last file, cursor, scroll,
// selection and find bar state are persisted as a tiny JSON snapshot and
// restored on the next launch. These tests drive the same entry points the
// cmd layer uses: Logic.BeginRestore before Run (restore) and the session
// saver callback (persist).
import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
"pad/internal/editor"
"pad/internal/io/pool/real"
"pad/internal/test/e2e"
"pad/internal/ui"
)
// restoreHarness writes content to <tempdir>/name, builds a harness over the
// real filesystem, hands sess to the logic BEFORE Run (as the cmd layer does
// on relaunch), starts it with the standard 390x844@2x window, and waits for
// the restored file to fully load (content + line index).
func restoreHarness(t *testing.T, name, content string, sess editor.SessionState) *e2e.Harness {
t.Helper()
dir := t.TempDir()
diskPath := filepath.Join(dir, name)
if err := os.WriteFile(diskPath, []byte(content), 0644); err != nil {
t.Fatal(err)
}
sess.File = "/" + name
h := e2e.NewHarness(e2e.WithFileSystem(real.NewRealFileSystem(dir), "/"))
h.Logic().BeginRestore(sess)
h.Run()
h.SendConfig(780, 1688) // 390x844 @ 2x scale
h.SendScale(2.0)
// "Loaded" means both async results are fully applied: the ReadFile
// content AND the BuildLineIndex result (same contract as
// realFileHarness: FileLen>0 && LineIndex!=nil alone is a race).
for i := 0; i < 100; i++ {
v, err := h.Inspect(func(st *editor.State) any {
cb := st.Editor.ChunkedBuffer
if cb == nil || cb.FileLen() == 0 || cb.LineIndex == nil {
return false
}
full, err := cb.FullContent()
return err == nil && int64(len(full)) == cb.FileLen() &&
cb.LineIndex.Size == cb.FileLen()
})
if err != nil {
t.Fatalf("Inspect: %v", err)
}
if v.(bool) {
return h
}
time.Sleep(50 * time.Millisecond)
}
t.Fatal("timed out waiting for restored file to load")
return nil
}
// TestRestore_FileCursorScrollSelection restores a file with a cursor, a
// non-zero scroll and a live selection; all three must land once the content
// arrives, on the editor page (not the browser).
func TestRestore_FileCursorScrollSelection(t *testing.T) {
content := strings.Repeat("hello world line\n", 100)
h := restoreHarness(t, "notes.txt", content, editor.SessionState{
Cursor: 123,
Scroll: 100,
SelStart: 100,
SelEnd: 110,
})
defer h.Cleanup()
v, err := h.Inspect(func(st *editor.State) any {
return struct {
Page editor.Page
File string
Cursor int
Scroll ui.Dp
SelStart int
SelEnd int
Anchor int
}{st.Page(), st.Editor.Filename, st.Editor.CursorPosition, st.ScrollOffset,
st.Editor.SelectionStart, st.Editor.SelectionEnd, st.Editor.SelectionAnchor}
})
if err != nil {
t.Fatalf("Inspect: %v", err)
}
got := v.(struct {
Page editor.Page
File string
Cursor int
Scroll ui.Dp
SelStart int
SelEnd int
Anchor int
})
if got.Page != editor.EditorPage {
t.Errorf("page = %v, want EditorPage (restore lands straight in the editor)", got.Page)
}
if got.File != "/notes.txt" {
t.Errorf("file = %q, want /notes.txt", got.File)
}
if got.Cursor != 123 {
t.Errorf("cursor = %d, want 123", got.Cursor)
}
if got.Scroll != ui.Dp(100) {
t.Errorf("scroll = %v, want 100", got.Scroll)
}
if got.SelStart != 100 || got.SelEnd != 110 || got.Anchor != 100 {
t.Errorf("selection = [%d,%d) anchor %d, want [100,110) anchor 100",
got.SelStart, got.SelEnd, got.Anchor)
}
}
// TestRestore_ClampsToShorterFile: the file was truncated since the last
// session; the cursor and selection must clamp to the shorter content, not
// restore past EOF.
func TestRestore_ClampsToShorterFile(t *testing.T) {
content := "0123456789" // 10 bytes
h := restoreHarness(t, "short.txt", content, editor.SessionState{
Cursor: 57, // far past EOF
SelStart: 5,
SelEnd: 99, // spans EOF
})
defer h.Cleanup()
v, err := h.Inspect(func(st *editor.State) any {
return struct{ Cursor, SelStart, SelEnd int }{
st.Editor.CursorPosition, st.Editor.SelectionStart, st.Editor.SelectionEnd,
}
})
if err != nil {
t.Fatalf("Inspect: %v", err)
}
got := v.(struct{ Cursor, SelStart, SelEnd int })
if got.Cursor != 10 {
t.Errorf("cursor = %d, want 10 (clamped to EOF)", got.Cursor)
}
if got.SelStart != 5 || got.SelEnd != 10 {
t.Errorf("selection = [%d,%d), want [5,10) (end clamped to EOF)", got.SelStart, got.SelEnd)
}
}
// TestRestore_MissingFileFallsBackToBrowser: the restored file was deleted
// since the last session; the app must land on the browser page with a clean
// editor, not an empty editor page.
func TestRestore_MissingFileFallsBackToBrowser(t *testing.T) {
dir := t.TempDir()
h := e2e.NewHarness(e2e.WithFileSystem(real.NewRealFileSystem(dir), "/"))
h.Logic().BeginRestore(editor.SessionState{File: "/gone.txt", Cursor: 10})
h.Run()
defer h.Cleanup()
h.SendConfig(780, 1688)
h.SendScale(2.0)
deadline := time.Now().Add(5 * time.Second)
for {
v, err := h.Inspect(func(st *editor.State) any {
return st.Page() == editor.BrowserPage && st.Editor.Filename == "" &&
st.Editor.ChunkedBuffer == nil
})
if err != nil {
t.Fatalf("Inspect: %v", err)
}
if v.(bool) {
return
}
if time.Now().After(deadline) {
t.Fatal("timed out: restore of a missing file did not fall back to the browser")
}
time.Sleep(25 * time.Millisecond)
}
}
// TestRestore_FindBar reopens the file with the find bar open, a query and a
// current match: the query must re-scan, select the saved current match (by
// byte offset) and NOT re-scroll the restored viewport.
func TestRestore_FindBar(t *testing.T) {
var b strings.Builder
for i := 0; i < 50; i++ {
fmt.Fprintf(&b, "filler line number %02d\n", i)
}
b.WriteString("needle at start of the interesting part\n")
b.WriteString("middle filler\n")
b.WriteString("needle in the middle of it\n")
b.WriteString("tail filler\n")
b.WriteString("needle at the end\n")
for i := 0; i < 50; i++ {
fmt.Fprintf(&b, "trailing filler %02d\n", i)
}
content := b.String()
// Offsets of the three "needle" occurrences.
first := strings.Index(content, "needle")
second := strings.Index(content[first+1:], "needle") + first + 1
third := strings.Index(content[second+1:], "needle") + second + 1
if first < 0 || second < 0 || third < 0 {
t.Fatal("test content lost its needles")
}
h := restoreHarness(t, "findme.txt", content, editor.SessionState{
FindQuery: "needle",
FindVisible: true,
FindCurByte: second,
// Cursor/scroll restored at the top: if the re-scan wrongly
// re-scrolls to the match, ScrollOffset would no longer be 0.
})
defer h.Cleanup()
deadline := time.Now().Add(5 * time.Second)
for {
v, err := h.Inspect(func(st *editor.State) any {
f := &st.Editor.Find
return struct {
Visible, Scanning bool
N, Cur, SelStart, SelEnd int
Scroll ui.Dp
}{f.Visible, f.Scanning, len(f.Matches), f.Cur,
st.Editor.SelectionStart, st.Editor.SelectionEnd, st.ScrollOffset}
})
if err != nil {
t.Fatalf("Inspect: %v", err)
}
got := v.(struct {
Visible, Scanning bool
N, Cur, SelStart, SelEnd int
Scroll ui.Dp
})
if !got.Scanning && got.N == 3 {
if !got.Visible {
t.Errorf("find bar visible = false, want true")
}
if got.Cur != 1 {
t.Errorf("current match = %d, want 1 (the saved current item)", got.Cur)
}
if got.SelStart != second || got.SelEnd != second+len("needle") {
t.Errorf("selection = [%d,%d), want [%d,%d)",
got.SelStart, got.SelEnd, second, second+len("needle"))
}
if got.Scroll != 0 {
t.Errorf("scroll = %v, want 0 (restore must not re-scroll to the match)", got.Scroll)
}
return
}
if time.Now().After(deadline) {
t.Fatalf("timed out waiting for the restore re-scan (matches=%d scanning=%v)", got.N, got.Scanning)
}
time.Sleep(25 * time.Millisecond)
}
}
// TestRestore_FindBarClosed keeps the query but does not open the bar: no
// scan may run at restore (it would be dropped and leave Scanning stuck);
// opening the bar afterwards must re-scan and produce the matches.
func TestRestore_FindBarClosed(t *testing.T) {
var b strings.Builder
for i := 0; i < 60; i++ {
fmt.Fprintf(&b, "padding line %02d\n", i)
}
b.WriteString("needle one\nneedle two\nneedle three\n")
content := b.String()
h := restoreHarness(t, "closed.txt", content, editor.SessionState{
FindQuery: "needle",
FindVisible: false,
})
defer h.Cleanup()
// Give any (wrong) eager scan time to dispatch, then check state.
time.Sleep(300 * time.Millisecond)
v, err := h.Inspect(func(st *editor.State) any {
f := &st.Editor.Find
return struct {
Visible, Scanning bool
Query string
N int
}{f.Visible, f.Scanning, f.Query, len(f.Matches)}
})
if err != nil {
t.Fatalf("Inspect: %v", err)
}
got := v.(struct {
Visible, Scanning bool
Query string
N int
})
if got.Visible {
t.Errorf("find bar visible = true, want false")
}
if got.Query != "needle" {
t.Errorf("query = %q, want needle", got.Query)
}
if got.Scanning {
t.Errorf("scanning = true after a closed-bar restore; the scan would be dropped and never re-run")
}
if got.N != 0 {
t.Errorf("matches = %d, want 0 (no scan while the bar is closed)", got.N)
}
// Opening the bar now must re-scan (findShow) and find all three.
if err := h.WithState(func(st *editor.State) { editor.ToggleFind(nil) }); err != nil {
t.Fatalf("ToggleFind: %v", err)
}
deadline := time.Now().Add(5 * time.Second)
for {
n, err := h.Inspect(func(st *editor.State) any {
return len(st.Editor.Find.Matches)
})
if err != nil {
t.Fatalf("Inspect: %v", err)
}
if n.(int) == 3 {
return
}
if time.Now().After(deadline) {
t.Fatal("timed out: opening the find bar did not re-scan the restored query")
}
time.Sleep(25 * time.Millisecond)
}
}
// TestRestore_SaverSnapshot verifies the persist side: a state change
// (cursor + selection + scroll) is picked up by the rate-limited saver as a
// snapshot with the right values.
func TestRestore_SaverSnapshot(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "small.txt"), []byte("hello world"), 0644); err != nil {
t.Fatal(err)
}
saves := make(chan editor.SessionState, 16)
h := e2e.NewHarness(e2e.WithFileSystem(real.NewRealFileSystem(dir), "/"))
h.Logic().SetSessionSaver(func(s editor.SessionState) { saves <- s })
h.Run()
h.SendConfig(780, 1688)
h.SendScale(2.0)
defer h.Cleanup()
// Open the file the usual way (browser-row tap path).
if err := h.WithState(func(st *editor.State) { editor.OpenFile("/small.txt") }); err != nil {
t.Fatalf("OpenFile: %v", err)
}
time.Sleep(300 * time.Millisecond) // let the load settle
// Change state on the owner, then force a frame (emitFrame drives the
// rate-limited save).
if err := h.WithState(func(st *editor.State) {
st.Editor.CursorPosition = 6
st.Editor.SelectionAnchor = 0
st.Editor.SelectionStart = 0
st.Editor.SelectionEnd = 5
st.ScrollOffset = 10
}); err != nil {
t.Fatalf("WithState: %v", err)
}
// The save is rate-limited to one per second; wait past the limit, then
// force a frame (emitFrame drives the check).
time.Sleep(1100 * time.Millisecond)
h.SendConfig(780, 1688)
deadline := time.Now().Add(5 * time.Second)
for {
remaining := time.Until(deadline)
if remaining <= 0 {
t.Fatal("timed out: saver did not receive the expected snapshot")
}
select {
case s := <-saves:
if s.File == "/small.txt" && s.Cursor == 6 &&
s.SelStart == 0 && s.SelEnd == 5 && s.Scroll == 10 {
return
}
case <-time.After(remaining):
t.Fatal("timed out: saver did not receive the expected snapshot")
}
}
}
// TestRestore_ShutdownSavesFinalSnapshot verifies Shutdown's unconditional
// final save: the last state is persisted even without a rate-limited tick.
func TestRestore_ShutdownSavesFinalSnapshot(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "a.txt"), []byte("abcdef"), 0644); err != nil {
t.Fatal(err)
}
saves := make(chan editor.SessionState, 16)
l := editor.NewLogic(real.NewRealFileSystem(dir), "/", func(string) {})
l.SetSessionSaver(func(s editor.SessionState) { saves <- s })
go l.Run()
// Open the file (the goroutine-send keeps this off the owner's select).
if _, ok := l.Inspect(func(st *editor.State) any {
editor.OpenFile("/a.txt")
return nil
}); !ok {
t.Fatal("Inspect timed out")
}
for i := 0; i < 100; i++ {
loaded, ok := l.Inspect(func(st *editor.State) any {
cb := st.Editor.ChunkedBuffer
return cb != nil && cb.FileLen() == 6
})
if ok && loaded.(bool) {
break
}
time.Sleep(20 * time.Millisecond)
}
if _, ok := l.Inspect(func(st *editor.State) any {
st.Editor.CursorPosition = 3
return nil
}); !ok {
t.Fatal("Inspect timed out")
}
l.Shutdown()
deadline := time.After(3 * time.Second)
for {
select {
case s := <-saves:
if s.File == "/a.txt" && s.Cursor == 3 {
return
}
case <-deadline:
t.Fatal("timed out: Shutdown did not persist a snapshot with the final state")
}
}
}