//! 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_with_roots(&crate::iso::roots()); 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_and_fires_per_pane() { let dir = fresh_state_dir(); let src = editor(&dir); let a = write_file(&dir, "a.txt", "aaaa\nbbbb\ncccc\ndddd\n"); // Two panes both showing A, different cursors. exec(&src, &format!("pmacs.buffer.find_or_open({a:?})")); exec(&src, "pmacs.window.split_vertical()"); { let mut core = src.core.borrow_mut(); let ids: Vec = core.views[&FrontendId::LOCAL].layout.iter_ids(); core.windows.get_mut(&ids[0]).unwrap().cursor = 2; core.windows.get_mut(&ids[1]).unwrap().cursor = 15; } assert!(save(&src)); let mut dst = editor(&dir); // after-load must fire once PER PANE (not once per buffer) so each // window gets its own per-window overlay (syntax attaches to the // active window; LSP attach is idempotent). exec( &dst, "_G.fires = 0; pmacs.hook.add('buffer.after-load', function() _G.fires = _G.fires + 1 end)", ); restore(&mut dst); let fires: i64 = dst.lua_host.lua().load("return _G.fires").eval().unwrap(); assert_eq!( fires, 2, "after-load fires once per pane for the same buffer" ); let ls = leaves(&dst); assert_eq!(ls.len(), 2); assert_eq!(ls[0], (a.clone(), 2, 0)); assert_eq!(ls[1], (a, 15, 0)); std::fs::remove_dir_all(&dir).ok(); } #[test] fn daemon_mode_disables_save_and_restore() { let dir = fresh_state_dir(); // Seed a desktop from a normal (non-daemon) editor. let src = editor(&dir); let a = write_file(&dir, "a.txt", "aaaa\n"); exec(&src, &format!("pmacs.buffer.find_or_open({a:?})")); assert!(save(&src)); // A daemon editor must not save or restore (local-only, Q#DS9) — // the Rust gate holds regardless of what init did. let mut daemon = editor(&dir); daemon .lua_host .lua() .set_app_data(pmacs::lua_bindings::DaemonMode); assert!(!save(&daemon), "daemon save is a no-op"); restore(&mut daemon); assert!( leaves(&daemon).iter().all(|(p, _, _)| p != &a), "daemon restore is a no-op" ); std::fs::remove_dir_all(&dir).ok(); } #[test] fn disabling_desktop_mode_unarms_restore() { let dir = fresh_state_dir(); let src = editor(&dir); let a = write_file(&dir, "a.txt", "aaaa\n"); exec(&src, &format!("pmacs.buffer.find_or_open({a:?})")); assert!(save(&src)); // Enable then disable desktop_mode → the startup restore must NOT // fire (arming is a boolean; disable unarms). let mut dst = editor(&dir); exec( &dst, "pmacs.session.desktop_mode(true); pmacs.session.desktop_mode(false)", ); dst.restore_desktop_if_armed(false); assert!( leaves(&dst).iter().all(|(p, _, _)| p != &a), "disabled desktop_mode leaves restore unarmed" ); std::fs::remove_dir_all(&dir).ok(); } #[test] fn missing_file_collapses_and_focus_falls_back() { let dir = fresh_state_dir(); let src = editor(&dir); let (a, b) = build_two_pane(&src, &dir, [1, 1], 0, 0); // The right pane (B) was focused at save (focus_next moved there). assert!(save(&src)); // Delete B before restore → its leaf collapses; focus must fall // back to a surviving leaf (A), not crash. std::fs::remove_file(&b).unwrap(); let mut dst = editor(&dir); restore(&mut dst); let ls = leaves(&dst); assert_eq!(ls.len(), 1, "only A survives"); assert_eq!(ls[0].0, a); // Active window is a real, surviving window. let core = dst.core.borrow(); let active = core.views[&FrontendId::LOCAL].active; assert!(core.windows.contains_key(&active), "active window survives"); std::fs::remove_dir_all(&dir).ok(); } #[test] fn restore_leaves_no_orphan_windows() { let dir = fresh_state_dir(); let src = editor(&dir); build_two_pane(&src, &dir, [1, 1], 0, 0); assert!(save(&src)); let mut dst = editor(&dir); // dst starts with 1 scratch window; after restore only the rebuilt // leaves should remain in core.windows. restore(&mut dst); let core = dst.core.borrow(); let leaf_ids: std::collections::HashSet<_> = core.views[&FrontendId::LOCAL] .layout .iter_ids() .into_iter() .collect(); assert_eq!( core.windows.len(), leaf_ids.len(), "no orphan windows linger in core.windows" ); std::fs::remove_dir_all(&dir).ok(); } #[test] fn session_keys_scope_by_name_vs_cwd() { let dir = fresh_state_dir(); // Save under an instance name. let src_named = editor(&dir); src_named.lua_host.set_instance_name(Some("work".into())); let a = write_file(&dir, "a.txt", "aaaa\n"); exec(&src_named, &format!("pmacs.buffer.find_or_open({a:?})")); assert!(save(&src_named)); // A cwd-keyed (nameless) editor must NOT see the named desktop. let mut dst_cwd = editor(&dir); dst_cwd.lua_host.set_instance_name(None); restore(&mut dst_cwd); assert!( leaves(&dst_cwd).iter().all(|(p, _, _)| p != &a), "cwd session does not restore the name session's desktop" ); std::fs::remove_dir_all(&dir).ok(); } #[test] fn modified_buffer_warns_on_restore() { let dir = fresh_state_dir(); let src = editor(&dir); let a = write_file(&dir, "a.txt", "aaaa\nbbbb\n"); exec(&src, &format!("pmacs.buffer.find_or_open({a:?})")); // Dirty the buffer (an edit) so it saves as modified. exec(&src, "pmacs.window.buffer():insert(0, 'x')"); assert!(save(&src)); let mut dst = editor(&dir); restore(&mut dst); let status = dst.core.borrow().status.clone(); assert!( status.contains("unsaved changes"), "restore warns about modified buffers: {status:?}" ); std::fs::remove_dir_all(&dir).ok(); } #[test] fn startup_gate_respects_file_arg_and_arming() { let dir = fresh_state_dir(); let src = editor(&dir); let a = write_file(&dir, "a.txt", "aaaa\n"); exec(&src, &format!("pmacs.buffer.find_or_open({a:?})")); assert!(save(&src)); // Not armed → no restore even with no file arg. let mut d1 = editor(&dir); d1.restore_desktop_if_armed(false); assert!( leaves(&d1).iter().all(|(p, _, _)| p != &a), "unarmed: no restore" ); // Armed + a file arg → still no restore (Q#DS7). let mut d2 = editor(&dir); exec(&d2, "pmacs.session.arm_restore()"); d2.restore_desktop_if_armed(true); assert!( leaves(&d2).iter().all(|(p, _, _)| p != &a), "armed + file arg: no restore" ); // Armed + no file arg → restore. let mut d3 = editor(&dir); exec(&d3, "pmacs.session.arm_restore()"); d3.restore_desktop_if_armed(false); assert!( leaves(&d3).iter().any(|(p, _, _)| p == &a), "armed + no file: restores" ); std::fs::remove_dir_all(&dir).ok(); } // Isolated bootstrap storage roots (see the module docs): an // integration test is compiled without `cfg(test)`, so a raw // `EditorState::new()` would read the developer's real `init.lua` and // write into their real data root. #[path = "common/iso.rs"] mod iso;