Restore the browser page on relaunch, not the last edited file

A relaunch session always re-opened the last edited file, even when the
app was closed sitting on the browser page. The snapshot now records
which page the app was on (SessionState.InBrowser/BrowserPath); the cmd
layer lands back in the browser at the saved directory on relaunch
(falling back to the startup directory if it has vanished) instead of
calling BeginRestore. Browser-page snapshots clear File, which also
makes the editor->browser transition an urgent save, so the
back-in-browser state hits disk immediately. Legacy session files (no
InBrowser key) unmarshal to false and restore the editor exactly as
before.

Verified on emulator and phone: editor sessions still restore the file
(cursor/scroll/font), browser sessions land in the browser at the saved
dir; legacy files keep the old behavior. New e2e regression test
TestRestore_BrowserPageSession.
This commit is contained in:
Greg Pomerantz 2026-09-03 12:11:44 -04:00
parent 4a3ba37075
commit e96464ec79
4 changed files with 119 additions and 17 deletions

View File

@ -112,18 +112,30 @@ func run(w *app.Window) error {
// 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.
// Shutdown. A session taken on the editor page re-opens the last file
// straight into the editor; one taken on the browser page lands back in
// the browser at the saved directory.
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)
if sess.InBrowser {
log.Printf("restoring session: browser path=%q", sess.BrowserPath)
// The saved directory may have vanished (deleted, synced away):
// fall back to the startup directory rather than pointing the
// browser at a dead path.
if fi, err := os.Stat(sess.BrowserPath); err == nil && fi.IsDir() {
logic.BeginBrowserRestore(sess.BrowserPath)
}
} else {
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)
}
}
}
@ -435,7 +447,9 @@ func newSessionSaver(path string) func(editor.SessionState) {
// 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.
// — in which case the app starts in the browser at the startup directory
// as before. Browser-page sessions carry no file; an editor-page session
// (the pre-InBrowser legacy shape included) needs one.
func loadSession(path string) (editor.SessionState, bool) {
b, err := os.ReadFile(path)
if err != nil {
@ -465,7 +479,7 @@ func loadSession(path string) (editor.SessionState, bool) {
if s.ScrollSub < 0 {
s.ScrollSub = 0
}
if s.File == "" {
if !s.InBrowser && s.File == "" {
return editor.SessionState{}, false
}
return s, true

View File

@ -141,10 +141,13 @@ elsewhere.
### 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 position, live selection,
and the find bar (query, open/closed, current match). A relaunch therefore
never requires re-browsing to the last file.
- On launch, Pad lands exactly where the last session ended. If the app was
in the editor, it re-opens the last file straight on the editor page,
restoring the cursor, scroll position, live selection, and the find bar
(query, open/closed, current match) — a relaunch therefore never requires
re-browsing to the last file. If the app was on the browser page, it lands
back in the browser at the saved directory (`InBrowser`/`BrowserPath` in
the snapshot; a deleted directory falls back to the startup directory).
- The scroll position is persisted as the LOGICAL line at the viewport top
plus the sub-line remainder (the raw pixel offset is kept too, for
pre-line-coordinate session files). The pixel offset alone is not

View File

@ -1,7 +1,8 @@
// 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,
// goroutine takes on demand: which page the app was on, and, on the editor
// page, 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
@ -65,6 +66,14 @@ type SessionState struct {
FindCurByte int // start byte of the current find match (-1 = none)
// AppFontScale is the app-local pinch font scale (0 = default 1.0).
AppFontScale float64
// InBrowser records that the app was sitting on the BROWSER page when
// the snapshot was taken: the relaunch must land back in the browser at
// BrowserPath instead of re-opening the last file in the editor. The
// flag (not "File == "") disambiguates from pre-flag session files, whose
// absence of the key unmarshals to false and restores the editor as
// before. When true, File is "" and the editor-side fields are stale.
InBrowser bool
BrowserPath string
}
// SetSessionSaver registers the callback that persists snapshots (the cmd
@ -115,8 +124,20 @@ func (l *Logic) SnapshotSession() SessionState {
// Dp discriminator used on restore).
scrollSub = r0 / float64(lh)
}
inBrowser := l.state.page == BrowserPage
file := e.Filename
if inBrowser {
// Nothing to restore in the editor: leaving the last file in File
// would let a reader that ignores InBrowser re-open it, and the
// editor->browser transition is then also an urgent save (File
// change), so the "back in the browser" state hits disk immediately
// instead of waiting out the rate limit.
file = ""
}
return SessionState{
File: e.Filename,
File: file,
InBrowser: inBrowser,
BrowserPath: l.state.Browser.CurrentPath,
Cursor: cur,
Scroll: float64(s.ScrollOffset),
ScrollLine: scrollLine,
@ -254,6 +275,18 @@ func (l *Logic) BeginRestore(s SessionState) {
l.emitFrame() // show the editor page now; the content loads async
}
// BeginBrowserRestore makes a relaunch land in the browser at dirPath
// instead of the startup directory (see SessionState.InBrowser). It is
// synchronous: the directory is dispatched for its index by Run, which reads
// CurrentPath, so it must be called after NewLogic and before Run (the cmd
// layer does both before starting the logic goroutine). An empty dirPath
// keeps the startup directory.
func (l *Logic) BeginBrowserRestore(dirPath string) {
if dirPath != "" {
l.state.Browser.CurrentPath = dirPath
}
}
// 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

View File

@ -833,3 +833,55 @@ drainDone:
t.Fatal("timed out: selection appearance was not saved immediately (urgent path)")
}
}
// TestRestore_BrowserPageSession covers the relaunch bug where a session
// ended on the browser page still re-opened the last edited file: a
// snapshot taken on the browser page must mark InBrowser (with the browser
// directory and no file), and a relaunch handed that snapshot must land
// back in the browser at the saved directory.
func TestRestore_BrowserPageSession(t *testing.T) {
// Snapshot on the browser page.
h := e2e.NewHarnessWithDefaults()
defer h.Cleanup()
if err := h.WithState(func(st *editor.State) {
st.Browser.CurrentPath = "/Documents/Work"
}); err != nil {
t.Fatalf("WithState: %v", err)
}
v, err := h.Inspect(func(st *editor.State) any {
return h.Logic().SnapshotSession()
})
if err != nil {
t.Fatalf("Inspect: %v", err)
}
sess := v.(editor.SessionState)
if !sess.InBrowser {
t.Fatalf("browser-page snapshot: InBrowser = false, want true: %+v", sess)
}
if sess.File != "" {
t.Errorf("browser-page snapshot: File = %q, want \"\"", sess.File)
}
if sess.BrowserPath != "/Documents/Work" {
t.Errorf("browser-page snapshot: BrowserPath = %q, want \"/Documents/Work\"", sess.BrowserPath)
}
// Relaunch with that session: land in the browser at the saved dir.
h2 := e2e.NewHarness()
defer h2.Cleanup()
h2.Logic().BeginBrowserRestore(sess.BrowserPath)
h2.Run()
h2.SendConfig(780, 1688)
h2.SendScale(2.0)
if _, err := e2e.WaitForNewFrame(h2, 0, 5*time.Second); err != nil {
t.Fatalf("timeout waiting for frames: %v", err)
}
v, err = h2.Inspect(func(st *editor.State) any {
return st.Page() == editor.BrowserPage && st.Browser.CurrentPath == sess.BrowserPath
})
if err != nil {
t.Fatalf("Inspect: %v", err)
}
if !v.(bool) {
t.Fatal("relaunch with a browser session did not land on the browser page at the saved directory")
}
}