aboutsummaryrefslogtreecommitdiff
path: root/src/persist.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/persist.rs')
-rw-r--r--src/persist.rs446
1 files changed, 446 insertions, 0 deletions
diff --git a/src/persist.rs b/src/persist.rs
new file mode 100644
index 00000000..31a37576
--- /dev/null
+++ b/src/persist.rs
@@ -0,0 +1,446 @@
+//! Session persistence — save/restore workspaces, layouts, and working directories.
+//!
+//! Stored at `~/.config/herdr/session.json`.
+
+use std::collections::HashMap;
+use std::path::PathBuf;
+
+use ratatui::layout::Direction;
+use serde::{Deserialize, Serialize};
+use tracing::{error, info, warn};
+
+use tokio::sync::mpsc;
+
+use crate::events::AppEvent;
+use crate::layout::{Node, PaneId, TileLayout};
+use crate::pane::{PaneRuntime, PaneState};
+use crate::workspace::Workspace;
+
+/// Current snapshot format version.
+const SNAPSHOT_VERSION: u32 = 1;
+
+/// Serializable snapshot of the entire herdr session.
+#[derive(Serialize, Deserialize)]
+pub struct SessionSnapshot {
+ /// Format version — used to detect incompatible changes.
+ #[serde(default)]
+ pub version: u32,
+ pub workspaces: Vec<WorkspaceSnapshot>,
+ pub active: Option<usize>,
+ pub selected: usize,
+}
+
+#[derive(Serialize, Deserialize)]
+pub struct WorkspaceSnapshot {
+ pub name: String,
+ pub layout: LayoutSnapshot,
+ pub panes: HashMap<u32, PaneSnapshot>,
+ pub zoomed: bool,
+ /// Raw pane ID that was focused when saved.
+ #[serde(default)]
+ pub focused: Option<u32>,
+}
+
+#[derive(Serialize, Deserialize)]
+pub struct PaneSnapshot {
+ pub cwd: PathBuf,
+}
+
+/// Serializable BSP tree.
+#[derive(Serialize, Deserialize)]
+pub enum LayoutSnapshot {
+ Pane(u32),
+ Split {
+ direction: DirectionSnapshot,
+ ratio: f32,
+ first: Box<LayoutSnapshot>,
+ second: Box<LayoutSnapshot>,
+ },
+}
+
+#[derive(Serialize, Deserialize)]
+pub enum DirectionSnapshot {
+ Horizontal,
+ Vertical,
+}
+
+// --- Capture ---
+
+/// Capture the current app state into a serializable snapshot.
+pub fn capture(
+ workspaces: &[Workspace],
+ active: Option<usize>,
+ selected: usize,
+) -> SessionSnapshot {
+ SessionSnapshot {
+ version: SNAPSHOT_VERSION,
+ workspaces: workspaces.iter().map(capture_workspace).collect(),
+ active,
+ selected,
+ }
+}
+
+fn capture_workspace(ws: &Workspace) -> WorkspaceSnapshot {
+ let mut panes = HashMap::new();
+ for id in ws.panes.keys() {
+ let cwd = ws.runtimes.get(id)
+ .and_then(|rt| rt.cwd())
+ .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| "/".into()));
+ panes.insert(id.raw(), PaneSnapshot { cwd });
+ }
+ WorkspaceSnapshot {
+ name: ws.name.clone(),
+ layout: capture_node(ws.layout.root()),
+ panes,
+ zoomed: ws.zoomed,
+ focused: Some(ws.layout.focused().raw()),
+ }
+}
+
+fn capture_node(node: &Node) -> LayoutSnapshot {
+ match node {
+ Node::Pane(id) => LayoutSnapshot::Pane(id.raw()),
+ Node::Split {
+ direction,
+ ratio,
+ first,
+ second,
+ } => LayoutSnapshot::Split {
+ direction: match direction {
+ Direction::Horizontal => DirectionSnapshot::Horizontal,
+ Direction::Vertical => DirectionSnapshot::Vertical,
+ },
+ ratio: *ratio,
+ first: Box::new(capture_node(first)),
+ second: Box::new(capture_node(second)),
+ },
+ }
+}
+
+// --- Restore ---
+
+/// Restore workspaces from a snapshot. Each pane gets a fresh shell in its saved cwd.
+pub fn restore(
+ snapshot: &SessionSnapshot,
+ rows: u16,
+ cols: u16,
+ events: mpsc::Sender<AppEvent>,
+) -> Vec<Workspace> {
+ snapshot
+ .workspaces
+ .iter()
+ .filter_map(|ws_snap| restore_workspace(ws_snap, rows, cols, events.clone()))
+ .collect()
+}
+
+fn restore_workspace(
+ snap: &WorkspaceSnapshot,
+ rows: u16,
+ cols: u16,
+ events: mpsc::Sender<AppEvent>,
+) -> Option<Workspace> {
+ let (node, id_map) = restore_node_remapped(&snap.layout);
+ let pane_ids = collect_pane_ids(&node);
+
+ // Restore focused pane: map saved raw ID to new ID, fall back to first pane
+ let focus = snap
+ .focused
+ .and_then(|old_raw| id_map.get(&old_raw).copied())
+ .or_else(|| pane_ids.first().copied())
+ .unwrap_or(PaneId::from_raw(0));
+ let layout = TileLayout::from_saved(node, focus);
+
+ let mut panes = HashMap::new();
+ let mut runtimes = HashMap::new();
+ for id in &pane_ids {
+ let old_id = id_map.iter().find(|(_, new)| **new == *id).map(|(old, _)| old);
+ let cwd = old_id
+ .and_then(|old| snap.panes.get(old))
+ .map(|p| p.cwd.clone())
+ .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| "/".into()));
+
+ match PaneRuntime::spawn(*id, rows, cols, cwd, events.clone()) {
+ Ok(runtime) => {
+ panes.insert(*id, PaneState::new());
+ runtimes.insert(*id, runtime);
+ }
+ Err(e) => {
+ error!(workspace = %snap.name, err = %e, "failed to restore pane");
+ return None;
+ }
+ }
+ }
+
+ Some(Workspace {
+ name: snap.name.clone(),
+ layout,
+ panes,
+ runtimes,
+ zoomed: snap.zoomed,
+ events,
+ })
+}
+
+/// Restore a layout tree, remapping every pane ID to a fresh globally unique one.
+/// Returns the new tree and a map of old_raw_id → new PaneId.
+fn restore_node_remapped(snap: &LayoutSnapshot) -> (Node, HashMap<u32, PaneId>) {
+ let mut id_map = HashMap::new();
+ let node = remap_inner(snap, &mut id_map);
+ (node, id_map)
+}
+
+fn remap_inner(snap: &LayoutSnapshot, id_map: &mut HashMap<u32, PaneId>) -> Node {
+ match snap {
+ LayoutSnapshot::Pane(old_id) => {
+ let new_id = PaneId::alloc();
+ id_map.insert(*old_id, new_id);
+ Node::Pane(new_id)
+ }
+ LayoutSnapshot::Split {
+ direction,
+ ratio,
+ first,
+ second,
+ } => {
+ let first_node = remap_inner(first, id_map);
+ let second_node = remap_inner(second, id_map);
+ let dir = match direction {
+ DirectionSnapshot::Horizontal => Direction::Horizontal,
+ DirectionSnapshot::Vertical => Direction::Vertical,
+ };
+ Node::Split {
+ direction: dir,
+ ratio: *ratio,
+ first: Box::new(first_node),
+ second: Box::new(second_node),
+ }
+ }
+ }
+}
+
+fn collect_pane_ids(node: &Node) -> Vec<PaneId> {
+ let mut ids = Vec::new();
+ collect_ids_inner(node, &mut ids);
+ ids
+}
+
+fn collect_ids_inner(node: &Node, ids: &mut Vec<PaneId>) {
+ match node {
+ Node::Pane(id) => ids.push(*id),
+ Node::Split { first, second, .. } => {
+ collect_ids_inner(first, ids);
+ collect_ids_inner(second, ids);
+ }
+ }
+}
+
+// --- File I/O ---
+
+fn session_path() -> PathBuf {
+ if let Ok(dir) = std::env::var("XDG_CONFIG_HOME") {
+ PathBuf::from(dir).join("herdr/session.json")
+ } else if let Ok(home) = std::env::var("HOME") {
+ PathBuf::from(home).join(".config/herdr/session.json")
+ } else {
+ PathBuf::from("/tmp/herdr/session.json")
+ }
+}
+
+pub fn save(snapshot: &SessionSnapshot) {
+ let path = session_path();
+ if let Some(parent) = path.parent() {
+ if let Err(e) = std::fs::create_dir_all(parent) {
+ error!(err = %e, "failed to create session directory");
+ return;
+ }
+ }
+ let json = match serde_json::to_string_pretty(snapshot) {
+ Ok(j) => j,
+ Err(e) => {
+ error!(err = %e, "failed to serialize session");
+ return;
+ }
+ };
+ // Atomic write: write to temp file, then rename
+ let tmp_path = path.with_extension("json.tmp");
+ if let Err(e) = std::fs::write(&tmp_path, &json) {
+ error!(err = %e, "failed to write session temp file");
+ return;
+ }
+ if let Err(e) = std::fs::rename(&tmp_path, &path) {
+ error!(err = %e, "failed to rename session file");
+ // Clean up temp file
+ let _ = std::fs::remove_file(&tmp_path);
+ return;
+ }
+ info!(workspaces = snapshot.workspaces.len(), "session saved");
+}
+
+pub fn load() -> Option<SessionSnapshot> {
+ let path = session_path();
+ if !path.exists() {
+ return None;
+ }
+ let content = match std::fs::read_to_string(&path) {
+ Ok(c) => c,
+ Err(e) => {
+ warn!(err = %e, "failed to read session file");
+ return None;
+ }
+ };
+ match serde_json::from_str::<SessionSnapshot>(&content) {
+ Ok(snap) => {
+ if snap.version > SNAPSHOT_VERSION {
+ warn!(
+ file_version = snap.version,
+ supported = SNAPSHOT_VERSION,
+ "session file is from a newer herdr version, ignoring"
+ );
+ return None;
+ }
+ Some(snap)
+ }
+ Err(e) => {
+ warn!(err = %e, "failed to parse session file, ignoring");
+ None
+ }
+ }
+}
+
+// --- Tests ---
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn round_trip_empty_session() {
+ let snap = SessionSnapshot {
+ version: 1,
+ workspaces: vec![],
+ active: None,
+ selected: 0,
+ };
+ let json = serde_json::to_string(&snap).unwrap();
+ let restored: SessionSnapshot = serde_json::from_str(&json).unwrap();
+ assert!(restored.workspaces.is_empty());
+ assert_eq!(restored.active, None);
+ }
+
+ #[test]
+ fn round_trip_layout_snapshot() {
+ let layout = LayoutSnapshot::Split {
+ direction: DirectionSnapshot::Horizontal,
+ ratio: 0.6,
+ first: Box::new(LayoutSnapshot::Pane(0)),
+ second: Box::new(LayoutSnapshot::Split {
+ direction: DirectionSnapshot::Vertical,
+ ratio: 0.5,
+ first: Box::new(LayoutSnapshot::Pane(1)),
+ second: Box::new(LayoutSnapshot::Pane(2)),
+ }),
+ };
+ let json = serde_json::to_string(&layout).unwrap();
+ let restored: LayoutSnapshot = serde_json::from_str(&json).unwrap();
+
+ // Verify structure
+ match restored {
+ LayoutSnapshot::Split { ratio, .. } => assert!((ratio - 0.6).abs() < 0.01),
+ _ => panic!("expected split"),
+ }
+ }
+
+ #[test]
+ fn round_trip_full_workspace_snapshot() {
+ let mut panes = HashMap::new();
+ panes.insert(
+ 0,
+ PaneSnapshot {
+ cwd: PathBuf::from("/home/can/Projects/herdr"),
+ },
+ );
+ panes.insert(
+ 1,
+ PaneSnapshot {
+ cwd: PathBuf::from("/home/can/Projects/website"),
+ },
+ );
+
+ let snap = SessionSnapshot {
+ workspaces: vec![WorkspaceSnapshot {
+ name: "pi-mono".to_string(),
+ layout: LayoutSnapshot::Split {
+ direction: DirectionSnapshot::Horizontal,
+ ratio: 0.5,
+ first: Box::new(LayoutSnapshot::Pane(0)),
+ second: Box::new(LayoutSnapshot::Pane(1)),
+ },
+ panes,
+ zoomed: false,
+ focused: Some(0),
+ }],
+ active: Some(0),
+ selected: 0,
+ version: 1,
+ };
+
+ let json = serde_json::to_string_pretty(&snap).unwrap();
+ let restored: SessionSnapshot = serde_json::from_str(&json).unwrap();
+
+ assert_eq!(restored.workspaces.len(), 1);
+ assert_eq!(restored.workspaces[0].name, "pi-mono");
+ assert_eq!(restored.workspaces[0].panes.len(), 2);
+ assert_eq!(
+ restored.workspaces[0].panes[&0].cwd,
+ PathBuf::from("/home/can/Projects/herdr")
+ );
+ }
+
+ #[test]
+ fn capture_and_restore_node_round_trip() {
+ // Create a tree: Split(H, 0.5, Pane(0), Split(V, 0.3, Pane(1), Pane(2)))
+ let node = Node::Split {
+ direction: Direction::Horizontal,
+ ratio: 0.5,
+ first: Box::new(Node::Pane(PaneId::from_raw(0))),
+ second: Box::new(Node::Split {
+ direction: Direction::Vertical,
+ ratio: 0.3,
+ first: Box::new(Node::Pane(PaneId::from_raw(1))),
+ second: Box::new(Node::Pane(PaneId::from_raw(2))),
+ }),
+ };
+
+ let snap = capture_node(&node);
+ let (restored, id_map) = restore_node_remapped(&snap);
+
+ assert_eq!(id_map.len(), 3);
+ let ids = collect_pane_ids(&restored);
+ assert_eq!(ids.len(), 3);
+ let unique: std::collections::HashSet<u32> = ids.iter().map(|id| id.raw()).collect();
+ assert_eq!(unique.len(), 3);
+ }
+
+ #[test]
+ fn old_unversioned_snapshot_loads_as_version_0() {
+ // Simulate a snapshot from before versioning was added
+ let json = r#"{"workspaces":[],"active":null,"selected":0}"#;
+ let snap: SessionSnapshot = serde_json::from_str(json).unwrap();
+ assert_eq!(snap.version, 0); // #[serde(default)] gives 0
+ }
+
+ #[test]
+ fn future_version_is_rejected() {
+ let json = r#"{"version":999,"workspaces":[],"active":null,"selected":0}"#;
+ let snap: SessionSnapshot = serde_json::from_str(json).unwrap();
+ assert!(snap.version > SNAPSHOT_VERSION);
+ // load() would reject this — tested via the version check in load()
+ }
+
+ #[test]
+ fn focused_pane_default_is_none() {
+ let json = r#"{"name":"test","layout":{"Pane":0},"panes":{},"zoomed":false}"#;
+ let ws: WorkspaceSnapshot = serde_json::from_str(json).unwrap();
+ assert_eq!(ws.focused, None); // #[serde(default)]
+ }
+}