15 KiB
Desktop-save — framing (Arc 3 phase 2)
Reopen pmacs and your session is gone: which files were open, how the
window was split, where each cursor sat. desktop-save serializes the
open file buffers + the window layout + per-window positions on quit and
rebuilds them on startup — Emacs's desktop.el, opt-in.
Builds on phase 1 (PR #98, merged): the pmacs.state confined store,
state_dir(), goto_byte/set_view_top/view_top, and saveplace
(which already restores a file's cursor on open — desktop leans on it).
Parent decisions: docs/persistence-framing.md Q#PS5-7. This doc nails
the phase-2 implementation against ground truth.
Ground truth (scouted; file:line in the commit)
- Layout tree:
LayoutNode = Leaf(WindowId) | Split { orientation: Orientation, weights: Vec<u32>, children: Vec<LayoutNode> }(src/window.rs:264). Not serde. Owned per-frontend atcore.views[fid].layout.root(FrontendView,src/window.rs:301);core.active_layout()/_mut()reach the active one (src/editor_core.rs:355).core.windowsis apub BTreeMap< WindowId, Window>(src/editor_core.rs:135). Window(src/window.rs:158) stores its ownbuffer_id,cursor(byte),view_top(line). So a window→buffer→path chain is fully readable in Rust.WindowIdis a process-lifetimeAtomicU64counter (src/window.rs:55) — not restart-stable; rebuild structurally, never persist raw ids.- No tree read/rebuild API (Lua or Rust):
iter_ids()is a flat preorder id list (src/window.rs:334);split_windowhardcodes 1:1 weights (src/window.rs:456). Arbitrary shape/weights must be built by constructingLayoutNode+Windows directly against thepubfields (the window unit tests already mutatelayout.rootthis way). - No per-
BufferIdpath getter in Lua — but irrelevant here: save/restore live in Rust and read paths straight off the registry (registry.ids()→registry.get(id).file_path(),src/buffer.rs:263;is_modified():452;find_by_path:168). serde_json+serdederive +sha2(SHA-256) are all existing deps (Cargo.toml). SHA-256 is the established key hasher (sha256_hex,src/packages/fetcher.rs:517).instance.identity()returnsinstance_name: Option<String>andworking_directory: String(InstanceIdentity, serde-derived,pmacs-protocol/src/message.rs:1394).- Startup:
editor::run(file: Option<PathBuf>)(src/editor.rs:1520) — thematch fileat:1522consumesfile; capturehad_filebefore it.install_state_dirs()is at:1527(the natural post-construction trigger point).run_daemontakes no file arg (src/daemon.rs:448), constructs at:468, wires state at:470. - No
editor.after-inithook — restore must be Rust-triggered.editor.before-quitis a short-circuit hook fired by the quit command (builtin/commands/default.lua:239) — the save seam. - Rust can fire a Lua hook (precedent: daemon remote-op fires
buffer.after-edit) — so restore can firebuffer.after-loadper opened file to attach saveplace/LSP/syntax.
Decisions
Q#DS1 — Rust-owned pmacs.session.* + a thin Lua desktop.lua
Everything load-bearing (registry walk, layout serde, structural rebuild, per-window state) is Rust, because the tree types aren't serde and there is no Lua tree API. Surface:
pmacs.session.save_desktop()— Rust; serialize the active frontend's layout + file buffers + positions to the state store.pmacs.session.restore_desktop()— Rust; rebuild from the store.pmacs.session.arm_restore()— Rust; set the "restore on startup" flag (read by the Rust startup trigger).builtin/runtime/desktop.lua—pmacs.session.desktop_mode(on): whenon, registers aneditor.before-quithook that callssave_desktop()and callsarm_restore(). Plusdesktop-save/desktop-restorecommands for manual use. Opt-in: nothing runs unless init.lua callsdesktop_mode(true).
No per-buffer Lua path getter is added — the framing's phase-2 "per
BufferId file_path()" primitive turns out unnecessary because
enumeration is Rust-side.
Q#DS2 — The serialized format
A serde-derived mirror (its own types, leaving the core enums
untouched), serde_json to state_dir()/desktop/<key>:
SavedDesktop { version: u32, session_key: String,
buffers: Vec<SavedBuffer>, // ALL open file buffers
root: SavedNode, // the window layout
active_leaf: usize }
SavedBuffer { path: String, modified: bool }
SavedNode = Leaf(SavedLeaf) | Split { orientation, weights: Vec<u32>,
children: Vec<SavedNode> }
SavedLeaf { path: String, cursor: u64, view_top: usize }
buffers is every file buffer in the registry (registry.ids() →
file_path().is_some()), not just those visible in a window — so a
file opened then switched away from (live but hidden) survives restore.
The scope really is "open file buffers + layout" (finding: the earlier
draft saved only layout leaves and silently dropped hidden buffers).
Restore opens the whole buffers set, then rebuilds the layout on top.
root preserves exact orientation + weights + nesting. active_leaf
is the preorder index into the surviving leaf sequence (Q#PS5 — a
path can't identify which leaf had focus when the same file shows in
several).
Only file buffers. A leaf whose window shows a scratch/*special*
buffer is dropped and its parent split collapses (remaining siblings'
weights kept, renormalized by the layout math). If that dropping removes
the leaf active_leaf pointed at, active_leaf falls back to the
nearest surviving preorder neighbor (Q#DS10). If no file leaf
survives, no desktop is written.
modified rides on SavedBuffer so the restore-time warning (Q#DS6)
has a source; contents are never saved.
Q#DS3 — Restore: structural rebuild in Rust
The ordering constraint that drives this: buffer.after-load hooks
read active state — saveplace/recentf via pmacs.editor.file_path(),
syntax via pmacs.window.buffer(), LSP's attach_buffer derives
language/path/text from the active buffer. So a restored buffer must be
active when its after-load fires, or the hooks attach to the wrong
buffer (finding). get_or_load_buffer (Q#DS4) deliberately does not
switch focus, so restore sequences activation explicitly.
restore_desktop():
- Read + parse
desktop/<key>; if absent orsession_keymismatches, no-op. - Open every
SavedBufferviaget_or_load_buffer(path)(Q#DS4), recording which ids are newly loaded. A path that no longer exists on disk is skipped with a warning (its leaves collapse per Q#DS10). - Prune the entire old LOCAL layout: remove all windows belonging
to
core.views[LOCAL]fromcore.windows(not just the startup scratch window — leftover windows would linger in thepubmap and still take part in edit notifications and buffer-liveness checks, finding). - Build a fresh
LayoutNodefromSavedNodewith newWindowIds and aWindowper surviving leaf (weights copied verbatim), install it ascore.views[LOCAL].layout.root. - Fire
after-loadwith the right leaf active, once per leaf: for each surviving leaf in preorder, set its window active, firebuffer.after-load, then set that window's exactcursor/view_top. Firing per leaf (not per buffer) is deliberate — syntax attaches its overlay to the active window, so each pane needs its own fire; LSP'sattach_bufferis idempotent, so the same file in two panes attaches LSP once but syntax to both (finding, round 3). The per-leafcursor/view_topwrite lands after the hook, so desktop wins over saveplace — and same-file-two-leaves keeps distinct positions a single saveplace entry could not. - Set
activeto theactive_leafwindow (Q#DS10 fallback if that leaf didn't survive).
Hidden buffers are registry-only in v1 (finding, round 3): a
restored SavedBuffer with no leaf (open but not shown) is loaded into
the registry — it is not lost, it is in the buffer list / recentf — but
it does not fire after-load, so it attaches syntax on first visit
(after-switch) and LSP when next shown. Full initial attach for hidden
buffers is deferred.
Structural construction against the pub fields — no new tree-builder
API, matching how the window unit tests already assemble layouts.
Q#DS4 — get_or_load_buffer(path) core helper
The one genuinely new Rust seam. Reuses EditorState::open's internals:
registry.find_by_path(path) → return the existing id; else
file_io::load_file → create buffer → set_buffer_path/set_buffer_meta
→ return the new id. It does not switch the active window (restore
places buffers into windows it builds explicitly). Returns io::Result
so a since-deleted file is skipped (its leaf collapses) with a warning,
not a hard failure.
Q#DS5 — Session key
instance.identity() → key, then SHA-256 hex (the established key
hasher), tag-prefixed for legibility and to satisfy the Q#PS2 state-key
charset (: is disallowed, so a dot separator):
name.<sha256hex(instance_name)> when a socket name is set, else
cwd.<sha256hex(working_directory)>. Stored as state key
desktop/name.<hex> (both components pass validate_name). Hashing
both uniformly sidesteps odd characters in either value.
Q#DS6 — No contents; modified = warning-only
Saves the file list + layout + positions, never buffer contents
(Emacs desktop.el). Each SavedBuffer.modified records whether that
buffer was dirty at save time; restore opens the on-disk file (clean)
and, if any modified flags are set, surfaces a one-line count ("N
buffers had unsaved changes when the desktop was saved") via
core.status. Unsaved work is autosave's job (phase 3).
Q#DS7 — Startup gate (the Q#PS7 trap, made concrete)
Restore is armed, never inline in init — desktop_mode(true) runs
inside new() (before the file opens), so it only sets the flag +
before-quit hook. Arming is a boolean (arm_restore(on)), so
desktop_mode(false) unarms — an enable-then-disable in init does not
still restore (finding, round 3). The Rust startup trigger fires restore:
editor::run: capturelet had_file = file.is_some();before thematch fileatsrc/editor.rs:1522consumesfile. But fire restore inside theRunLocalarm of the attach dispatch (aftertake_requested_attach+dispatch_attach), not right afterinstall_state_dirs()— at the earlier pointrun()hasn't yet resolved an init-timepmacs.attach{}request, so a restore could populate anEditorStatethat is about to be dropped for attach hand-off (finding). In theRunLocalarm, callstate.restore_desktop_if_armed(had_file)— restores only when armed and!had_file.- Manual
desktop-restorecommand ignores the gate (explicit user intent).
Q#DS8 — before-quit save semantics
The editor.before-quit hook is short-circuit; the desktop save handler
performs its write and returns nil (never vetoes quit). It serializes
the layout as it stands at quit. A save failure is logged, not fatal —
quitting must not be blockable by a state-write error.
Q#DS9 — Scope v1 to local (in-process) mode — save and restore
The daemon holds a layout per attached frontend (views keyed by
FrontendId), and the Q#DS5 key has no frontend component; at
run_daemon construction no frontend is attached, so there is nothing
to restore into until first attach. v1 targets only the local
editor::run path (single LOCAL frontend view built at startup).
desktop_mode(true) auto-save and auto-restore are both no-ops in
daemon mode — not half-enabled. The enforcement is in Rust, not just
Lua (finding, round 3): save_session/restore_session early-return
when the DaemonMode app-data marker is present. That marker is set
right after the daemon's EditorState::new(), so it holds for every
save/restore that can run after startup — the before-quit hook, manual
commands, and direct binding calls — even though init.lua (where
desktop_mode runs) executes before it is set, when is_daemon() in
Lua would still read false. Daemon + GPU-attach save/restore is
deferred to the first-attach design.
Q#DS10 — Active-focus fallback
Two prunings can orphan the focus target: a scratch/*special* leaf
dropped at save time, or a missing file's leaf collapsed at
restore time. In both cases, resolve active_leaf to the nearest
surviving preorder neighbor (the next later leaf, else the previous),
and assert the result indexes a real surviving leaf. A desktop with zero
surviving file leaves is never written (save) / is a no-op (restore), so
active_leaf always resolves to something.
Phasing
One PR — save and restore are only useful paired. In-diff order: mirror
types + save_desktop + get_or_load_buffer first, then
restore_desktop + the startup trigger + desktop.lua.
Bets (score at close)
- Structural rebuild is faithful (the parent bet #2) — a nested/asymmetric weighted tree round-trips exactly. Highest risk.
- Activate-then-fire attaches everything — firing
buffer.after-loadwith the restored leaf active makes saveplace, LSP, and syntax behave on a restored buffer exactly as on a hand-opened one (the whole point of Q#DS3's ordering). - Preorder is a stable leaf identity —
active_leafindex + restore's own preorder walk agree, so focus lands on the right leaf. - No content-save is unsurprising — restoring a modified buffer clean (with a warning) matches expectations, doesn't read as data loss.
Deferred (named)
- Daemon / GPU-attach restore (Q#DS9) — first-attach trigger.
- Multiple named desktops per session key (one per key in v1).
- Window-local overlays / minor state (buffers + positions only).
- Remote/cross-machine desktops (paths are local).
- Saving unsaved buffer content (autosave, phase 3).
- Non-file (scratch/
*special*) buffers in the desktop.
Acceptance (Rust, tempdir state root injected)
No Lua tree API exists, so tests drive setup/inspection through Rust +
the pmacs.session.* bindings:
- Build a nested, asymmetric weighted split (two+ files);
save_desktop; construct a fresh editor;restore_desktop; assert tree shape + weights + each window's buffer path + cursor + view_top + the active leaf. - Hidden buffer survives: open file A, open file B in the same window (A now hidden), save, restore → both A and B are live buffers.
- after-load sees the right active buffer: a probe hook recording
(file_path, buffer)atbuffer.after-loadfires once per restored buffer with that buffer active. - Same file in two leaves → two distinct restored positions.
- Session-key scoping: a
name.*desktop and acwd.*desktop don't collide. - Startup gate: armed + no file arg restores; armed + file arg does not.
- A modified buffer at save → restore opens clean + the warning count reflects it.
- A since-deleted file's leaf collapses, focus falls back to a surviving leaf (Q#DS10), and the restore doesn't abort.
- No orphan windows: after restore,
core.windowsfor LOCAL holds exactly the rebuilt leaves — the pre-restore windows are gone.