diff --git a/cmd/pad/impl_android.go b/cmd/pad/impl_android.go index 5f141e6..abe11bb 100644 --- a/cmd/pad/impl_android.go +++ b/cmd/pad/impl_android.go @@ -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 diff --git a/cmd/pad/impl_other.go b/cmd/pad/impl_other.go index a802dd0..9964d16 100644 --- a/cmd/pad/impl_other.go +++ b/cmd/pad/impl_other.go @@ -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) { } diff --git a/cmd/pad/main.go b/cmd/pad/main.go index f43b3f9..7583f32 100644 --- a/cmd/pad/main.go +++ b/cmd/pad/main.go @@ -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 diff --git a/doc/architecture.md b/doc/architecture.md index 3cca63e..b3344a9 100644 --- a/doc/architecture.md +++ b/doc/architecture.md @@ -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 diff --git a/doc/spec.md b/doc/spec.md index 9024302..fb4b493 100644 --- a/doc/spec.md +++ b/doc/spec.md @@ -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. | diff --git a/internal/editor/logic.go b/internal/editor/logic.go index beb554a..96c9533 100644 --- a/internal/editor/logic.go +++ b/internal/editor/logic.go @@ -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() } diff --git a/internal/editor/search.go b/internal/editor/search.go index dc93d6a..75dfba7 100644 --- a/internal/editor/search.go +++ b/internal/editor/search.go @@ -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 diff --git a/internal/editor/session.go b/internal/editor/session.go new file mode 100644 index 0000000..c49ed83 --- /dev/null +++ b/internal/editor/session.go @@ -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 = "" +} diff --git a/internal/editor/state.go b/internal/editor/state.go index ae590f6..843eb9b 100644 --- a/internal/editor/state.go +++ b/internal/editor/state.go @@ -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 } diff --git a/internal/test/e2e/restore_test.go b/internal/test/e2e/restore_test.go new file mode 100644 index 0000000..b59d4d6 --- /dev/null +++ b/internal/test/e2e/restore_test.go @@ -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 /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") + } + } +}