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