Merge pull request #99 from levineuwirth/session-persistence-p2-desktop

feat(persistence): desktop-save — buffers + layout + positions (Arc 3 phase 2)
This commit is contained in:
Levi Neuwirth 2026-07-08 22:54:10 -04:00 committed by GitHub
commit a0a4e7f5a1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 1584 additions and 0 deletions

View File

@ -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
-- 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
-- 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,
}

View File

@ -0,0 +1,302 @@
# 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 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<String>` and
`working_directory: String` (`InstanceIdentity`, serde-derived,
`pmacs-protocol/src/message.rs:1394`).
- **Startup**: `editor::run(file: Option<PathBuf>)` (`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/<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()`:
1. Read + parse `desktop/<key>`; 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, 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.
### 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`: 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. 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)
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.

View File

@ -468,6 +468,13 @@ pub fn run_daemon(socket_path: PathBuf, instance_name: Option<String>) -> 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

646
src/desktop.rs Normal file
View File

@ -0,0 +1,646 @@
// 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;
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<SavedBuffer>,
/// 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<u32>,
/// Children in display order.
children: Vec<SavedNode>,
},
}
/// 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<Orientation> for SavedOrientation {
fn from(o: Orientation) -> Self {
match o {
Orientation::Horizontal => SavedOrientation::Horizontal,
Orientation::Vertical => SavedOrientation::Vertical,
}
}
}
impl From<SavedOrientation> 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.<sha256hex>` keyed on the instance/socket name when set, else
/// `cwd.<sha256hex>` 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<SavedLeaf>,
surviving: &mut Vec<WindowId>,
) -> Option<SavedNode> {
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).
// ---------------------------------------------------------------------------
/// 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::<crate::lua_bindings::DaemonMode>()
.is_some()
}
/// The desktop session key for this process (`cwd.<hash>` in local mode).
fn session_key_from_lua(lua: &Lua) -> String {
match lua
.app_data_ref::<LocalInstanceInfo>()
.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<SavedDesktop> {
let view = core.views.get(&FrontendId::LOCAL)?;
let focused = view.active;
let reg = core.registry.borrow();
let resolve = |wid: WindowId| -> Option<SavedLeaf> {
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<bool, String> {
if is_daemon(lua) {
return Ok(false); // local-only in v1 (Q#DS9)
}
let Some(base) = lua.app_data_ref::<StateDir>().map(|d| d.0.clone()) else {
return Ok(false);
};
let core = lua
.app_data_ref::<SharedCore>()
.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> {
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::<StateDir>().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::<SharedCore>()
.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,
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<String, (BufferId, bool)> = 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<RestoreLeaf> = Vec::new();
let mut save_slots: Vec<Option<WindowId>> = 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, 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 {
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<String, (BufferId, bool)>,
leaves: &mut Vec<RestoreLeaf>,
save_slots: &mut Vec<Option<WindowId>>,
) -> Option<LayoutNode> {
match node {
SavedNode::Leaf(leaf) => {
let Some(&(buffer_id, _newly)) = 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,
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<WindowId>], want: usize) -> Option<WindowId> {
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::<SavedDesktop>(&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);
}
}

View File

@ -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::<crate::lua_bindings::DesktopRestoreArmed>()
.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<Self> {
@ -1519,6 +1544,9 @@ impl Default for EditorState {
/// local-TUI for the terminal.
pub fn run(file: Option<PathBuf>) -> 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<PathBuf>) -> 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) => {

View File

@ -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]

View File

@ -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;

View File

@ -1962,10 +1962,77 @@ 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<Table> {
let m = lua.create_table()?;
// 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, on: Option<bool>| {
if on.unwrap_or(true) {
lua.set_app_data(DesktopRestoreArmed);
} else {
lua.remove_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::<DaemonMode>().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`)

430
tests/desktop_acceptance.rs Normal file
View File

@ -0,0 +1,430 @@
//! 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::<StateDir>();
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<WindowId>) {
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<Vec<u32>> {
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<String> {
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<WindowId> = 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<String> = 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_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");
// 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<WindowId> = 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);
// 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));
assert_eq!(ls[1], (a, 15, 0));
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();
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();
}