From 72563e93cffc7065a5c793591bd72fca40bc1d19 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Wed, 8 Jul 2026 18:50:57 -0400 Subject: [PATCH 1/3] docs: desktop-save framing (Arc 3 phase 2) Q#DS1-10: all-Rust pmacs.session.* + thin desktop.lua (opt-in). Serde mirror (SavedDesktop with all-buffers list + layout tree + active_leaf preorder index); SHA-256 session key; get_or_load_buffer helper; activate-leaf-then-fire-after-load restore ordering; prune all old LOCAL windows; RunLocal-arm startup trigger; local-only save+restore (daemon deferred); active-focus fallback. Incorporates the 2026-07-08 review (both rounds). Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/desktop-save-framing.md | 289 +++++++++++++++++++++++++++++++++++ 1 file changed, 289 insertions(+) create mode 100644 docs/desktop-save-framing.md diff --git a/docs/desktop-save-framing.md b/docs/desktop-save-framing.md new file mode 100644 index 0000000..3e52601 --- /dev/null +++ b/docs/desktop-save-framing.md @@ -0,0 +1,289 @@ +# 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, children: Vec }` + (`src/window.rs:264`). **Not serde.** Owned per-frontend at + `core.views[fid].layout.root` (`FrontendView`, `src/window.rs:301`); + `core.active_layout()/_mut()` reach the active one + (`src/editor_core.rs:355`). `core.windows` is a **`pub BTreeMap< + WindowId, Window>`** (`src/editor_core.rs:135`). +- **`Window`** (`src/window.rs:158`) stores its own `buffer_id`, + `cursor` (byte), `view_top` (line). So a window→buffer→path chain is + fully readable in Rust. +- **`WindowId`** is a process-lifetime `AtomicU64` counter + (`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_window` hardcodes 1:1 + weights (`src/window.rs:456`). Arbitrary shape/weights must be built + by constructing `LayoutNode` + `Window`s directly against the `pub` + fields (the window unit tests already mutate `layout.root` this way). +- **No per-`BufferId` path 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`** + **`serde` derive** + **`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()`** returns `instance_name: Option` and + `working_directory: String` (`InstanceIdentity`, serde-derived, + `pmacs-protocol/src/message.rs:1394`). +- **Startup**: `editor::run(file: Option)` (`src/editor.rs:1520`) + — the `match file` at `:1522` **consumes** `file`; capture + `had_file` *before* it. `install_state_dirs()` is at `:1527` (the + natural post-construction trigger point). `run_daemon` takes **no + file arg** (`src/daemon.rs:448`), constructs at `:468`, wires state at + `:470`. +- **No `editor.after-init` hook** — restore must be **Rust-triggered**. + `editor.before-quit` is 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 fire `buffer.after-load` per + 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)`: + when `on`, registers an `editor.before-quit` hook that calls + `save_desktop()` **and** calls `arm_restore()`. Plus `desktop-save` / + `desktop-restore` commands for manual use. **Opt-in**: nothing runs + unless init.lua calls `desktop_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/`: + +``` +SavedDesktop { version: u32, session_key: String, + buffers: Vec, // ALL open file buffers + root: SavedNode, // the window layout + active_leaf: usize } +SavedBuffer { path: String, modified: bool } +SavedNode = Leaf(SavedLeaf) | Split { orientation, weights: Vec, + children: Vec } +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()`: +1. Read + parse `desktop/`; if absent or `session_key` mismatches, + no-op. +2. **Open every `SavedBuffer`** via `get_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). +3. **Prune the entire old LOCAL layout**: remove *all* windows belonging + to `core.views[LOCAL]` from `core.windows` (not just the startup + scratch window — leftover windows would linger in the `pub` map and + still take part in edit notifications and buffer-liveness checks, + finding). +4. Build a fresh `LayoutNode` from `SavedNode` with new `WindowId`s and + a `Window` per surviving leaf (weights copied verbatim), install it + as `core.views[LOCAL].layout.root`. +5. **Fire `after-load` with the right leaf active**: for each surviving + leaf in preorder, set its window active; the first time a given + buffer is seen, fire `buffer.after-load` (once per newly-loaded + buffer, so saveplace/LSP/syntax attach against the correct active + buffer); then set that window's exact `cursor`/`view_top` from the + leaf. Desktop's per-leaf write lands *after* the hook, so it wins + over saveplace for precision — and same-file-two-leaves keeps + distinct positions a single saveplace entry could not. +6. Set `active` to the `active_leaf` window (Q#DS10 fallback if that + leaf didn't survive). + +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.` when a socket name is set, else +`cwd.`. Stored as state key +`desktop/name.` (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. The Rust startup trigger fires restore: + +- `editor::run`: capture `let had_file = file.is_some();` **before** the + `match file` at `src/editor.rs:1522` consumes `file`. But fire restore + **inside the `RunLocal` arm** of the attach dispatch (after + `take_requested_attach` + `dispatch_attach`), *not* right after + `install_state_dirs()` — at the earlier point `run()` hasn't yet + resolved an init-time `pmacs.attach{}` request, so a restore could + populate an `EditorState` that is about to be dropped for attach + hand-off (finding). In the `RunLocal` arm, call + `state.restore_desktop_if_armed(had_file)` — restores only when armed + **and** `!had_file`. +- Manual `desktop-restore` command 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. Serializing "wherever a layout exists" +is ambiguous with multiple frontend layouts sharing one key, so the +before-quit save simply doesn't register (or early-returns) when the +process is a daemon; a diagnostic notes desktop-save is local-only in +v1. Daemon + GPU-attach save/restore is **deferred** to the first-attach +design. (Manual `desktop-save`/`desktop-restore` commands likewise +refuse in daemon mode in v1.) + +### 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) + +1. **Structural rebuild is faithful** (the parent bet #2) — a + nested/asymmetric weighted tree round-trips exactly. *Highest risk.* +2. **Activate-then-fire attaches everything** — firing + `buffer.after-load` with 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). +3. **Preorder is a stable leaf identity** — `active_leaf` index + + restore's own preorder walk agree, so focus lands on the right leaf. +4. **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)` at `buffer.after-load` fires once per restored + buffer with that buffer active. +- Same file in two leaves → two distinct restored positions. +- Session-key scoping: a `name.*` desktop and a `cwd.*` 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.windows` for LOCAL holds + exactly the rebuilt leaves — the pre-restore windows are gone. From 3607df0afe761e229a08e7380e20f72fdb4fced1 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Wed, 8 Jul 2026 20:28:43 -0400 Subject: [PATCH 2/3] feat(persistence): desktop-save --- buffers + layout + positions (Arc 3 phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Framing: docs/desktop-save-framing.md (Q#DS1-10). Save the open file buffers, window layout, and per-window positions on quit; rebuild them on startup. Emacs desktop.el, opt-in, local-mode only in v1. All-Rust (Q#DS1) --- the core window enums are not serde and there is no Lua tree API, so the layout mirror + structural rebuild live in Rust. Lua adds only the opt-in switch and manual commands. src/desktop.rs (new): - Serde mirror (SavedDesktop / SavedBuffer / SavedNode / SavedLeaf / SavedOrientation): every open file buffer (visible OR hidden, so a switched-away file survives), the layout tree with orientation + weights + nesting, per-leaf cursor/view_top, and an active-leaf preorder index with a nearest-neighbor fallback (Q#DS10). - session_key: SHA-256, name. when a socket name is set else cwd. (charset-safe for the pmacs.state key). - save_session / restore_session take the &Lua that carries the SharedCore / StateDir / LocalInstanceInfo app-data, so they run identically from a pmacs.session.* binding and the startup trigger. - restore ordering (Q#DS3): open all buffers; prune EVERY window of the old LOCAL layout (not just scratch); rebuild the tree; then per leaf in preorder activate its window and fire buffer.after-load once per newly-loaded buffer (hooks read active state), and write the exact cursor/view_top AFTER so desktop wins over saveplace (same file in two panes keeps distinct positions). A missing file collapses its leaf. src/editor_core.rs: get_or_load_buffer(path) --- find_by_path else load fresh, WITHOUT switching the active window; returns (id, newly). src/lua_bindings: pmacs.session.{save_desktop, restore_desktop, arm_restore, is_daemon}; DesktopRestoreArmed + DaemonMode markers; fire_after_load_hook seam. builtin/runtime/desktop.lua: pmacs.session.desktop_mode(on) wires before-quit save + arms restore; desktop-save / desktop-restore commands. No-op under a daemon (Q#DS9). Startup trigger (Q#DS7): editor::run captures had_file before the match consumes `file`, and restore_desktop_if_armed runs INSIDE the RunLocal arm (after attach dispatch) so a hand-off to attach never populates an EditorState it is about to drop. Daemon marks DaemonMode → desktop stays local-only. Tests: src/desktop.rs units (tree collapse, active-leaf fallback, key/json round-trip) + tests/desktop_acceptance.rs (9): nested weighted round-trip, hidden-buffer survival, after-load-active probe, same-file two-pane distinct positions, missing-file collapse + focus fallback, no orphan windows, name-vs-cwd key scoping, modified warning, startup gate. Gates: fmt + workspace clippy clean; lib 1487; crdt 1658; desktop 9; persistence 5; m4 90; m8 daemon 10/15; query-replace/completion/ listview/overlay/cua green; GPU 58; git diff --check clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- builtin/runtime/desktop.lua | 72 +++++ src/daemon.rs | 7 + src/desktop.rs | 626 ++++++++++++++++++++++++++++++++++++ src/editor.rs | 33 ++ src/editor_core.rs | 26 ++ src/lib.rs | 1 + src/lua_bindings/mod.rs | 61 ++++ tests/desktop_acceptance.rs | 370 +++++++++++++++++++++ 8 files changed, 1196 insertions(+) create mode 100644 builtin/runtime/desktop.lua create mode 100644 src/desktop.rs create mode 100644 tests/desktop_acceptance.rs diff --git a/builtin/runtime/desktop.lua b/builtin/runtime/desktop.lua new file mode 100644 index 0000000..78c3003 --- /dev/null +++ b/builtin/runtime/desktop.lua @@ -0,0 +1,72 @@ +-- desktop.lua --- session desktop-save wiring (Arc 3 phase 2). +-- +-- The Rust `pmacs.session.{save_desktop,restore_desktop,arm_restore, +-- is_daemon}` primitives do the load-bearing work (layout serde + +-- structural rebuild). This thin layer adds the opt-in `desktop_mode` +-- switch and the manual commands. +-- +-- Opt-in: nothing happens unless init.lua calls +-- `pmacs.session.desktop_mode(true)`. Local-only in v1 (Q#DS9) — a +-- no-op under a daemon, where each attached frontend has its own layout. +-- +-- Framing: docs/desktop-save-framing.md. + +local enabled = false + +-- Enable (or disable) desktop-save. When enabled in local mode: +-- * arm restore-on-startup (the RunLocal trigger fires it), and +-- * save the session on quit (editor.before-quit). +function pmacs.session.desktop_mode(on) + on = (on ~= false) + if on and pmacs.session.is_daemon() then + -- Local-only in v1; keep quiet rather than half-enable. + return false + end + enabled = on + if on then + pmacs.session.arm_restore() + end + return enabled +end + +-- Save on quit. before-quit is short-circuit; returning nil never +-- vetoes, and a save failure must not block quitting (Q#DS8). +pmacs.hook.add("editor.before-quit", function() + if enabled then + pcall(pmacs.session.save_desktop) + end +end) + +pmacs.command.define { + name = "desktop-save", + description = "Save the current session (buffers + layout) to disk.", + fn = function() + if pmacs.session.is_daemon() then + pmacs.editor.set_status("desktop-save: local-only in v1") + return + end + local ok, wrote_or_err = pcall(pmacs.session.save_desktop) + if not ok then + pmacs.editor.set_status("desktop-save: " .. tostring(wrote_or_err)) + elseif wrote_or_err then + pmacs.editor.set_status("desktop saved") + else + pmacs.editor.set_status("desktop-save: nothing to save") + end + end, +} + +pmacs.command.define { + name = "desktop-restore", + description = "Restore the saved session (buffers + layout) from disk.", + fn = function() + if pmacs.session.is_daemon() then + pmacs.editor.set_status("desktop-restore: local-only in v1") + return + end + local ok, err = pcall(pmacs.session.restore_desktop) + if not ok then + pmacs.editor.set_status("desktop-restore: " .. tostring(err)) + end + end, +} diff --git a/src/daemon.rs b/src/daemon.rs index c2148cb..4c2f06f 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -468,6 +468,13 @@ pub fn run_daemon(socket_path: PathBuf, instance_name: Option) -> Result let mut editor = EditorState::new(); // Real session: wire up on-disk persistence (history + pmacs.state). editor.install_state_dirs(); + // Mark this process a daemon so `pmacs.session.desktop_mode` keeps + // desktop save/restore local-only in v1 (Q#DS9): the daemon has a + // layout per attached frontend and none at construction. + editor + .lua_host + .lua() + .set_app_data(crate::lua_bindings::DaemonMode); // Mirror the daemon's `--socket NAME` and start time into the // editor's `LocalInstanceInfo` so `pmacs.instance.identity()` // (T M5.6f) reports the same identity the daemon hands back over diff --git a/src/desktop.rs b/src/desktop.rs new file mode 100644 index 0000000..4277854 --- /dev/null +++ b/src/desktop.rs @@ -0,0 +1,626 @@ +// desktop.rs --- session desktop-save (Arc 3 phase 2). + +//! Serialize the open file buffers + window layout + per-window +//! positions so a session survives a restart. Emacs `desktop.el`, +//! opt-in via `pmacs.session.desktop_mode(true)`. +//! +//! This module owns the whole feature: the serde mirror types (the core +//! window enums are not serde and stay that way), the SHA-256 session +//! key, the save-side snapshot, and the restore orchestration that opens +//! files, prunes/rebuilds windows, and fires `buffer.after-load`. +//! [`save_session`] / [`restore_session`] take the `&Lua` that carries +//! the editor's `SharedCore` / `StateDir` / `LocalInstanceInfo` +//! app-data, so they run identically from a `pmacs.session.*` binding +//! and from the `RunLocal` startup trigger. +//! +//! Framing: docs/desktop-save-framing.md. + +use std::collections::{HashMap, HashSet}; +use std::path::Path; + +use mlua::Lua; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use crate::buffer::BufferId; +use crate::editor_core::EditorCore; +use crate::lua_bindings::{LocalInstanceInfo, SharedCore, StateDir, fire_after_load_hook}; +use crate::protocol::FrontendId; +use crate::text_view::TextView; +use crate::window::{FrontendView, Layout, LayoutNode, Orientation, Window, WindowId}; + +/// Bump when the on-disk shape changes incompatibly. Restore ignores a +/// desktop whose `version` it does not recognize. +pub const DESKTOP_VERSION: u32 = 1; + +/// A serializable snapshot of a session: every open file buffer, the +/// window layout, and which leaf was focused. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SavedDesktop { + /// Format version ([`DESKTOP_VERSION`]). + pub version: u32, + /// The [`session_key`] this was saved under; restore refuses a + /// mismatch (defense in depth — the filename already encodes it). + pub session_key: String, + /// **Every** open file buffer (visible or hidden), so a file opened + /// then switched away from survives restore — not just layout + /// leaves. + pub buffers: Vec, + /// The window layout tree. + pub root: SavedNode, + /// Preorder index (into the surviving leaf sequence) of the focused + /// leaf. Resolved with a nearest-neighbor fallback if the focused + /// leaf did not survive (Q#DS10). + pub active_leaf: usize, +} + +/// One open file buffer. Contents are never saved (Q#DS6); `modified` +/// only drives the restore-time "unsaved changes" warning. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SavedBuffer { + /// Absolute-or-relative path exactly as the buffer holds it. + pub path: String, + /// Whether the buffer had unsaved edits at save time. + pub modified: bool, +} + +/// Mirror of [`LayoutNode`] — a `Leaf` carries the window's file + +/// position; a `Split` carries orientation, weights, and children. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum SavedNode { + /// A window showing a file. + Leaf(SavedLeaf), + /// A proportional split. + Split { + /// Split axis. + orientation: SavedOrientation, + /// Per-child weights (same length as `children`). + weights: Vec, + /// Children in display order. + children: Vec, + }, +} + +/// A restored window: which file, and where the cursor / viewport sit. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SavedLeaf { + /// File path (always a file buffer — scratch/special are dropped). + pub path: String, + /// Cursor byte offset. + pub cursor: u64, + /// First visible source line. + pub view_top: usize, +} + +/// Serde mirror of [`Orientation`] (which is not itself serde). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum SavedOrientation { + /// Children stacked top-to-bottom. + Horizontal, + /// Children side-by-side. + Vertical, +} + +impl From for SavedOrientation { + fn from(o: Orientation) -> Self { + match o { + Orientation::Horizontal => SavedOrientation::Horizontal, + Orientation::Vertical => SavedOrientation::Vertical, + } + } +} + +impl From for Orientation { + fn from(o: SavedOrientation) -> Self { + match o { + SavedOrientation::Horizontal => Orientation::Horizontal, + SavedOrientation::Vertical => Orientation::Vertical, + } + } +} + +/// The state-store key a session's desktop is saved under (Q#DS5). +/// +/// `name.` keyed on the instance/socket name when set, else +/// `cwd.` keyed on the working directory (Emacs's +/// per-directory model). Hashing both uniformly sidesteps odd +/// characters and satisfies the `pmacs.state` key charset (a raw `:` or +/// `/` would be rejected); the `desktop/` prefix is added by the store +/// key, not here. +#[must_use] +pub fn session_key(instance_name: Option<&str>, working_directory: &str) -> String { + match instance_name { + Some(name) => format!("name.{}", sha256_hex(name)), + None => format!("cwd.{}", sha256_hex(working_directory)), + } +} + +/// The `pmacs.state` key (under a `desktop/` subdir) for a session key. +#[must_use] +pub fn desktop_state_key(session_key: &str) -> String { + format!("desktop/{session_key}") +} + +fn sha256_hex(s: &str) -> String { + let mut h = Sha256::new(); + h.update(s.as_bytes()); + let digest = h.finalize(); + let mut out = String::with_capacity(digest.len() * 2); + for b in digest { + use std::fmt::Write as _; + let _ = write!(out, "{b:02x}"); + } + out +} + +/// Build the serializable layout tree from a core [`LayoutNode`], +/// resolving each leaf window to a [`SavedLeaf`] (returning `None` for a +/// non-file leaf, which is dropped and its split collapsed). +/// +/// Returns the mirror node plus the **surviving leaf window-ids in +/// preorder** — the caller uses that list to compute `active_leaf` +/// (with the Q#DS10 fallback). Returns `None` when nothing survives. +pub fn build_saved_node( + node: &LayoutNode, + resolve: &impl Fn(WindowId) -> Option, + surviving: &mut Vec, +) -> Option { + match node { + LayoutNode::Leaf(id) => resolve(*id).map(|leaf| { + surviving.push(*id); + SavedNode::Leaf(leaf) + }), + LayoutNode::Split { + orientation, + weights, + children, + } => { + let mut kept: Vec<(SavedNode, u32)> = Vec::new(); + for (i, child) in children.iter().enumerate() { + if let Some(saved) = build_saved_node(child, resolve, surviving) { + let w = weights.get(i).copied().unwrap_or(1).max(1); + kept.push((saved, w)); + } + } + match kept.len() { + 0 => None, + // A split with a single surviving child collapses to + // that child (the sibling that carried the other pane is + // gone), so the tree never has a one-child split. + 1 => Some(kept.into_iter().next().unwrap().0), + _ => { + let (nodes, weights): (Vec<_>, Vec<_>) = kept.into_iter().unzip(); + Some(SavedNode::Split { + orientation: (*orientation).into(), + weights, + children: nodes, + }) + } + } + } + } +} + +/// Given the full preorder leaf list (before dropping) with a survived +/// flag, and the focused window, return the `active_leaf` index into +/// the *surviving* sequence — the focused leaf if it survived, else the +/// nearest surviving preorder neighbor (later preferred), else 0 +/// (Q#DS10). `surviving_ids` is the preorder list of leaves that +/// survived, in the same order they appear in `full`. +#[must_use] +pub fn resolve_active_leaf( + full: &[(WindowId, bool)], + surviving_ids: &[WindowId], + focused: WindowId, +) -> usize { + // Direct hit: focused survived. + if let Some(i) = surviving_ids.iter().position(|&id| id == focused) { + return i; + } + // Focused was dropped: find its position in the full preorder list, + // then the nearest survivor (scan right, then left). + let Some(fpos) = full.iter().position(|&(id, _)| id == focused) else { + return 0; + }; + let neighbor = full + .iter() + .enumerate() + .filter(|(_, (_, survived))| *survived) + .min_by_key(|(pos, _)| { + // Prefer later leaves on ties: right distance rounds down. + let dist = pos.abs_diff(fpos); + (dist, i32::from(*pos < fpos)) + }) + .map(|(_, (id, _))| *id); + neighbor + .and_then(|id| surviving_ids.iter().position(|&s| s == id)) + .unwrap_or(0) +} + +// --------------------------------------------------------------------------- +// Save / restore orchestration (driven from `pmacs.session.*` and the +// RunLocal startup trigger; both hand us the `&Lua` that carries the +// SharedCore / StateDir / LocalInstanceInfo app-data). +// --------------------------------------------------------------------------- + +/// The desktop session key for this process (`cwd.` in local mode). +fn session_key_from_lua(lua: &Lua) -> String { + match lua + .app_data_ref::() + .map(|i| i.build_identity()) + { + Some(id) => session_key(id.instance_name.as_deref(), &id.working_directory), + None => session_key(None, ""), + } +} + +/// Snapshot the LOCAL frontend as a [`SavedDesktop`] (Q#DS2). `None` +/// when no file window survives (nothing worth saving). +#[must_use] +pub fn snapshot(core: &EditorCore, session_key: String) -> Option { + let view = core.views.get(&FrontendId::LOCAL)?; + let focused = view.active; + let reg = core.registry.borrow(); + + let resolve = |wid: WindowId| -> Option { + let win = core.windows.get(&wid)?; + let path = reg.get(win.buffer_id).ok()?.file_path()?; + Some(SavedLeaf { + path: path.display().to_string(), + cursor: win.cursor, + view_top: win.view_top, + }) + }; + + let mut surviving = Vec::new(); + let root = build_saved_node(&view.layout.root, &resolve, &mut surviving)?; + + let full: Vec<(WindowId, bool)> = view + .layout + .iter_ids() + .into_iter() + .map(|id| (id, surviving.contains(&id))) + .collect(); + let active_leaf = resolve_active_leaf(&full, &surviving, focused); + + let buffers = reg + .ids() + .iter() + .filter_map(|&id| { + let b = reg.get(id).ok()?; + let path = b.file_path()?; + Some(SavedBuffer { + path: path.display().to_string(), + modified: b.is_modified(), + }) + }) + .collect(); + + Some(SavedDesktop { + version: DESKTOP_VERSION, + session_key, + buffers, + root, + active_leaf, + }) +} + +/// Serialize the current session to the desktop state file (Q#DS1). +/// `Ok(false)` when the state dir is unconfigured or nothing is worth +/// saving. Never fails hard for the before-quit path (Q#DS8) — the +/// caller may ignore the error. +/// +/// # Errors +/// A state-write / serialization failure (surfaced for manual save). +pub fn save_session(lua: &Lua) -> Result { + let Some(base) = lua.app_data_ref::().map(|d| d.0.clone()) else { + return Ok(false); + }; + let core = lua + .app_data_ref::() + .ok_or("no editor core")? + .clone(); + let key = session_key_from_lua(lua); + let snap = snapshot(&core.borrow(), key); + let Some(snap) = snap else { + return Ok(false); + }; + let json = serde_json::to_string(&snap).map_err(|e| e.to_string())?; + let state_key = desktop_state_key(&snap.session_key); + crate::state::write(&base, &state_key, json.as_bytes()).map_err(|e| e.to_string())?; + Ok(true) +} + +/// Rebuild the LOCAL session from the desktop state file (Q#DS3). A +/// no-op when no desktop is saved for this key / version / session +/// mismatches. Fires `buffer.after-load` via `lua` with each restored +/// leaf active. +/// +/// # Errors +/// Parse / state-read failures; a missing individual file collapses its +/// leaf rather than failing the whole restore. +pub fn restore_session(lua: &Lua) -> Result<(), String> { + let key = session_key_from_lua(lua); + let Some(base) = lua.app_data_ref::().map(|d| d.0.clone()) else { + return Ok(()); + }; + let state_key = desktop_state_key(&key); + let Some(json) = crate::state::read(&base, &state_key).map_err(|e| e.to_string())? else { + return Ok(()); // no desktop saved + }; + let saved: SavedDesktop = serde_json::from_str(&json).map_err(|e| e.to_string())?; + if saved.version != DESKTOP_VERSION || saved.session_key != key { + return Ok(()); // unrecognized / wrong session + } + let core = lua + .app_data_ref::() + .ok_or("no editor core")? + .clone(); + let modified = restore_into(&core, &saved, || fire_after_load_hook(lua)); + if modified > 0 { + core.borrow_mut().status = + format!("desktop restored; {modified} buffer(s) had unsaved changes when saved"); + } + Ok(()) +} + +/// One restored leaf window, carried from the tree build to the +/// activate-then-fire pass (Q#DS3). +struct RestoreLeaf { + window: WindowId, + buffer: BufferId, + newly_loaded: bool, + cursor: u64, + view_top: usize, +} + +/// Do the structural rebuild: open buffers, prune the old LOCAL layout, +/// build the new tree + windows, install the view, then fire +/// `buffer.after-load` (via `fire_after_load`) with each newly-loaded +/// leaf active and apply the exact per-leaf `cursor`/`view_top` afterward +/// (desktop wins over saveplace, Q#DS3). Returns the count of buffers +/// that were modified at save time (for the Q#DS6 warning). +/// +/// Takes `&SharedCore` (not a borrow) so it can release the core borrow +/// around each hook fire — `buffer.after-load` re-enters `pmacs.editor.*` +/// which re-borrows the core. +pub fn restore_into( + core: &SharedCore, + saved: &SavedDesktop, + mut fire_after_load: impl FnMut(), +) -> usize { + // (2) Open every buffer up front (hidden ones survive), keyed by the + // raw saved path. A missing file is absent → its leaves collapse. + let mut modified_count = 0usize; + let mut opened: HashMap = HashMap::new(); + for sb in &saved.buffers { + if sb.modified { + modified_count += 1; + } + if let Ok(res) = core.borrow_mut().get_or_load_buffer(Path::new(&sb.path)) { + opened.insert(sb.path.clone(), res); + } + } + + // (3-5) Build + prune + install the new LOCAL view. + let mut leaves: Vec = Vec::new(); + let mut save_slots: Vec> = Vec::new(); + let active_wid = { + let mut c = core.borrow_mut(); + let old_ids = c.views.get(&FrontendId::LOCAL).map(|v| v.layout.iter_ids()); + let Some(root) = + build_restore_node(&mut c, &saved.root, &opened, &mut leaves, &mut save_slots) + else { + return modified_count; // nothing survived → keep current session + }; + // Prune every window of the old LOCAL layout (not just scratch) + // so none linger orphaned in `core.windows`. + if let Some(old_ids) = old_ids { + for id in old_ids { + c.windows.remove(&id); + } + } + let active = pick_active_window(&save_slots, saved.active_leaf) + .or_else(|| leaves.first().map(|l| l.window)); + let Some(active) = active else { + return modified_count; + }; + c.active_frontend = FrontendId::LOCAL; + c.views.insert( + FrontendId::LOCAL, + FrontendView { + layout: Layout { root }, + active, + }, + ); + active + }; + + // (5) activate-then-fire: after-load must observe the restored leaf + // as active (saveplace/recentf/syntax/LSP read active state). Fire + // once per newly-loaded buffer, then write the exact per-leaf + // cursor/view_top so desktop wins over saveplace. + let mut fired: HashSet = HashSet::new(); + for leaf in &leaves { + if leaf.newly_loaded && fired.insert(leaf.buffer) { + core.borrow_mut().set_active_window_id(leaf.window); + fire_after_load(); + } + let mut c = core.borrow_mut(); + if let Some(win) = c.windows.get_mut(&leaf.window) { + win.cursor = leaf.cursor; + win.view_top = leaf.view_top; + } + } + core.borrow_mut().set_active_window_id(active_wid); + modified_count +} + +/// Recursively rebuild a [`LayoutNode`] from a [`SavedNode`], creating a +/// `Window` in `core.windows` per surviving leaf. A leaf whose file +/// failed to open (absent from `opened`) is dropped and its split +/// collapsed — mirroring the save-side collapse. +fn build_restore_node( + core: &mut EditorCore, + node: &SavedNode, + opened: &HashMap, + leaves: &mut Vec, + save_slots: &mut Vec>, +) -> Option { + match node { + SavedNode::Leaf(leaf) => { + let Some(&(buffer_id, newly_loaded)) = opened.get(&leaf.path) else { + save_slots.push(None); // file missing → leaf collapses + return None; + }; + let text_view = { + let reg = core.registry.borrow(); + TextView::new(reg.get(buffer_id).ok()?) + }; + let buf_len = core + .registry + .borrow() + .get(buffer_id) + .map_or(0, crate::buffer::Buffer::len); + let cursor = leaf.cursor.min(buf_len); + let view_top = leaf.view_top.min(text_view.line_count().saturating_sub(1)); + let wid = WindowId::next(); + let mut win = Window::new(wid, buffer_id, text_view); + win.cursor = cursor; + win.view_top = view_top; + core.windows.insert(wid, win); + leaves.push(RestoreLeaf { + window: wid, + buffer: buffer_id, + newly_loaded, + cursor, + view_top, + }); + save_slots.push(Some(wid)); + Some(LayoutNode::Leaf(wid)) + } + SavedNode::Split { + orientation, + weights, + children, + } => { + let mut kept: Vec<(LayoutNode, u32)> = Vec::new(); + for (i, child) in children.iter().enumerate() { + if let Some(n) = build_restore_node(core, child, opened, leaves, save_slots) { + kept.push((n, weights.get(i).copied().unwrap_or(1).max(1))); + } + } + match kept.len() { + 0 => None, + 1 => Some(kept.into_iter().next().unwrap().0), + _ => { + let (nodes, ws): (Vec<_>, Vec<_>) = kept.into_iter().unzip(); + Some(LayoutNode::Split { + orientation: (*orientation).into(), + weights: ws, + children: nodes, + }) + } + } + } + } +} + +/// Resolve the focused window from the save-time `active_leaf` index +/// against the restore survivors: direct hit, else nearest surviving +/// preorder neighbor (later preferred) — Q#DS10. +fn pick_active_window(slots: &[Option], want: usize) -> Option { + if let Some(Some(w)) = slots.get(want) { + return Some(*w); + } + (0..slots.len()) + .filter(|&i| slots[i].is_some()) + .min_by_key(|&i| (i.abs_diff(want), usize::from(i < want))) + .and_then(|i| slots[i]) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn session_key_distinguishes_name_and_cwd() { + let by_name = session_key(Some("work"), "/home/u/proj"); + let by_cwd = session_key(None, "/home/u/proj"); + assert!(by_name.starts_with("name.")); + assert!(by_cwd.starts_with("cwd.")); + assert_ne!(by_name, by_cwd); + // Deterministic + charset-safe (state key validates it). + assert_eq!(by_name, session_key(Some("work"), "/elsewhere")); + assert!(crate::state::validate_name(&desktop_state_key(&by_name)).is_ok()); + assert!(crate::state::validate_name(&desktop_state_key(&by_cwd)).is_ok()); + } + + #[test] + fn saved_desktop_json_round_trips() { + let d = SavedDesktop { + version: DESKTOP_VERSION, + session_key: "cwd.abc".into(), + buffers: vec![SavedBuffer { + path: "/a.rs".into(), + modified: true, + }], + root: SavedNode::Split { + orientation: SavedOrientation::Vertical, + weights: vec![2, 1], + children: vec![ + SavedNode::Leaf(SavedLeaf { + path: "/a.rs".into(), + cursor: 10, + view_top: 2, + }), + SavedNode::Leaf(SavedLeaf { + path: "/b.rs".into(), + cursor: 0, + view_top: 0, + }), + ], + }, + active_leaf: 1, + }; + let json = serde_json::to_string(&d).unwrap(); + assert_eq!(serde_json::from_str::(&json).unwrap(), d); + } + + fn leaf(path: &str) -> SavedLeaf { + SavedLeaf { + path: path.into(), + cursor: 0, + view_top: 0, + } + } + + #[test] + fn split_with_one_survivor_collapses() { + // A 2-way split where only the first child is a file leaf → + // collapses to that leaf, no one-child split. + let (a, b) = (WindowId::next(), WindowId::next()); + let node = LayoutNode::Split { + orientation: Orientation::Vertical, + weights: vec![1, 1], + children: vec![LayoutNode::Leaf(a), LayoutNode::Leaf(b)], + }; + let resolve = |id: WindowId| (id == a).then(|| leaf("/a.rs")); + let mut surviving = Vec::new(); + let saved = build_saved_node(&node, &resolve, &mut surviving).unwrap(); + assert_eq!(saved, SavedNode::Leaf(leaf("/a.rs"))); + assert_eq!(surviving, vec![a]); + } + + #[test] + fn active_leaf_falls_back_to_neighbor() { + let (a, b, c) = (WindowId::next(), WindowId::next(), WindowId::next()); + // b was focused but dropped; survivors are [a, c] in preorder. + let full = vec![(a, true), (b, false), (c, true)]; + let surviving = vec![a, c]; + // Nearest neighbor to b (pos 1) preferring later → c (index 1). + assert_eq!(resolve_active_leaf(&full, &surviving, b), 1); + // Direct hit still works. + assert_eq!(resolve_active_leaf(&full, &surviving, a), 0); + } +} diff --git a/src/editor.rs b/src/editor.rs index 855e0bf..598b670 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -315,6 +315,12 @@ impl EditorState { include_str!("../builtin/runtime/recentf.lua"), ) .expect("load recentf builtin chunk"); + lua_host + .eval( + Some("@pmacs/builtin/runtime/desktop.lua"), + include_str!("../builtin/runtime/desktop.lua"), + ) + .expect("load desktop builtin chunk"); // T M7.11 bundled-package bootstrap. Through M7.10 the REPL // was loaded directly via `eval(include_str!(...))`; the // M7.11 deliverable migrates it to the package system so it @@ -464,6 +470,25 @@ impl EditorState { } } + /// Restore the session saved under this desktop's key, if armed + /// (`pmacs.session.desktop_mode(true)` called it) and no positional + /// file arg was given (Q#DS7). Called from the `RunLocal` arm of + /// [`run`]. All the work lives in [`crate::desktop::restore_session`] + /// (driven off the Lua host's app-data + hook mechanism). + pub fn restore_desktop_if_armed(&mut self, had_file: bool) { + let armed = self + .lua_host + .lua() + .app_data_ref::() + .is_some(); + if armed + && !had_file + && let Err(e) = crate::desktop::restore_session(self.lua_host.lua()) + { + self.core.borrow_mut().status = format!("desktop-restore: {e}"); + } + } + /// Construct an editor for a path. Empty buffer with `[new file]` /// status if the path does not exist; loaded contents otherwise. pub fn open(path: PathBuf) -> io::Result { @@ -1519,6 +1544,9 @@ impl Default for EditorState { /// local-TUI for the terminal. pub fn run(file: Option) -> io::Result<()> { install_panic_hook(); + // Capture before the `match` consumes `file`: a positional file arg + // means "open this", not "restore my desktop" (Q#DS7). + let had_file = file.is_some(); let mut state = match file { Some(path) => EditorState::open(path)?, None => EditorState::new(), @@ -1535,6 +1563,11 @@ pub fn run(file: Option) -> io::Result<()> { let requested = state.lua_host.take_requested_attach(); match crate::attach_dispatch::dispatch_attach(requested) { crate::attach_dispatch::AttachDispatch::RunLocal => { + // Committed to local mode: restore the desktop if armed and + // no file arg was given (Q#DS7). Done here, not right after + // construction, so a hand-off to attach mode (above) never + // populates an EditorState it's about to drop. + state.restore_desktop_if_armed(had_file); // Fall through to the local TUI loop below. } crate::attach_dispatch::AttachDispatch::RunAttachLocalSocket(socket) => { diff --git a/src/editor_core.rs b/src/editor_core.rs index 1432b75..0874bef 100644 --- a/src/editor_core.rs +++ b/src/editor_core.rs @@ -480,6 +480,32 @@ impl EditorCore { } } + /// Find the buffer already showing `path`, or load it fresh from + /// disk into a new buffer — **without** switching the active window + /// (Arc 3 desktop-restore builds its windows explicitly). Returns + /// `(id, newly_loaded)`; `newly_loaded` is `false` on a dedup hit + /// (the same file in two split panes) so the caller fires + /// `buffer.after-load` at most once per buffer. + /// + /// # Errors + /// Propagates a load failure (e.g. a since-deleted file) so restore + /// can skip that leaf rather than abort. + pub fn get_or_load_buffer(&mut self, path: &Path) -> std::io::Result<(BufferId, bool)> { + let normalized = normalize_buffer_path(path.to_path_buf()); + if let Some(id) = self.registry.borrow().find_by_path(&normalized) { + return Ok((id, false)); + } + let (bytes, meta) = crate::file_io::load_file(path)?; + let display_name = path.display().to_string(); + let id = self + .registry + .borrow_mut() + .create_from_bytes(display_name, &bytes); + self.set_buffer_path(id, Some(normalized)); + self.set_buffer_meta(id, Some(meta)); + Ok((id, true)) + } + /// Cursor of the active window (compatibility shim for callers /// migrated from pre-M2.8 code). #[must_use] diff --git a/src/lib.rs b/src/lib.rs index 3e6003f..7469641 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -66,6 +66,7 @@ pub mod buffer_mirror; pub mod daemon; pub mod daemon_attach; pub mod definition; +pub mod desktop; pub mod diag; pub mod document_highlight; pub mod editor; diff --git a/src/lua_bindings/mod.rs b/src/lua_bindings/mod.rs index ef73963..35f73ad 100644 --- a/src/lua_bindings/mod.rs +++ b/src/lua_bindings/mod.rs @@ -1962,10 +1962,71 @@ pub fn install( pmacs.set("ansi", install_ansi_module(lua)?)?; pmacs.set("packages", install_packages_module(lua)?)?; pmacs.set("state", install_state_module(lua)?)?; + pmacs.set("session", install_session_module(lua)?)?; lua.globals().set("pmacs", pmacs)?; Ok(()) } +/// Marker app-data set by `pmacs.session.arm_restore()` (Arc 3 phase 2, +/// Q#DS7). Its presence tells the `RunLocal` startup trigger to attempt +/// a desktop restore; `desktop_mode(true)` in init.lua arms it. +pub struct DesktopRestoreArmed; + +/// Marker app-data set by `run_daemon` (Arc 3 phase 2, Q#DS9). Desktop +/// save/restore is local-only in v1 (the daemon has a layout per +/// attached frontend and no frontend at construction), so `desktop.lua` +/// checks `pmacs.session.is_daemon()` and no-ops there. +pub struct DaemonMode; + +/// Fire `buffer.after-load` from Rust with the current active buffer — +/// the seam desktop-restore uses (Q#DS3). `pub(crate)` so +/// [`crate::desktop`] can drive it. +pub(crate) fn fire_after_load_hook(lua: &Lua) { + run_hook_if_defined(lua, "buffer.after-load", mlua::MultiValue::new()); +} + +/// `pmacs.session.*` — desktop-save (Arc 3 phase 2). All-Rust because +/// the layout serde + structural rebuild can't live in Lua (Q#DS1). +/// The thin `desktop.lua` builtin wires `desktop_mode` on top of these. +fn install_session_module(lua: &Lua) -> mlua::Result { + let m = lua.create_table()?; + + // arm_restore(): mark that a desktop restore should run at startup. + m.set( + "arm_restore", + lua.create_function(|lua, ()| { + lua.set_app_data(DesktopRestoreArmed); + Ok(()) + })?, + )?; + + // is_daemon(): keep desktop save/restore local-only in v1 (Q#DS9). + m.set( + "is_daemon", + lua.create_function(|lua, ()| Ok(lua.app_data_ref::().is_some()))?, + )?; + + // save_desktop(): serialize the current session. Returns true when + // a desktop was written (false = nothing to save / no state dir). + m.set( + "save_desktop", + lua.create_function(|lua, ()| { + crate::desktop::save_session(lua).map_err(mlua::Error::external) + })?, + )?; + + // restore_desktop(): rebuild the saved session (manual command; + // the startup path goes through EditorState::restore_desktop_if_armed). + m.set( + "restore_desktop", + lua.create_function(|lua, ()| { + crate::desktop::restore_session(lua).map_err(mlua::Error::external) + })?, + )?; + + Ok(m) +} + /// The configured base state directory (Arc 3, Q#PS2). Present as Lua /// app-data only when a real dir was resolved at startup; its absence /// (the `cfg(test)` case, and any host without `HOME`/`XDG_STATE_HOME`) diff --git a/tests/desktop_acceptance.rs b/tests/desktop_acceptance.rs new file mode 100644 index 0000000..4f7db40 --- /dev/null +++ b/tests/desktop_acceptance.rs @@ -0,0 +1,370 @@ +//! Desktop-save acceptance (Arc 3 phase 2): save the open file buffers, +//! window layout, and per-window positions, then restore them into a +//! fresh editor. Driven through the `pmacs.session` bindings and the +//! real Lua surface; fixtures use the split bindings plus direct core +//! access. +//! +//! Each test injects a private tempdir `StateDir` (integration tests +//! link the lib without `cfg(test)`), so nothing touches a developer's +//! real state dir. The session key is cwd-based, so a save in one +//! editor and a restore in another (same process) agree on the key. +//! +//! Framing: `docs/desktop-save-framing.md`. + +use pmacs::editor::EditorState; +use pmacs::lua_bindings::StateDir; +use pmacs::protocol::FrontendId; +use pmacs::window::{LayoutNode, WindowId}; +use std::path::PathBuf; +use std::sync::atomic::{AtomicUsize, Ordering}; + +/// Shared tempdir so a save and a restore in two editors use one state +/// store. Unique per test. +fn fresh_state_dir() -> PathBuf { + static SEQ: AtomicUsize = AtomicUsize::new(0); + let dir = std::env::temp_dir().join(format!( + "pmacs-desktop-{}-{}", + std::process::id(), + SEQ.fetch_add(1, Ordering::Relaxed) + )); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +fn editor(state_dir: &std::path::Path) -> EditorState { + let s = EditorState::new(); + s.lua_host.lua().remove_app_data::(); + s.lua_host + .lua() + .set_app_data(StateDir(state_dir.to_path_buf())); + s +} + +fn write_file(dir: &std::path::Path, name: &str, body: &str) -> String { + let p = dir.join(name); + std::fs::write(&p, body).unwrap(); + p.display().to_string() +} + +fn exec(s: &EditorState, src: &str) { + s.lua_host.lua().load(src.to_string()).exec().unwrap(); +} + +fn save(s: &EditorState) -> bool { + pmacs::desktop::save_session(s.lua_host.lua()).unwrap() +} + +fn restore(s: &mut EditorState) { + pmacs::desktop::restore_session(s.lua_host.lua()).unwrap(); +} + +/// The LOCAL frontend's leaves in preorder as `(path, cursor, view_top)`. +fn leaves(s: &EditorState) -> Vec<(String, u64, usize)> { + let core = s.core.borrow(); + let view = core.views.get(&FrontendId::LOCAL).unwrap(); + let mut ids = Vec::new(); + collect(&view.layout.root, &mut ids); + let reg = core.registry.borrow(); + ids.into_iter() + .map(|id| { + let w = core.windows.get(&id).unwrap(); + let path = reg + .get(w.buffer_id) + .unwrap() + .file_path() + .map(|p| p.display().to_string()) + .unwrap_or_default(); + (path, w.cursor, w.view_top) + }) + .collect() +} + +fn collect(node: &LayoutNode, out: &mut Vec) { + match node { + LayoutNode::Leaf(id) => out.push(*id), + LayoutNode::Split { children, .. } => { + for c in children { + collect(c, out); + } + } + } +} + +/// The root split's weights, if the root is a split. +fn root_weights(s: &EditorState) -> Option> { + let core = s.core.borrow(); + match &core.views.get(&FrontendId::LOCAL).unwrap().layout.root { + LayoutNode::Split { weights, .. } => Some(weights.clone()), + LayoutNode::Leaf(_) => None, + } +} + +/// All file-buffer paths in the registry (visible or hidden). +fn buffer_paths(s: &EditorState) -> Vec { + let core = s.core.borrow(); + let reg = core.registry.borrow(); + reg.ids() + .iter() + .filter_map(|&id| { + reg.get(id) + .ok()? + .file_path() + .map(|p| p.display().to_string()) + }) + .collect() +} + +/// Build a two-pane vertical split: left shows A, right shows B, with +/// the given root weights and per-pane cursors. Returns `(a_path, b_path)`. +fn build_two_pane( + s: &EditorState, + dir: &std::path::Path, + weights: [u32; 2], + ca: u64, + cb: u64, +) -> (String, String) { + let a = write_file(dir, "a.txt", "aaaa\nbbbb\ncccc\ndddd\neeee\n"); + let b = write_file(dir, "b.txt", "1111\n2222\n3333\n4444\n5555\n"); + exec(s, &format!("pmacs.buffer.find_or_open({a:?})")); + exec(s, "pmacs.window.split_vertical()"); + // Focus the new (right) pane and open B there. + exec(s, "pmacs.window.focus_next()"); + exec(s, &format!("pmacs.buffer.find_or_open({b:?})")); + // Set weights + cursors directly (the split API is 1:1 only). + let mut core = s.core.borrow_mut(); + if let LayoutNode::Split { weights: w, .. } = &mut core.active_layout_mut().root { + *w = weights.to_vec(); + } + let ids: Vec = core.views[&FrontendId::LOCAL].layout.iter_ids(); + core.windows.get_mut(&ids[0]).unwrap().cursor = ca; + core.windows.get_mut(&ids[1]).unwrap().cursor = cb; + (a, b) +} + +#[test] +fn round_trips_layout_weights_buffers_and_cursors() { + let dir = fresh_state_dir(); + let src = editor(&dir); + let (a, b) = build_two_pane(&src, &dir, [3, 1], 5, 12); + assert!(save(&src), "a desktop was written"); + + // Fresh editor, same state store → restore. + let mut dst = editor(&dir); + restore(&mut dst); + + assert_eq!( + root_weights(&dst).as_deref(), + Some(&[3, 1][..]), + "weights round-trip" + ); + let ls = leaves(&dst); + assert_eq!(ls.len(), 2, "two panes"); + assert_eq!(ls[0], (a, 5, 0), "left pane = A @ cursor 5"); + assert_eq!(ls[1], (b, 12, 0), "right pane = B @ cursor 12"); + std::fs::remove_dir_all(&dir).ok(); +} + +#[test] +fn hidden_buffer_survives_restore() { + let dir = fresh_state_dir(); + let src = editor(&dir); + let a = write_file(&dir, "a.txt", "aaaa\n"); + let b = write_file(&dir, "b.txt", "bbbb\n"); + // Open A, then B in the SAME window → A is now hidden but live. + exec(&src, &format!("pmacs.buffer.find_or_open({a:?})")); + exec(&src, &format!("pmacs.buffer.find_or_open({b:?})")); + assert!(save(&src)); + + let mut dst = editor(&dir); + restore(&mut dst); + let mut paths = buffer_paths(&dst); + paths.sort(); + assert!(paths.contains(&a), "hidden buffer A restored"); + assert!(paths.contains(&b), "visible buffer B restored"); + std::fs::remove_dir_all(&dir).ok(); +} + +#[test] +fn after_load_fires_with_the_restored_leaf_active() { + let dir = fresh_state_dir(); + let src = editor(&dir); + let (_a, _b) = build_two_pane(&src, &dir, [1, 1], 0, 0); + assert!(save(&src)); + + // Restore in a fresh editor with a probe on buffer.after-load that + // records the ACTIVE file path each time it fires. If the wrong + // buffer were active, the recorded paths would not match. + let mut dst = editor(&dir); + exec( + &dst, + r#" + _G.seen = {} + pmacs.hook.add("buffer.after-load", function() + _G.seen[#_G.seen + 1] = pmacs.editor.file_path() + end) + "#, + ); + restore(&mut dst); + let seen: Vec = dst.lua_host.lua().load("return _G.seen").eval().unwrap(); + // Two distinct files, each observed active exactly when its + // after-load fired. + let mut sorted = seen.clone(); + sorted.sort(); + sorted.dedup(); + assert_eq!( + sorted.len(), + 2, + "after-load fired once per buffer, active-correct: {seen:?}" + ); + std::fs::remove_dir_all(&dir).ok(); +} + +#[test] +fn same_file_in_two_panes_keeps_distinct_positions() { + let dir = fresh_state_dir(); + let src = editor(&dir); + let a = write_file(&dir, "a.txt", "aaaa\nbbbb\ncccc\ndddd\n"); + // Two panes both showing A, different cursors. + exec(&src, &format!("pmacs.buffer.find_or_open({a:?})")); + exec(&src, "pmacs.window.split_vertical()"); + { + let mut core = src.core.borrow_mut(); + let ids: Vec = core.views[&FrontendId::LOCAL].layout.iter_ids(); + core.windows.get_mut(&ids[0]).unwrap().cursor = 2; + core.windows.get_mut(&ids[1]).unwrap().cursor = 15; + } + assert!(save(&src)); + + let mut dst = editor(&dir); + restore(&mut dst); + let ls = leaves(&dst); + assert_eq!(ls.len(), 2); + assert_eq!(ls[0], (a.clone(), 2, 0)); + assert_eq!(ls[1], (a, 15, 0)); + std::fs::remove_dir_all(&dir).ok(); +} + +#[test] +fn missing_file_collapses_and_focus_falls_back() { + let dir = fresh_state_dir(); + let src = editor(&dir); + let (a, b) = build_two_pane(&src, &dir, [1, 1], 0, 0); + // The right pane (B) was focused at save (focus_next moved there). + assert!(save(&src)); + // Delete B before restore → its leaf collapses; focus must fall + // back to a surviving leaf (A), not crash. + std::fs::remove_file(&b).unwrap(); + + let mut dst = editor(&dir); + restore(&mut dst); + let ls = leaves(&dst); + assert_eq!(ls.len(), 1, "only A survives"); + assert_eq!(ls[0].0, a); + // Active window is a real, surviving window. + let core = dst.core.borrow(); + let active = core.views[&FrontendId::LOCAL].active; + assert!(core.windows.contains_key(&active), "active window survives"); + std::fs::remove_dir_all(&dir).ok(); +} + +#[test] +fn restore_leaves_no_orphan_windows() { + let dir = fresh_state_dir(); + let src = editor(&dir); + build_two_pane(&src, &dir, [1, 1], 0, 0); + assert!(save(&src)); + + let mut dst = editor(&dir); + // dst starts with 1 scratch window; after restore only the rebuilt + // leaves should remain in core.windows. + restore(&mut dst); + let core = dst.core.borrow(); + let leaf_ids: std::collections::HashSet<_> = core.views[&FrontendId::LOCAL] + .layout + .iter_ids() + .into_iter() + .collect(); + assert_eq!( + core.windows.len(), + leaf_ids.len(), + "no orphan windows linger in core.windows" + ); + std::fs::remove_dir_all(&dir).ok(); +} + +#[test] +fn session_keys_scope_by_name_vs_cwd() { + let dir = fresh_state_dir(); + // Save under an instance name. + let src_named = editor(&dir); + src_named.lua_host.set_instance_name(Some("work".into())); + let a = write_file(&dir, "a.txt", "aaaa\n"); + exec(&src_named, &format!("pmacs.buffer.find_or_open({a:?})")); + assert!(save(&src_named)); + + // A cwd-keyed (nameless) editor must NOT see the named desktop. + let mut dst_cwd = editor(&dir); + dst_cwd.lua_host.set_instance_name(None); + restore(&mut dst_cwd); + assert!( + leaves(&dst_cwd).iter().all(|(p, _, _)| p != &a), + "cwd session does not restore the name session's desktop" + ); + std::fs::remove_dir_all(&dir).ok(); +} + +#[test] +fn modified_buffer_warns_on_restore() { + let dir = fresh_state_dir(); + let src = editor(&dir); + let a = write_file(&dir, "a.txt", "aaaa\nbbbb\n"); + exec(&src, &format!("pmacs.buffer.find_or_open({a:?})")); + // Dirty the buffer (an edit) so it saves as modified. + exec(&src, "pmacs.window.buffer():insert(0, 'x')"); + assert!(save(&src)); + + let mut dst = editor(&dir); + restore(&mut dst); + let status = dst.core.borrow().status.clone(); + assert!( + status.contains("unsaved changes"), + "restore warns about modified buffers: {status:?}" + ); + std::fs::remove_dir_all(&dir).ok(); +} + +#[test] +fn startup_gate_respects_file_arg_and_arming() { + let dir = fresh_state_dir(); + let src = editor(&dir); + let a = write_file(&dir, "a.txt", "aaaa\n"); + exec(&src, &format!("pmacs.buffer.find_or_open({a:?})")); + assert!(save(&src)); + + // Not armed → no restore even with no file arg. + let mut d1 = editor(&dir); + d1.restore_desktop_if_armed(false); + assert!( + leaves(&d1).iter().all(|(p, _, _)| p != &a), + "unarmed: no restore" + ); + + // Armed + a file arg → still no restore (Q#DS7). + let mut d2 = editor(&dir); + exec(&d2, "pmacs.session.arm_restore()"); + d2.restore_desktop_if_armed(true); + assert!( + leaves(&d2).iter().all(|(p, _, _)| p != &a), + "armed + file arg: no restore" + ); + + // Armed + no file arg → restore. + let mut d3 = editor(&dir); + exec(&d3, "pmacs.session.arm_restore()"); + d3.restore_desktop_if_armed(false); + assert!( + leaves(&d3).iter().any(|(p, _, _)| p == &a), + "armed + no file: restores" + ); + std::fs::remove_dir_all(&dir).ok(); +} From e56c3055f206ede8dad26696d1e6ffa5afc058f9 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Wed, 8 Jul 2026 22:27:24 -0400 Subject: [PATCH 3/3] fix(persistence): reliable daemon gate, unarm, per-pane after-load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the PR #99 review: - HIGH daemon local-only was not reliable: run_daemon sets DaemonMode only after EditorState::new() has run init.lua, so desktop_mode(true) in init saw is_daemon()==false and the raw bindings were ungated. Now save_session/restore_session early-return in Rust when the DaemonMode marker is present — set right after the daemon's new(), so it holds for every save/restore that can run after startup (before-quit hook, manual commands, direct binding calls). - MEDIUM desktop_mode(false) could not unarm startup restore: arm_restore is now a boolean (arm_restore(on)) that sets/removes the marker, and desktop_mode(on) calls arm_restore(on). enable-then-disable no longer restores. - MEDIUM/LOW same-file multi-pane missed per-window overlays: restore now fires buffer.after-load once PER LEAF (per window), not once per buffer. Syntax attaches its overlay to the active window, so each pane gets its own; LSP attach_buffer is idempotent, so the same file in two panes attaches LSP once but syntax to both. - MEDIUM hidden restored buffers: documented as registry-only in v1 (they are live/openable/in recentf, but do not fire after-load, so they attach syntax on first visit via after-switch and LSP when next shown). Full initial attach for hidden buffers is deferred. Noted in the framing + a code comment. - LOW trailing whitespace in docs/desktop-save-framing.md. Tests (desktop_acceptance now 11): same_file_..._fires_per_pane asserts after-load fires twice for two panes of one file; daemon_mode_disables_ save_and_restore; disabling_desktop_mode_unarms_restore. Gates: fmt + workspace clippy clean; lib 1487; crdt 1658; desktop 11; persistence 5; m4 90; GPU 58; git diff --check clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- builtin/runtime/desktop.lua | 6 ++-- docs/desktop-save-framing.md | 47 +++++++++++++++++---------- src/desktop.rs | 50 ++++++++++++++++++++--------- src/lua_bindings/mod.rs | 12 +++++-- tests/desktop_acceptance.rs | 62 +++++++++++++++++++++++++++++++++++- 5 files changed, 138 insertions(+), 39 deletions(-) diff --git a/builtin/runtime/desktop.lua b/builtin/runtime/desktop.lua index 78c3003..7a66adc 100644 --- a/builtin/runtime/desktop.lua +++ b/builtin/runtime/desktop.lua @@ -23,9 +23,9 @@ function pmacs.session.desktop_mode(on) return false end enabled = on - if on then - pmacs.session.arm_restore() - end + -- Arm (or, when disabling, unarm) restore-on-startup, so an + -- enable-then-disable in init.lua does not still restore. + pmacs.session.arm_restore(on) return enabled end diff --git a/docs/desktop-save-framing.md b/docs/desktop-save-framing.md index 3e52601..a85302e 100644 --- a/docs/desktop-save-framing.md +++ b/docs/desktop-save-framing.md @@ -135,21 +135,30 @@ switch focus, so restore sequences activation explicitly. to `core.views[LOCAL]` from `core.windows` (not just the startup scratch window — leftover windows would linger in the `pub` map and still take part in edit notifications and buffer-liveness checks, - finding). + finding). 4. Build a fresh `LayoutNode` from `SavedNode` with new `WindowId`s and a `Window` per surviving leaf (weights copied verbatim), install it as `core.views[LOCAL].layout.root`. -5. **Fire `after-load` with the right leaf active**: for each surviving - leaf in preorder, set its window active; the first time a given - buffer is seen, fire `buffer.after-load` (once per newly-loaded - buffer, so saveplace/LSP/syntax attach against the correct active - buffer); then set that window's exact `cursor`/`view_top` from the - leaf. Desktop's per-leaf write lands *after* the hook, so it wins - over saveplace for precision — and same-file-two-leaves keeps - distinct positions a single saveplace entry could not. +5. **Fire `after-load` with the right leaf active, once per leaf**: for + each surviving leaf in preorder, set its window active, fire + `buffer.after-load`, then set that window's exact `cursor`/`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's `attach_buffer` is idempotent, so the same file in two panes + attaches LSP once but syntax to both (finding, round 3). The per-leaf + `cursor`/`view_top` write lands *after* the hook, so desktop wins over + saveplace — and same-file-two-leaves keeps distinct positions a single + saveplace entry could not. 6. Set `active` to the `active_leaf` window (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. @@ -186,7 +195,9 @@ buffers had unsaved changes when the desktop was saved") via 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. The Rust startup trigger fires restore: +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`: capture `let had_file = file.is_some();` **before** the `match file` at `src/editor.rs:1522` consumes `file`. But fire restore @@ -217,13 +228,15 @@ 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. Serializing "wherever a layout exists" -is ambiguous with multiple frontend layouts sharing one key, so the -before-quit save simply doesn't register (or early-returns) when the -process is a daemon; a diagnostic notes desktop-save is local-only in -v1. Daemon + GPU-attach save/restore is **deferred** to the first-attach -design. (Manual `desktop-save`/`desktop-restore` commands likewise -refuse in daemon mode in v1.) +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 diff --git a/src/desktop.rs b/src/desktop.rs index 4277854..b871d2b 100644 --- a/src/desktop.rs +++ b/src/desktop.rs @@ -15,7 +15,7 @@ //! //! Framing: docs/desktop-save-framing.md. -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; use std::path::Path; use mlua::Lua; @@ -243,6 +243,18 @@ pub fn resolve_active_leaf( // SharedCore / StateDir / LocalInstanceInfo app-data). // --------------------------------------------------------------------------- +/// True when this process is a daemon (multi-frontend). Desktop +/// save/restore is local-only in v1 (Q#DS9); this is the **reliable** +/// enforcement — the `DaemonMode` marker is set right after the daemon's +/// `EditorState::new()`, so it is present for every save/restore that +/// can run after startup (the before-quit hook, manual commands, direct +/// binding calls), even though `init.lua` runs before it is set. Both +/// `save_session` and `restore_session` no-op when it holds. +fn is_daemon(lua: &Lua) -> bool { + lua.app_data_ref::() + .is_some() +} + /// The desktop session key for this process (`cwd.` in local mode). fn session_key_from_lua(lua: &Lua) -> String { match lua @@ -313,6 +325,9 @@ pub fn snapshot(core: &EditorCore, session_key: String) -> Option /// # Errors /// A state-write / serialization failure (surfaced for manual save). pub fn save_session(lua: &Lua) -> Result { + if is_daemon(lua) { + return Ok(false); // local-only in v1 (Q#DS9) + } let Some(base) = lua.app_data_ref::().map(|d| d.0.clone()) else { return Ok(false); }; @@ -340,6 +355,9 @@ pub fn save_session(lua: &Lua) -> Result { /// Parse / state-read failures; a missing individual file collapses its /// leaf rather than failing the whole restore. pub fn restore_session(lua: &Lua) -> Result<(), String> { + if is_daemon(lua) { + return Ok(()); // local-only in v1 (Q#DS9) + } let key = session_key_from_lua(lua); let Some(base) = lua.app_data_ref::().map(|d| d.0.clone()) else { return Ok(()); @@ -368,8 +386,6 @@ pub fn restore_session(lua: &Lua) -> Result<(), String> { /// activate-then-fire pass (Q#DS3). struct RestoreLeaf { window: WindowId, - buffer: BufferId, - newly_loaded: bool, cursor: u64, view_top: usize, } @@ -436,16 +452,22 @@ pub fn restore_into( active }; - // (5) activate-then-fire: after-load must observe the restored leaf - // as active (saveplace/recentf/syntax/LSP read active state). Fire - // once per newly-loaded buffer, then write the exact per-leaf - // cursor/view_top so desktop wins over saveplace. - let mut fired: HashSet = HashSet::new(); + // (5) activate-then-fire, once **per leaf** (per window). after-load + // must observe the restored leaf as active (saveplace/recentf/syntax/ + // LSP read active state). Firing per leaf — not per buffer — gives + // each pane its own per-window overlay (syntax attaches to the active + // window), while LSP's `attach_buffer` is idempotent, so the same + // file in two panes still attaches LSP once but syntax to both. + // Writing the exact per-leaf cursor/view_top *after* the hook lets + // desktop win over saveplace. + // + // NOTE (Q#DS3, hidden buffers): a restored buffer with NO leaf (open + // but hidden) is loaded into the registry but does not fire + // after-load here — it attaches syntax on first visit (after-switch) + // and LSP when it is next shown/opened. Registry-only in v1. for leaf in &leaves { - if leaf.newly_loaded && fired.insert(leaf.buffer) { - core.borrow_mut().set_active_window_id(leaf.window); - fire_after_load(); - } + core.borrow_mut().set_active_window_id(leaf.window); + fire_after_load(); let mut c = core.borrow_mut(); if let Some(win) = c.windows.get_mut(&leaf.window) { win.cursor = leaf.cursor; @@ -469,7 +491,7 @@ fn build_restore_node( ) -> Option { match node { SavedNode::Leaf(leaf) => { - let Some(&(buffer_id, newly_loaded)) = opened.get(&leaf.path) else { + let Some(&(buffer_id, _newly)) = opened.get(&leaf.path) else { save_slots.push(None); // file missing → leaf collapses return None; }; @@ -491,8 +513,6 @@ fn build_restore_node( core.windows.insert(wid, win); leaves.push(RestoreLeaf { window: wid, - buffer: buffer_id, - newly_loaded, cursor, view_top, }); diff --git a/src/lua_bindings/mod.rs b/src/lua_bindings/mod.rs index 35f73ad..54ab0d3 100644 --- a/src/lua_bindings/mod.rs +++ b/src/lua_bindings/mod.rs @@ -1991,11 +1991,17 @@ pub(crate) fn fire_after_load_hook(lua: &Lua) { fn install_session_module(lua: &Lua) -> mlua::Result
{ let m = lua.create_table()?; - // arm_restore(): mark that a desktop restore should run at startup. + // arm_restore(on): arm (or, with `false`, unarm) restore-on-startup. + // A boolean app-data path so `desktop_mode(false)` can undo a prior + // `desktop_mode(true)` — the marker is not one-way. m.set( "arm_restore", - lua.create_function(|lua, ()| { - lua.set_app_data(DesktopRestoreArmed); + lua.create_function(|lua, on: Option| { + if on.unwrap_or(true) { + lua.set_app_data(DesktopRestoreArmed); + } else { + lua.remove_app_data::(); + } Ok(()) })?, )?; diff --git a/tests/desktop_acceptance.rs b/tests/desktop_acceptance.rs index 4f7db40..61cab86 100644 --- a/tests/desktop_acceptance.rs +++ b/tests/desktop_acceptance.rs @@ -220,7 +220,7 @@ fn after_load_fires_with_the_restored_leaf_active() { } #[test] -fn same_file_in_two_panes_keeps_distinct_positions() { +fn same_file_in_two_panes_keeps_distinct_positions_and_fires_per_pane() { let dir = fresh_state_dir(); let src = editor(&dir); let a = write_file(&dir, "a.txt", "aaaa\nbbbb\ncccc\ndddd\n"); @@ -236,7 +236,19 @@ fn same_file_in_two_panes_keeps_distinct_positions() { assert!(save(&src)); let mut dst = editor(&dir); + // after-load must fire once PER PANE (not once per buffer) so each + // window gets its own per-window overlay (syntax attaches to the + // active window; LSP attach is idempotent). + exec( + &dst, + "_G.fires = 0; pmacs.hook.add('buffer.after-load', function() _G.fires = _G.fires + 1 end)", + ); restore(&mut dst); + let fires: i64 = dst.lua_host.lua().load("return _G.fires").eval().unwrap(); + assert_eq!( + fires, 2, + "after-load fires once per pane for the same buffer" + ); let ls = leaves(&dst); assert_eq!(ls.len(), 2); assert_eq!(ls[0], (a.clone(), 2, 0)); @@ -244,6 +256,54 @@ fn same_file_in_two_panes_keeps_distinct_positions() { std::fs::remove_dir_all(&dir).ok(); } +#[test] +fn daemon_mode_disables_save_and_restore() { + let dir = fresh_state_dir(); + // Seed a desktop from a normal (non-daemon) editor. + let src = editor(&dir); + let a = write_file(&dir, "a.txt", "aaaa\n"); + exec(&src, &format!("pmacs.buffer.find_or_open({a:?})")); + assert!(save(&src)); + + // A daemon editor must not save or restore (local-only, Q#DS9) — + // the Rust gate holds regardless of what init did. + let mut daemon = editor(&dir); + daemon + .lua_host + .lua() + .set_app_data(pmacs::lua_bindings::DaemonMode); + assert!(!save(&daemon), "daemon save is a no-op"); + restore(&mut daemon); + assert!( + leaves(&daemon).iter().all(|(p, _, _)| p != &a), + "daemon restore is a no-op" + ); + std::fs::remove_dir_all(&dir).ok(); +} + +#[test] +fn disabling_desktop_mode_unarms_restore() { + let dir = fresh_state_dir(); + let src = editor(&dir); + let a = write_file(&dir, "a.txt", "aaaa\n"); + exec(&src, &format!("pmacs.buffer.find_or_open({a:?})")); + assert!(save(&src)); + + // Enable then disable desktop_mode → the startup restore must NOT + // fire (arming is a boolean; disable unarms). + let mut dst = editor(&dir); + exec( + &dst, + "pmacs.session.desktop_mode(true); pmacs.session.desktop_mode(false)", + ); + dst.restore_desktop_if_armed(false); + assert!( + leaves(&dst).iter().all(|(p, _, _)| p != &a), + "disabled desktop_mode leaves restore unarmed" + ); + std::fs::remove_dir_all(&dir).ok(); +} + #[test] fn missing_file_collapses_and_focus_falls_back() { let dir = fresh_state_dir();