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:
parent
4a3ba37075
commit
e96464ec79
|
|
@ -112,18 +112,30 @@ func run(w *app.Window) error {
|
||||||
// file, cursor, scroll, selection and find bar state. The cmd layer owns
|
// file, cursor, scroll, selection and find bar state. The cmd layer owns
|
||||||
// the file (sessionFilePath, per platform); the logic layer owns the
|
// the file (sessionFilePath, per platform); the logic layer owns the
|
||||||
// snapshot (editor.SessionState) and calls the saver rate-limited and at
|
// 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()
|
sessPath := sessionFilePath()
|
||||||
logic.SetSessionSaver(newSessionSaver(sessPath))
|
logic.SetSessionSaver(newSessionSaver(sessPath))
|
||||||
if sess, ok := loadSession(sessPath); ok {
|
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)
|
if sess.InBrowser {
|
||||||
logic.BeginRestore(sess)
|
log.Printf("restoring session: browser path=%q", sess.BrowserPath)
|
||||||
// The find bar's input is a main-owned widget and the input source of
|
// The saved directory may have vanished (deleted, synced away):
|
||||||
// truth: seed it with the restored query so its first frame matches
|
// fall back to the startup directory rather than pointing the
|
||||||
// the logic-side query (an empty widget would forward "" and clear
|
// browser at a dead path.
|
||||||
// the restored query).
|
if fi, err := os.Stat(sess.BrowserPath); err == nil && fi.IsDir() {
|
||||||
if sess.FindQuery != "" {
|
logic.BeginBrowserRestore(sess.BrowserPath)
|
||||||
findEditor.SetText(sess.FindQuery)
|
}
|
||||||
|
} 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).
|
// 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
|
// 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) {
|
func loadSession(path string) (editor.SessionState, bool) {
|
||||||
b, err := os.ReadFile(path)
|
b, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -465,7 +479,7 @@ func loadSession(path string) (editor.SessionState, bool) {
|
||||||
if s.ScrollSub < 0 {
|
if s.ScrollSub < 0 {
|
||||||
s.ScrollSub = 0
|
s.ScrollSub = 0
|
||||||
}
|
}
|
||||||
if s.File == "" {
|
if !s.InBrowser && s.File == "" {
|
||||||
return editor.SessionState{}, false
|
return editor.SessionState{}, false
|
||||||
}
|
}
|
||||||
return s, true
|
return s, true
|
||||||
|
|
|
||||||
11
doc/spec.md
11
doc/spec.md
|
|
@ -141,10 +141,13 @@ elsewhere.
|
||||||
|
|
||||||
### 2.4 State restoration on relaunch
|
### 2.4 State restoration on relaunch
|
||||||
|
|
||||||
- On launch, Pad re-opens the file from the last session and lands straight
|
- On launch, Pad lands exactly where the last session ended. If the app was
|
||||||
on the editor page, restoring the cursor, scroll position, live selection,
|
in the editor, it re-opens the last file straight on the editor page,
|
||||||
and the find bar (query, open/closed, current match). A relaunch therefore
|
restoring the cursor, scroll position, live selection, and the find bar
|
||||||
never requires re-browsing to the last file.
|
(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
|
- 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
|
plus the sub-line remainder (the raw pixel offset is kept too, for
|
||||||
pre-line-coordinate session files). The pixel offset alone is not
|
pre-line-coordinate session files). The pixel offset alone is not
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
// State restoration on relaunch (spec §7).
|
// State restoration on relaunch (spec §7).
|
||||||
//
|
//
|
||||||
// The restorable state is a small snapshot (SessionState) the logic
|
// 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,
|
// the editor scroll offset, the live selection, and the find bar (query,
|
||||||
// open/closed, current match). The search RESULTS themselves are not part
|
// open/closed, current match). The search RESULTS themselves are not part
|
||||||
// of the snapshot: on relaunch they are regenerated by re-scanning the
|
// 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)
|
FindCurByte int // start byte of the current find match (-1 = none)
|
||||||
// AppFontScale is the app-local pinch font scale (0 = default 1.0).
|
// AppFontScale is the app-local pinch font scale (0 = default 1.0).
|
||||||
AppFontScale float64
|
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
|
// SetSessionSaver registers the callback that persists snapshots (the cmd
|
||||||
|
|
@ -115,8 +124,20 @@ func (l *Logic) SnapshotSession() SessionState {
|
||||||
// Dp discriminator used on restore).
|
// Dp discriminator used on restore).
|
||||||
scrollSub = r0 / float64(lh)
|
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{
|
return SessionState{
|
||||||
File: e.Filename,
|
File: file,
|
||||||
|
InBrowser: inBrowser,
|
||||||
|
BrowserPath: l.state.Browser.CurrentPath,
|
||||||
Cursor: cur,
|
Cursor: cur,
|
||||||
Scroll: float64(s.ScrollOffset),
|
Scroll: float64(s.ScrollOffset),
|
||||||
ScrollLine: scrollLine,
|
ScrollLine: scrollLine,
|
||||||
|
|
@ -254,6 +275,18 @@ func (l *Logic) BeginRestore(s SessionState) {
|
||||||
l.emitFrame() // show the editor page now; the content loads async
|
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
|
// abortRestore ends a restore without landing it (the user opened another
|
||||||
// file, the read failed, or the file is too large): clears the pending
|
// file, the read failed, or the file is too large): clears the pending
|
||||||
// snapshot so session saving resumes (see saveSessionIfChanged). The editor
|
// snapshot so session saving resumes (see saveSessionIfChanged). The editor
|
||||||
|
|
|
||||||
|
|
@ -833,3 +833,55 @@ drainDone:
|
||||||
t.Fatal("timed out: selection appearance was not saved immediately (urgent path)")
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user