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:
commit
a0a4e7f5a1
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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`)
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
Loading…
Reference in New Issue