diff --git a/internal/editor/session.go b/internal/editor/session.go index 32f3991..dd22344 100644 --- a/internal/editor/session.go +++ b/internal/editor/session.go @@ -30,8 +30,12 @@ import ( ) // 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 +// tiny (a few hundred bytes), so a few writes a second at most is +// negligible. The window bounds how stale the persisted state can be if +// the process is killed (recents-wipe) right after a change: at 1s, a +// kill less than a second after the last edit lost the cursor, selection +// and final scroll. See saveSessionIfChanged for the urgent path. +const sessionSaveInterval = 250 * time.Millisecond // 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 @@ -101,13 +105,21 @@ func (l *Logic) saveSessionIfChanged() { return } now := time.Now() - if now.Sub(l.lastSessionSave) < sessionSaveInterval { - return - } s := l.SnapshotSession() if s == l.lastSession { return } + // Save immediately when the file changes or a selection appears or + // vanishes — low-frequency, high-value changes (the user just opened a + // file or highlighted/cleared text). Everything else (scroll ticks, + // cursor moves, selection-handle drags) is high-frequency and stays + // rate-limited; a kill within the window then loses at most a fraction + // of a second of motion, not the edit state. + urgent := s.File != l.lastSession.File || + (s.SelStart >= 0) != (l.lastSession.SelStart >= 0) + if !urgent && now.Sub(l.lastSessionSave) < sessionSaveInterval { + return + } l.sessionSaver(s) l.lastSession = s l.lastSessionSave = now diff --git a/internal/test/e2e/restore_test.go b/internal/test/e2e/restore_test.go index d43700a..a345999 100644 --- a/internal/test/e2e/restore_test.go +++ b/internal/test/e2e/restore_test.go @@ -471,3 +471,84 @@ func TestRestore_ShutdownSavesFinalSnapshot(t *testing.T) { } } } + +// TestRestore_UrgentSaveOnSelection verifies the kill-race fix: a selection +// appearing (the user just highlighted text) saves immediately, even inside +// the rate-limit window that the file-open save opened. Without the urgent +// path, killing the app (recents-wipe) less than a second after highlighting +// lost the selection and cursor. +func TestRestore_UrgentSaveOnSelection(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() + defer l.Shutdown() + + // Open the file: the file change is itself urgent, so a save lands as + // the load settles. Drain everything up to and including the loaded + // snapshot. + if _, ok := l.Inspect(func(st *editor.State) any { + editor.OpenFile("/a.txt") + return nil + }); !ok { + t.Fatal("Inspect timed out") + } + loaded := false + for i := 0; i < 100 && !loaded; i++ { + v, ok := l.Inspect(func(st *editor.State) any { + cb := st.Editor.ChunkedBuffer + return cb != nil && cb.FileLen() == 6 + }) + if ok { + loaded = v.(bool) + } + if loaded { + break + } + time.Sleep(20 * time.Millisecond) + } + if !loaded { + t.Fatal("file did not load") + } + // Drain saves up to the first one describing the loaded file. + deadline := time.After(3 * time.Second) + for { + select { + case s := <-saves: + if s.File == "/a.txt" { + goto drainDone + } + case <-deadline: + t.Fatal("timed out: no save for the opened file") + } + } +drainDone: + // Immediately (well inside any rate-limit window after the saves above), + // make a selection appear and force a frame: the urgent path must save it + // now, not after the interval elapses. + if _, ok := l.Inspect(func(st *editor.State) any { + st.Editor.SelectionAnchor = 1 + st.Editor.SelectionStart = 1 + st.Editor.SelectionEnd = 4 + st.Editor.CursorPosition = 4 + st.ScrollOffset = 3 + return nil + }); !ok { + t.Fatal("Inspect timed out") + } + l.ConfigChan() <- editor.ConfigEvent{PixelWidth: 780, PixelHeight: 1688} // force emitFrame + + select { + case s := <-saves: + if s.SelStart == 1 && s.SelEnd == 4 && s.Cursor == 4 { + return + } + t.Fatalf("unexpected snapshot: %+v", s) + case <-time.After(2 * time.Second): + t.Fatal("timed out: selection appearance was not saved immediately (urgent path)") + } +}