feat(persistence): desktop-save --- buffers + layout + positions (Arc 3 phase 2)
Framing: docs/desktop-save-framing.md (Q#DS1-10). Save the open file
buffers, window layout, and per-window positions on quit; rebuild them
on startup. Emacs desktop.el, opt-in, local-mode only in v1.
All-Rust (Q#DS1) --- the core window enums are not serde and there is
no Lua tree API, so the layout mirror + structural rebuild live in Rust.
Lua adds only the opt-in switch and manual commands.
src/desktop.rs (new):
- Serde mirror (SavedDesktop / SavedBuffer / SavedNode / SavedLeaf /
SavedOrientation): every open file buffer (visible OR hidden, so a
switched-away file survives), the layout tree with orientation +
weights + nesting, per-leaf cursor/view_top, and an active-leaf
preorder index with a nearest-neighbor fallback (Q#DS10).
- session_key: SHA-256, name.<hex> when a socket name is set else
cwd.<hex> (charset-safe for the pmacs.state key).
- save_session / restore_session take the &Lua that carries the
SharedCore / StateDir / LocalInstanceInfo app-data, so they run
identically from a pmacs.session.* binding and the startup trigger.
- restore ordering (Q#DS3): open all buffers; prune EVERY window of the
old LOCAL layout (not just scratch); rebuild the tree; then per leaf
in preorder activate its window and fire buffer.after-load once per
newly-loaded buffer (hooks read active state), and write the exact
cursor/view_top AFTER so desktop wins over saveplace (same file in two
panes keeps distinct positions). A missing file collapses its leaf.
src/editor_core.rs: get_or_load_buffer(path) --- find_by_path else
load fresh, WITHOUT switching the active window; returns (id, newly).
src/lua_bindings: pmacs.session.{save_desktop, restore_desktop,
arm_restore, is_daemon}; DesktopRestoreArmed + DaemonMode markers;
fire_after_load_hook seam.
builtin/runtime/desktop.lua: pmacs.session.desktop_mode(on) wires
before-quit save + arms restore; desktop-save / desktop-restore
commands. No-op under a daemon (Q#DS9).
Startup trigger (Q#DS7): editor::run captures had_file before the match
consumes `file`, and restore_desktop_if_armed runs INSIDE the RunLocal
arm (after attach dispatch) so a hand-off to attach never populates an
EditorState it is about to drop. Daemon marks DaemonMode → desktop
stays local-only.
Tests: src/desktop.rs units (tree collapse, active-leaf fallback,
key/json round-trip) + tests/desktop_acceptance.rs (9): nested weighted
round-trip, hidden-buffer survival, after-load-active probe, same-file
two-pane distinct positions, missing-file collapse + focus fallback, no
orphan windows, name-vs-cwd key scoping, modified warning, startup gate.
Gates: fmt + workspace clippy clean; lib 1487; crdt 1658; desktop 9;
persistence 5; m4 90; m8 daemon 10/15; query-replace/completion/
listview/overlay/cua green; GPU 58; git diff --check clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
72563e93cf
commit
3607df0afe
|
|
@ -0,0 +1,72 @@
|
|||
-- desktop.lua --- session desktop-save wiring (Arc 3 phase 2).
|
||||
--
|
||||
-- The Rust `pmacs.session.{save_desktop,restore_desktop,arm_restore,
|
||||
-- is_daemon}` primitives do the load-bearing work (layout serde +
|
||||
-- structural rebuild). This thin layer adds the opt-in `desktop_mode`
|
||||
-- switch and the manual commands.
|
||||
--
|
||||
-- Opt-in: nothing happens unless init.lua calls
|
||||
-- `pmacs.session.desktop_mode(true)`. Local-only in v1 (Q#DS9) — a
|
||||
-- no-op under a daemon, where each attached frontend has its own layout.
|
||||
--
|
||||
-- Framing: docs/desktop-save-framing.md.
|
||||
|
||||
local enabled = false
|
||||
|
||||
-- Enable (or disable) desktop-save. When enabled in local mode:
|
||||
-- * arm restore-on-startup (the RunLocal trigger fires it), and
|
||||
-- * save the session on quit (editor.before-quit).
|
||||
function pmacs.session.desktop_mode(on)
|
||||
on = (on ~= false)
|
||||
if on and pmacs.session.is_daemon() then
|
||||
-- Local-only in v1; keep quiet rather than half-enable.
|
||||
return false
|
||||
end
|
||||
enabled = on
|
||||
if on then
|
||||
pmacs.session.arm_restore()
|
||||
end
|
||||
return enabled
|
||||
end
|
||||
|
||||
-- Save on quit. before-quit is short-circuit; returning nil never
|
||||
-- vetoes, and a save failure must not block quitting (Q#DS8).
|
||||
pmacs.hook.add("editor.before-quit", function()
|
||||
if enabled then
|
||||
pcall(pmacs.session.save_desktop)
|
||||
end
|
||||
end)
|
||||
|
||||
pmacs.command.define {
|
||||
name = "desktop-save",
|
||||
description = "Save the current session (buffers + layout) to disk.",
|
||||
fn = function()
|
||||
if pmacs.session.is_daemon() then
|
||||
pmacs.editor.set_status("desktop-save: local-only in v1")
|
||||
return
|
||||
end
|
||||
local ok, wrote_or_err = pcall(pmacs.session.save_desktop)
|
||||
if not ok then
|
||||
pmacs.editor.set_status("desktop-save: " .. tostring(wrote_or_err))
|
||||
elseif wrote_or_err then
|
||||
pmacs.editor.set_status("desktop saved")
|
||||
else
|
||||
pmacs.editor.set_status("desktop-save: nothing to save")
|
||||
end
|
||||
end,
|
||||
}
|
||||
|
||||
pmacs.command.define {
|
||||
name = "desktop-restore",
|
||||
description = "Restore the saved session (buffers + layout) from disk.",
|
||||
fn = function()
|
||||
if pmacs.session.is_daemon() then
|
||||
pmacs.editor.set_status("desktop-restore: local-only in v1")
|
||||
return
|
||||
end
|
||||
local ok, err = pcall(pmacs.session.restore_desktop)
|
||||
if not ok then
|
||||
pmacs.editor.set_status("desktop-restore: " .. tostring(err))
|
||||
end
|
||||
end,
|
||||
}
|
||||
|
|
@ -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,626 @@
|
|||
// desktop.rs --- session desktop-save (Arc 3 phase 2).
|
||||
|
||||
//! Serialize the open file buffers + window layout + per-window
|
||||
//! positions so a session survives a restart. Emacs `desktop.el`,
|
||||
//! opt-in via `pmacs.session.desktop_mode(true)`.
|
||||
//!
|
||||
//! This module owns the whole feature: the serde mirror types (the core
|
||||
//! window enums are not serde and stay that way), the SHA-256 session
|
||||
//! key, the save-side snapshot, and the restore orchestration that opens
|
||||
//! files, prunes/rebuilds windows, and fires `buffer.after-load`.
|
||||
//! [`save_session`] / [`restore_session`] take the `&Lua` that carries
|
||||
//! the editor's `SharedCore` / `StateDir` / `LocalInstanceInfo`
|
||||
//! app-data, so they run identically from a `pmacs.session.*` binding
|
||||
//! and from the `RunLocal` startup trigger.
|
||||
//!
|
||||
//! Framing: docs/desktop-save-framing.md.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::Path;
|
||||
|
||||
use mlua::Lua;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::buffer::BufferId;
|
||||
use crate::editor_core::EditorCore;
|
||||
use crate::lua_bindings::{LocalInstanceInfo, SharedCore, StateDir, fire_after_load_hook};
|
||||
use crate::protocol::FrontendId;
|
||||
use crate::text_view::TextView;
|
||||
use crate::window::{FrontendView, Layout, LayoutNode, Orientation, Window, WindowId};
|
||||
|
||||
/// Bump when the on-disk shape changes incompatibly. Restore ignores a
|
||||
/// desktop whose `version` it does not recognize.
|
||||
pub const DESKTOP_VERSION: u32 = 1;
|
||||
|
||||
/// A serializable snapshot of a session: every open file buffer, the
|
||||
/// window layout, and which leaf was focused.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SavedDesktop {
|
||||
/// Format version ([`DESKTOP_VERSION`]).
|
||||
pub version: u32,
|
||||
/// The [`session_key`] this was saved under; restore refuses a
|
||||
/// mismatch (defense in depth — the filename already encodes it).
|
||||
pub session_key: String,
|
||||
/// **Every** open file buffer (visible or hidden), so a file opened
|
||||
/// then switched away from survives restore — not just layout
|
||||
/// leaves.
|
||||
pub buffers: Vec<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).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 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> {
|
||||
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> {
|
||||
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,
|
||||
buffer: BufferId,
|
||||
newly_loaded: bool,
|
||||
cursor: u64,
|
||||
view_top: usize,
|
||||
}
|
||||
|
||||
/// Do the structural rebuild: open buffers, prune the old LOCAL layout,
|
||||
/// build the new tree + windows, install the view, then fire
|
||||
/// `buffer.after-load` (via `fire_after_load`) with each newly-loaded
|
||||
/// leaf active and apply the exact per-leaf `cursor`/`view_top` afterward
|
||||
/// (desktop wins over saveplace, Q#DS3). Returns the count of buffers
|
||||
/// that were modified at save time (for the Q#DS6 warning).
|
||||
///
|
||||
/// Takes `&SharedCore` (not a borrow) so it can release the core borrow
|
||||
/// around each hook fire — `buffer.after-load` re-enters `pmacs.editor.*`
|
||||
/// which re-borrows the core.
|
||||
pub fn restore_into(
|
||||
core: &SharedCore,
|
||||
saved: &SavedDesktop,
|
||||
mut fire_after_load: impl FnMut(),
|
||||
) -> usize {
|
||||
// (2) Open every buffer up front (hidden ones survive), keyed by the
|
||||
// raw saved path. A missing file is absent → its leaves collapse.
|
||||
let mut modified_count = 0usize;
|
||||
let mut opened: HashMap<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: after-load must observe the restored leaf
|
||||
// as active (saveplace/recentf/syntax/LSP read active state). Fire
|
||||
// once per newly-loaded buffer, then write the exact per-leaf
|
||||
// cursor/view_top so desktop wins over saveplace.
|
||||
let mut fired: HashSet<BufferId> = HashSet::new();
|
||||
for leaf in &leaves {
|
||||
if leaf.newly_loaded && fired.insert(leaf.buffer) {
|
||||
core.borrow_mut().set_active_window_id(leaf.window);
|
||||
fire_after_load();
|
||||
}
|
||||
let mut c = core.borrow_mut();
|
||||
if let Some(win) = c.windows.get_mut(&leaf.window) {
|
||||
win.cursor = leaf.cursor;
|
||||
win.view_top = leaf.view_top;
|
||||
}
|
||||
}
|
||||
core.borrow_mut().set_active_window_id(active_wid);
|
||||
modified_count
|
||||
}
|
||||
|
||||
/// Recursively rebuild a [`LayoutNode`] from a [`SavedNode`], creating a
|
||||
/// `Window` in `core.windows` per surviving leaf. A leaf whose file
|
||||
/// failed to open (absent from `opened`) is dropped and its split
|
||||
/// collapsed — mirroring the save-side collapse.
|
||||
fn build_restore_node(
|
||||
core: &mut EditorCore,
|
||||
node: &SavedNode,
|
||||
opened: &HashMap<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_loaded)) = opened.get(&leaf.path) else {
|
||||
save_slots.push(None); // file missing → leaf collapses
|
||||
return None;
|
||||
};
|
||||
let text_view = {
|
||||
let reg = core.registry.borrow();
|
||||
TextView::new(reg.get(buffer_id).ok()?)
|
||||
};
|
||||
let buf_len = core
|
||||
.registry
|
||||
.borrow()
|
||||
.get(buffer_id)
|
||||
.map_or(0, crate::buffer::Buffer::len);
|
||||
let cursor = leaf.cursor.min(buf_len);
|
||||
let view_top = leaf.view_top.min(text_view.line_count().saturating_sub(1));
|
||||
let wid = WindowId::next();
|
||||
let mut win = Window::new(wid, buffer_id, text_view);
|
||||
win.cursor = cursor;
|
||||
win.view_top = view_top;
|
||||
core.windows.insert(wid, win);
|
||||
leaves.push(RestoreLeaf {
|
||||
window: wid,
|
||||
buffer: buffer_id,
|
||||
newly_loaded,
|
||||
cursor,
|
||||
view_top,
|
||||
});
|
||||
save_slots.push(Some(wid));
|
||||
Some(LayoutNode::Leaf(wid))
|
||||
}
|
||||
SavedNode::Split {
|
||||
orientation,
|
||||
weights,
|
||||
children,
|
||||
} => {
|
||||
let mut kept: Vec<(LayoutNode, u32)> = Vec::new();
|
||||
for (i, child) in children.iter().enumerate() {
|
||||
if let Some(n) = build_restore_node(core, child, opened, leaves, save_slots) {
|
||||
kept.push((n, weights.get(i).copied().unwrap_or(1).max(1)));
|
||||
}
|
||||
}
|
||||
match kept.len() {
|
||||
0 => None,
|
||||
1 => Some(kept.into_iter().next().unwrap().0),
|
||||
_ => {
|
||||
let (nodes, ws): (Vec<_>, Vec<_>) = kept.into_iter().unzip();
|
||||
Some(LayoutNode::Split {
|
||||
orientation: (*orientation).into(),
|
||||
weights: ws,
|
||||
children: nodes,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the focused window from the save-time `active_leaf` index
|
||||
/// against the restore survivors: direct hit, else nearest surviving
|
||||
/// preorder neighbor (later preferred) — Q#DS10.
|
||||
fn pick_active_window(slots: &[Option<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,71 @@ pub fn install(
|
|||
pmacs.set("ansi", install_ansi_module(lua)?)?;
|
||||
pmacs.set("packages", install_packages_module(lua)?)?;
|
||||
pmacs.set("state", install_state_module(lua)?)?;
|
||||
pmacs.set("session", install_session_module(lua)?)?;
|
||||
lua.globals().set("pmacs", pmacs)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Marker app-data set by `pmacs.session.arm_restore()` (Arc 3 phase 2,
|
||||
/// Q#DS7). Its presence tells the `RunLocal` startup trigger to attempt
|
||||
/// a desktop restore; `desktop_mode(true)` in init.lua arms it.
|
||||
pub struct DesktopRestoreArmed;
|
||||
|
||||
/// Marker app-data set by `run_daemon` (Arc 3 phase 2, Q#DS9). Desktop
|
||||
/// save/restore is local-only in v1 (the daemon has a layout per
|
||||
/// attached frontend and no frontend at construction), so `desktop.lua`
|
||||
/// checks `pmacs.session.is_daemon()` and no-ops there.
|
||||
pub struct DaemonMode;
|
||||
|
||||
/// Fire `buffer.after-load` from Rust with the current active buffer —
|
||||
/// the seam desktop-restore uses (Q#DS3). `pub(crate)` so
|
||||
/// [`crate::desktop`] can drive it.
|
||||
pub(crate) fn fire_after_load_hook(lua: &Lua) {
|
||||
run_hook_if_defined(lua, "buffer.after-load", mlua::MultiValue::new());
|
||||
}
|
||||
|
||||
/// `pmacs.session.*` — desktop-save (Arc 3 phase 2). All-Rust because
|
||||
/// the layout serde + structural rebuild can't live in Lua (Q#DS1).
|
||||
/// The thin `desktop.lua` builtin wires `desktop_mode` on top of these.
|
||||
fn install_session_module(lua: &Lua) -> mlua::Result<Table> {
|
||||
let m = lua.create_table()?;
|
||||
|
||||
// arm_restore(): mark that a desktop restore should run at startup.
|
||||
m.set(
|
||||
"arm_restore",
|
||||
lua.create_function(|lua, ()| {
|
||||
lua.set_app_data(DesktopRestoreArmed);
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// is_daemon(): keep desktop save/restore local-only in v1 (Q#DS9).
|
||||
m.set(
|
||||
"is_daemon",
|
||||
lua.create_function(|lua, ()| Ok(lua.app_data_ref::<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,370 @@
|
|||
//! Desktop-save acceptance (Arc 3 phase 2): save the open file buffers,
|
||||
//! window layout, and per-window positions, then restore them into a
|
||||
//! fresh editor. Driven through the `pmacs.session` bindings and the
|
||||
//! real Lua surface; fixtures use the split bindings plus direct core
|
||||
//! access.
|
||||
//!
|
||||
//! Each test injects a private tempdir `StateDir` (integration tests
|
||||
//! link the lib without `cfg(test)`), so nothing touches a developer's
|
||||
//! real state dir. The session key is cwd-based, so a save in one
|
||||
//! editor and a restore in another (same process) agree on the key.
|
||||
//!
|
||||
//! Framing: `docs/desktop-save-framing.md`.
|
||||
|
||||
use pmacs::editor::EditorState;
|
||||
use pmacs::lua_bindings::StateDir;
|
||||
use pmacs::protocol::FrontendId;
|
||||
use pmacs::window::{LayoutNode, WindowId};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
/// Shared tempdir so a save and a restore in two editors use one state
|
||||
/// store. Unique per test.
|
||||
fn fresh_state_dir() -> PathBuf {
|
||||
static SEQ: AtomicUsize = AtomicUsize::new(0);
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"pmacs-desktop-{}-{}",
|
||||
std::process::id(),
|
||||
SEQ.fetch_add(1, Ordering::Relaxed)
|
||||
));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
fn editor(state_dir: &std::path::Path) -> EditorState {
|
||||
let s = EditorState::new();
|
||||
s.lua_host.lua().remove_app_data::<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() {
|
||||
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);
|
||||
restore(&mut dst);
|
||||
let ls = leaves(&dst);
|
||||
assert_eq!(ls.len(), 2);
|
||||
assert_eq!(ls[0], (a.clone(), 2, 0));
|
||||
assert_eq!(ls[1], (a, 15, 0));
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_file_collapses_and_focus_falls_back() {
|
||||
let dir = fresh_state_dir();
|
||||
let src = editor(&dir);
|
||||
let (a, b) = build_two_pane(&src, &dir, [1, 1], 0, 0);
|
||||
// The right pane (B) was focused at save (focus_next moved there).
|
||||
assert!(save(&src));
|
||||
// Delete B before restore → its leaf collapses; focus must fall
|
||||
// back to a surviving leaf (A), not crash.
|
||||
std::fs::remove_file(&b).unwrap();
|
||||
|
||||
let mut dst = editor(&dir);
|
||||
restore(&mut dst);
|
||||
let ls = leaves(&dst);
|
||||
assert_eq!(ls.len(), 1, "only A survives");
|
||||
assert_eq!(ls[0].0, a);
|
||||
// Active window is a real, surviving window.
|
||||
let core = dst.core.borrow();
|
||||
let active = core.views[&FrontendId::LOCAL].active;
|
||||
assert!(core.windows.contains_key(&active), "active window survives");
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_leaves_no_orphan_windows() {
|
||||
let dir = fresh_state_dir();
|
||||
let src = editor(&dir);
|
||||
build_two_pane(&src, &dir, [1, 1], 0, 0);
|
||||
assert!(save(&src));
|
||||
|
||||
let mut dst = editor(&dir);
|
||||
// dst starts with 1 scratch window; after restore only the rebuilt
|
||||
// leaves should remain in core.windows.
|
||||
restore(&mut dst);
|
||||
let core = dst.core.borrow();
|
||||
let leaf_ids: std::collections::HashSet<_> = core.views[&FrontendId::LOCAL]
|
||||
.layout
|
||||
.iter_ids()
|
||||
.into_iter()
|
||||
.collect();
|
||||
assert_eq!(
|
||||
core.windows.len(),
|
||||
leaf_ids.len(),
|
||||
"no orphan windows linger in core.windows"
|
||||
);
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_keys_scope_by_name_vs_cwd() {
|
||||
let dir = fresh_state_dir();
|
||||
// Save under an instance name.
|
||||
let src_named = editor(&dir);
|
||||
src_named.lua_host.set_instance_name(Some("work".into()));
|
||||
let a = write_file(&dir, "a.txt", "aaaa\n");
|
||||
exec(&src_named, &format!("pmacs.buffer.find_or_open({a:?})"));
|
||||
assert!(save(&src_named));
|
||||
|
||||
// A cwd-keyed (nameless) editor must NOT see the named desktop.
|
||||
let mut dst_cwd = editor(&dir);
|
||||
dst_cwd.lua_host.set_instance_name(None);
|
||||
restore(&mut dst_cwd);
|
||||
assert!(
|
||||
leaves(&dst_cwd).iter().all(|(p, _, _)| p != &a),
|
||||
"cwd session does not restore the name session's desktop"
|
||||
);
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modified_buffer_warns_on_restore() {
|
||||
let dir = fresh_state_dir();
|
||||
let src = editor(&dir);
|
||||
let a = write_file(&dir, "a.txt", "aaaa\nbbbb\n");
|
||||
exec(&src, &format!("pmacs.buffer.find_or_open({a:?})"));
|
||||
// Dirty the buffer (an edit) so it saves as modified.
|
||||
exec(&src, "pmacs.window.buffer():insert(0, 'x')");
|
||||
assert!(save(&src));
|
||||
|
||||
let mut dst = editor(&dir);
|
||||
restore(&mut dst);
|
||||
let status = dst.core.borrow().status.clone();
|
||||
assert!(
|
||||
status.contains("unsaved changes"),
|
||||
"restore warns about modified buffers: {status:?}"
|
||||
);
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_gate_respects_file_arg_and_arming() {
|
||||
let dir = fresh_state_dir();
|
||||
let src = editor(&dir);
|
||||
let a = write_file(&dir, "a.txt", "aaaa\n");
|
||||
exec(&src, &format!("pmacs.buffer.find_or_open({a:?})"));
|
||||
assert!(save(&src));
|
||||
|
||||
// Not armed → no restore even with no file arg.
|
||||
let mut d1 = editor(&dir);
|
||||
d1.restore_desktop_if_armed(false);
|
||||
assert!(
|
||||
leaves(&d1).iter().all(|(p, _, _)| p != &a),
|
||||
"unarmed: no restore"
|
||||
);
|
||||
|
||||
// Armed + a file arg → still no restore (Q#DS7).
|
||||
let mut d2 = editor(&dir);
|
||||
exec(&d2, "pmacs.session.arm_restore()");
|
||||
d2.restore_desktop_if_armed(true);
|
||||
assert!(
|
||||
leaves(&d2).iter().all(|(p, _, _)| p != &a),
|
||||
"armed + file arg: no restore"
|
||||
);
|
||||
|
||||
// Armed + no file arg → restore.
|
||||
let mut d3 = editor(&dir);
|
||||
exec(&d3, "pmacs.session.arm_restore()");
|
||||
d3.restore_desktop_if_armed(false);
|
||||
assert!(
|
||||
leaves(&d3).iter().any(|(p, _, _)| p == &a),
|
||||
"armed + no file: restores"
|
||||
);
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
Loading…
Reference in New Issue