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:
parent
f31f849665
commit
e3690d6b98
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) { }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
35
doc/spec.md
35
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. |
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
219
internal/editor/session.go
Normal 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 = ""
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
433
internal/test/e2e/restore_test.go
Normal file
433
internal/test/e2e/restore_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user