diff --git a/cmd/pad/main.go b/cmd/pad/main.go index 34f6463..3eab990 100644 --- a/cmd/pad/main.go +++ b/cmd/pad/main.go @@ -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 diff --git a/doc/spec.md b/doc/spec.md index 8943aa0..c379e1d 100644 --- a/doc/spec.md +++ b/doc/spec.md @@ -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 diff --git a/internal/editor/session.go b/internal/editor/session.go index 910ee38..b6c41a9 100644 --- a/internal/editor/session.go +++ b/internal/editor/session.go @@ -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 diff --git a/internal/test/e2e/restore_test.go b/internal/test/e2e/restore_test.go index 3b0b7f6..e0cd2ab 100644 --- a/internal/test/e2e/restore_test.go +++ b/internal/test/e2e/restore_test.go @@ -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") + } +}