pmacs/src/desktop.rs

635 lines
23 KiB
Rust

// desktop.rs --- session desktop-save (Arc 3 phase 2).
//! Serialize the open file buffers + window layout + per-window
//! positions so a session survives a restart. Emacs `desktop.el`,
//! opt-in via `pmacs.session.desktop_mode(true)`.
//!
//! This module owns the whole feature: the serde mirror types (the core
//! window enums are not serde and stay that way), the SHA-256 session
//! key, the save-side snapshot, and the restore orchestration that opens
//! files, prunes/rebuilds windows, and fires `buffer.after-load`.
//! [`save_session`] / [`restore_session`] take the `&Lua` that carries
//! the editor's `SharedCore` / `StateDir` / `LocalInstanceInfo`
//! app-data, so they run identically from a `pmacs.session.*` binding
//! and from the `RunLocal` startup trigger.
//!
//! Framing: docs/desktop-save-framing.md.
use std::collections::HashMap;
use std::path::Path;
use mlua::Lua;
use serde::{Deserialize, Serialize};
use crate::buffer::BufferId;
use crate::editor_core::EditorCore;
use crate::hash::sha256_hex;
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}")
}
/// Build the serializable layout tree from a core [`LayoutNode`],
/// resolving each leaf window to a [`SavedLeaf`] (returning `None` for a
/// non-file leaf, which is dropped and its split collapsed).
///
/// Returns the mirror node plus the **surviving leaf window-ids in
/// preorder** — the caller uses that list to compute `active_leaf`
/// (with the Q#DS10 fallback). Returns `None` when nothing survives.
pub fn build_saved_node(
node: &LayoutNode,
resolve: &impl Fn(WindowId) -> Option<SavedLeaf>,
surviving: &mut Vec<WindowId>,
) -> Option<SavedNode> {
match node {
LayoutNode::Leaf(id) => resolve(*id).map(|leaf| {
surviving.push(*id);
SavedNode::Leaf(leaf)
}),
LayoutNode::Split {
orientation,
weights,
children,
} => {
let mut kept: Vec<(SavedNode, u32)> = Vec::new();
for (i, child) in children.iter().enumerate() {
if let Some(saved) = build_saved_node(child, resolve, surviving) {
let w = weights.get(i).copied().unwrap_or(1).max(1);
kept.push((saved, w));
}
}
match kept.len() {
0 => None,
// A split with a single surviving child collapses to
// that child (the sibling that carried the other pane is
// gone), so the tree never has a one-child split.
1 => Some(kept.into_iter().next().unwrap().0),
_ => {
let (nodes, weights): (Vec<_>, Vec<_>) = kept.into_iter().unzip();
Some(SavedNode::Split {
orientation: (*orientation).into(),
weights,
children: nodes,
})
}
}
}
}
}
/// Given the full preorder leaf list (before dropping) with a survived
/// flag, and the focused window, return the `active_leaf` index into
/// the *surviving* sequence — the focused leaf if it survived, else the
/// nearest surviving preorder neighbor (later preferred), else 0
/// (Q#DS10). `surviving_ids` is the preorder list of leaves that
/// survived, in the same order they appear in `full`.
#[must_use]
pub fn resolve_active_leaf(
full: &[(WindowId, bool)],
surviving_ids: &[WindowId],
focused: WindowId,
) -> usize {
// Direct hit: focused survived.
if let Some(i) = surviving_ids.iter().position(|&id| id == focused) {
return i;
}
// Focused was dropped: find its position in the full preorder list,
// then the nearest survivor (scan right, then left).
let Some(fpos) = full.iter().position(|&(id, _)| id == focused) else {
return 0;
};
let neighbor = full
.iter()
.enumerate()
.filter(|(_, (_, survived))| *survived)
.min_by_key(|(pos, _)| {
// Prefer later leaves on ties: right distance rounds down.
let dist = pos.abs_diff(fpos);
(dist, i32::from(*pos < fpos))
})
.map(|(_, (id, _))| *id);
neighbor
.and_then(|id| surviving_ids.iter().position(|&s| s == id))
.unwrap_or(0)
}
// ---------------------------------------------------------------------------
// Save / restore orchestration (driven from `pmacs.session.*` and the
// RunLocal startup trigger; both hand us the `&Lua` that carries the
// SharedCore / StateDir / LocalInstanceInfo app-data).
// ---------------------------------------------------------------------------
/// True when this process is a daemon (multi-frontend). Desktop
/// save/restore is local-only in v1 (Q#DS9); this is the **reliable**
/// enforcement — the `DaemonMode` marker is set right after the daemon's
/// `EditorState::new()`, so it is present for every save/restore that
/// can run after startup (the before-quit hook, manual commands, direct
/// binding calls), even though `init.lua` runs before it is set. Both
/// `save_session` and `restore_session` no-op when it holds.
fn is_daemon(lua: &Lua) -> bool {
lua.app_data_ref::<crate::lua_bindings::DaemonMode>()
.is_some()
}
/// The desktop session key for this process (`cwd.<hash>` in local mode).
fn session_key_from_lua(lua: &Lua) -> String {
match lua
.app_data_ref::<LocalInstanceInfo>()
.map(|i| i.build_identity())
{
Some(id) => session_key(id.instance_name.as_deref(), &id.working_directory),
None => session_key(None, ""),
}
}
/// Snapshot the LOCAL frontend as a [`SavedDesktop`] (Q#DS2). `None`
/// when no file window survives (nothing worth saving).
#[must_use]
pub fn snapshot(core: &EditorCore, session_key: String) -> Option<SavedDesktop> {
let view = core.views.get(&FrontendId::LOCAL)?;
let focused = view.active;
let reg = core.registry.borrow();
let resolve = |wid: WindowId| -> Option<SavedLeaf> {
let win = core.windows.get(&wid)?;
let path = reg.get(win.buffer_id).ok()?.file_path()?;
Some(SavedLeaf {
path: path.display().to_string(),
cursor: win.cursor,
view_top: win.view_top,
})
};
let mut surviving = Vec::new();
let root = build_saved_node(&view.layout.root, &resolve, &mut surviving)?;
let full: Vec<(WindowId, bool)> = view
.layout
.iter_ids()
.into_iter()
.map(|id| (id, surviving.contains(&id)))
.collect();
let active_leaf = resolve_active_leaf(&full, &surviving, focused);
let buffers = reg
.ids()
.iter()
.filter_map(|&id| {
let b = reg.get(id).ok()?;
let path = b.file_path()?;
Some(SavedBuffer {
path: path.display().to_string(),
modified: b.is_modified(),
})
})
.collect();
Some(SavedDesktop {
version: DESKTOP_VERSION,
session_key,
buffers,
root,
active_leaf,
})
}
/// Serialize the current session to the desktop state file (Q#DS1).
/// `Ok(false)` when the state dir is unconfigured or nothing is worth
/// saving. Never fails hard for the before-quit path (Q#DS8) — the
/// caller may ignore the error.
///
/// # Errors
/// A state-write / serialization failure (surfaced for manual save).
pub fn save_session(lua: &Lua) -> Result<bool, String> {
if is_daemon(lua) {
return Ok(false); // local-only in v1 (Q#DS9)
}
let Some(base) = lua.app_data_ref::<StateDir>().map(|d| d.0.clone()) else {
return Ok(false);
};
let core = lua
.app_data_ref::<SharedCore>()
.ok_or("no editor core")?
.clone();
let key = session_key_from_lua(lua);
let snap = snapshot(&core.borrow(), key);
let Some(snap) = snap else {
return Ok(false);
};
let json = serde_json::to_string(&snap).map_err(|e| e.to_string())?;
let state_key = desktop_state_key(&snap.session_key);
crate::state::write(&base, &state_key, json.as_bytes()).map_err(|e| e.to_string())?;
Ok(true)
}
/// Rebuild the LOCAL session from the desktop state file (Q#DS3). A
/// no-op when no desktop is saved for this key / version / session
/// mismatches. Fires `buffer.after-load` via `lua` with each restored
/// leaf active.
///
/// # Errors
/// Parse / state-read failures; a missing individual file collapses its
/// leaf rather than failing the whole restore.
pub fn restore_session(lua: &Lua) -> Result<(), String> {
if is_daemon(lua) {
return Ok(()); // local-only in v1 (Q#DS9)
}
let key = session_key_from_lua(lua);
let Some(base) = lua.app_data_ref::<StateDir>().map(|d| d.0.clone()) else {
return Ok(());
};
let state_key = desktop_state_key(&key);
let Some(json) = crate::state::read(&base, &state_key).map_err(|e| e.to_string())? else {
return Ok(()); // no desktop saved
};
let saved: SavedDesktop = serde_json::from_str(&json).map_err(|e| e.to_string())?;
if saved.version != DESKTOP_VERSION || saved.session_key != key {
return Ok(()); // unrecognized / wrong session
}
let core = lua
.app_data_ref::<SharedCore>()
.ok_or("no editor core")?
.clone();
let modified = restore_into(&core, &saved, || fire_after_load_hook(lua));
if modified > 0 {
core.borrow_mut().status =
format!("desktop restored; {modified} buffer(s) had unsaved changes when saved");
}
Ok(())
}
/// One restored leaf window, carried from the tree build to the
/// activate-then-fire pass (Q#DS3).
struct RestoreLeaf {
window: WindowId,
cursor: u64,
view_top: usize,
}
/// Do the structural rebuild: open buffers, prune the old LOCAL layout,
/// build the new tree + windows, install the view, then fire
/// `buffer.after-load` (via `fire_after_load`) with each newly-loaded
/// leaf active and apply the exact per-leaf `cursor`/`view_top` afterward
/// (desktop wins over saveplace, Q#DS3). Returns the count of buffers
/// that were modified at save time (for the Q#DS6 warning).
///
/// Takes `&SharedCore` (not a borrow) so it can release the core borrow
/// around each hook fire — `buffer.after-load` re-enters `pmacs.editor.*`
/// which re-borrows the core.
pub fn restore_into(
core: &SharedCore,
saved: &SavedDesktop,
mut fire_after_load: impl FnMut(),
) -> usize {
// (2) Open every buffer up front (hidden ones survive), keyed by the
// raw saved path. A missing file is absent → its leaves collapse.
let mut modified_count = 0usize;
let mut opened: HashMap<String, (BufferId, bool)> = HashMap::new();
for sb in &saved.buffers {
if sb.modified {
modified_count += 1;
}
if let Ok(res) = core.borrow_mut().get_or_load_buffer(Path::new(&sb.path)) {
opened.insert(sb.path.clone(), res);
}
}
// (3-5) Build + prune + install the new LOCAL view.
let mut leaves: Vec<RestoreLeaf> = Vec::new();
let mut save_slots: Vec<Option<WindowId>> = Vec::new();
let active_wid = {
let mut c = core.borrow_mut();
let old_ids = c.views.get(&FrontendId::LOCAL).map(|v| v.layout.iter_ids());
let Some(root) =
build_restore_node(&mut c, &saved.root, &opened, &mut leaves, &mut save_slots)
else {
return modified_count; // nothing survived → keep current session
};
// Prune every window of the old LOCAL layout (not just scratch)
// so none linger orphaned in `core.windows`.
if let Some(old_ids) = old_ids {
for id in old_ids {
c.windows.remove(&id);
}
}
let active = pick_active_window(&save_slots, saved.active_leaf)
.or_else(|| leaves.first().map(|l| l.window));
let Some(active) = active else {
return modified_count;
};
c.active_frontend = FrontendId::LOCAL;
c.views.insert(
FrontendId::LOCAL,
FrontendView {
layout: Layout { root },
active,
},
);
active
};
// (5) activate-then-fire, once **per leaf** (per window). after-load
// must observe the restored leaf as active (saveplace/recentf/syntax/
// LSP read active state). Firing per leaf — not per buffer — gives
// each pane its own per-window overlay (syntax attaches to the active
// window), while LSP's `attach_buffer` is idempotent, so the same
// file in two panes still attaches LSP once but syntax to both.
// Writing the exact per-leaf cursor/view_top *after* the hook lets
// desktop win over saveplace.
//
// NOTE (Q#DS3, hidden buffers): a restored buffer with NO leaf (open
// but hidden) is loaded into the registry but does not fire
// after-load here — it attaches syntax on first visit (after-switch)
// and LSP when it is next shown/opened. Registry-only in v1.
for leaf in &leaves {
core.borrow_mut().set_active_window_id(leaf.window);
fire_after_load();
let mut c = core.borrow_mut();
if let Some(win) = c.windows.get_mut(&leaf.window) {
win.cursor = leaf.cursor;
win.view_top = leaf.view_top;
}
}
core.borrow_mut().set_active_window_id(active_wid);
modified_count
}
/// Recursively rebuild a [`LayoutNode`] from a [`SavedNode`], creating a
/// `Window` in `core.windows` per surviving leaf. A leaf whose file
/// failed to open (absent from `opened`) is dropped and its split
/// collapsed — mirroring the save-side collapse.
fn build_restore_node(
core: &mut EditorCore,
node: &SavedNode,
opened: &HashMap<String, (BufferId, bool)>,
leaves: &mut Vec<RestoreLeaf>,
save_slots: &mut Vec<Option<WindowId>>,
) -> Option<LayoutNode> {
match node {
SavedNode::Leaf(leaf) => {
let Some(&(buffer_id, _newly)) = opened.get(&leaf.path) else {
save_slots.push(None); // file missing → leaf collapses
return None;
};
let text_view = {
let reg = core.registry.borrow();
TextView::new(reg.get(buffer_id).ok()?)
};
let buf_len = core
.registry
.borrow()
.get(buffer_id)
.map_or(0, crate::buffer::Buffer::len);
let cursor = leaf.cursor.min(buf_len);
let view_top = leaf.view_top.min(text_view.line_count().saturating_sub(1));
let wid = WindowId::next();
let mut win = Window::new(wid, buffer_id, text_view);
win.cursor = cursor;
win.view_top = view_top;
core.windows.insert(wid, win);
leaves.push(RestoreLeaf {
window: wid,
cursor,
view_top,
});
save_slots.push(Some(wid));
Some(LayoutNode::Leaf(wid))
}
SavedNode::Split {
orientation,
weights,
children,
} => {
let mut kept: Vec<(LayoutNode, u32)> = Vec::new();
for (i, child) in children.iter().enumerate() {
if let Some(n) = build_restore_node(core, child, opened, leaves, save_slots) {
kept.push((n, weights.get(i).copied().unwrap_or(1).max(1)));
}
}
match kept.len() {
0 => None,
1 => Some(kept.into_iter().next().unwrap().0),
_ => {
let (nodes, ws): (Vec<_>, Vec<_>) = kept.into_iter().unzip();
Some(LayoutNode::Split {
orientation: (*orientation).into(),
weights: ws,
children: nodes,
})
}
}
}
}
}
/// Resolve the focused window from the save-time `active_leaf` index
/// against the restore survivors: direct hit, else nearest surviving
/// preorder neighbor (later preferred) — Q#DS10.
fn pick_active_window(slots: &[Option<WindowId>], want: usize) -> Option<WindowId> {
if let Some(Some(w)) = slots.get(want) {
return Some(*w);
}
(0..slots.len())
.filter(|&i| slots[i].is_some())
.min_by_key(|&i| (i.abs_diff(want), usize::from(i < want)))
.and_then(|i| slots[i])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn session_key_distinguishes_name_and_cwd() {
let by_name = session_key(Some("work"), "/home/u/proj");
let by_cwd = session_key(None, "/home/u/proj");
assert!(by_name.starts_with("name."));
assert!(by_cwd.starts_with("cwd."));
assert_ne!(by_name, by_cwd);
// Deterministic + charset-safe (state key validates it).
assert_eq!(by_name, session_key(Some("work"), "/elsewhere"));
assert!(crate::state::validate_name(&desktop_state_key(&by_name)).is_ok());
assert!(crate::state::validate_name(&desktop_state_key(&by_cwd)).is_ok());
}
#[test]
fn saved_desktop_json_round_trips() {
let d = SavedDesktop {
version: DESKTOP_VERSION,
session_key: "cwd.abc".into(),
buffers: vec![SavedBuffer {
path: "/a.rs".into(),
modified: true,
}],
root: SavedNode::Split {
orientation: SavedOrientation::Vertical,
weights: vec![2, 1],
children: vec![
SavedNode::Leaf(SavedLeaf {
path: "/a.rs".into(),
cursor: 10,
view_top: 2,
}),
SavedNode::Leaf(SavedLeaf {
path: "/b.rs".into(),
cursor: 0,
view_top: 0,
}),
],
},
active_leaf: 1,
};
let json = serde_json::to_string(&d).unwrap();
assert_eq!(serde_json::from_str::<SavedDesktop>(&json).unwrap(), d);
}
fn leaf(path: &str) -> SavedLeaf {
SavedLeaf {
path: path.into(),
cursor: 0,
view_top: 0,
}
}
#[test]
fn split_with_one_survivor_collapses() {
// A 2-way split where only the first child is a file leaf →
// collapses to that leaf, no one-child split.
let (a, b) = (WindowId::next(), WindowId::next());
let node = LayoutNode::Split {
orientation: Orientation::Vertical,
weights: vec![1, 1],
children: vec![LayoutNode::Leaf(a), LayoutNode::Leaf(b)],
};
let resolve = |id: WindowId| (id == a).then(|| leaf("/a.rs"));
let mut surviving = Vec::new();
let saved = build_saved_node(&node, &resolve, &mut surviving).unwrap();
assert_eq!(saved, SavedNode::Leaf(leaf("/a.rs")));
assert_eq!(surviving, vec![a]);
}
#[test]
fn active_leaf_falls_back_to_neighbor() {
let (a, b, c) = (WindowId::next(), WindowId::next(), WindowId::next());
// b was focused but dropped; survivors are [a, c] in preorder.
let full = vec![(a, true), (b, false), (c, true)];
let surviving = vec![a, c];
// Nearest neighbor to b (pos 1) preferring later → c (index 1).
assert_eq!(resolve_active_leaf(&full, &surviving, b), 1);
// Direct hit still works.
assert_eq!(resolve_active_leaf(&full, &surviving, a), 0);
}
}