use std::collections::HashMap; use std::path::PathBuf; use ratatui::layout::Direction; use serde::{Deserialize, Serialize}; use crate::layout::Node; use crate::workspace::Workspace; /// Current snapshot format version. pub(super) const SNAPSHOT_VERSION: u32 = 3; /// 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, pub active: Option, pub selected: usize, #[serde(default)] pub agent_panel_scope: crate::app::state::AgentPanelScope, #[serde(default)] pub sidebar_width: Option, #[serde(default)] pub sidebar_section_split: Option, } #[derive(Serialize, Deserialize)] pub struct WorkspaceSnapshot { #[serde(default)] pub id: Option, #[serde(default)] pub custom_name: Option, pub identity_cwd: PathBuf, pub tabs: Vec, #[serde(default)] pub active_tab: usize, } #[derive(Deserialize)] struct LegacyWorkspaceSnapshot { #[serde(default)] custom_name: Option, layout: LayoutSnapshot, panes: HashMap, zoomed: bool, #[serde(default)] focused: Option, #[serde(default)] root_pane: Option, } #[derive(Serialize, Deserialize)] pub struct TabSnapshot { #[serde(default)] pub custom_name: Option, pub layout: LayoutSnapshot, pub panes: HashMap, pub zoomed: bool, #[serde(default)] pub focused: Option, #[serde(default)] pub root_pane: Option, } #[derive(Serialize, Deserialize)] pub struct PaneSnapshot { pub cwd: PathBuf, #[serde(default, skip_serializing_if = "Option::is_none")] pub label: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub agent_name: Option, } /// Serializable BSP tree. #[derive(Serialize, Deserialize)] pub enum LayoutSnapshot { Pane(u32), Split { direction: DirectionSnapshot, ratio: f32, first: Box, second: Box, }, } #[derive(Serialize, Deserialize)] pub enum DirectionSnapshot { Horizontal, Vertical, } impl From for WorkspaceSnapshot { fn from(snap: LegacyWorkspaceSnapshot) -> Self { let identity_cwd = legacy_identity_cwd(&snap); let tab = TabSnapshot { custom_name: None, layout: snap.layout, panes: snap.panes, zoomed: snap.zoomed, focused: snap.focused, root_pane: snap.root_pane, }; Self { id: None, custom_name: snap.custom_name, identity_cwd, tabs: vec![tab], active_tab: 0, } } } #[derive(Deserialize)] struct RawSessionSnapshot { #[serde(default)] version: u32, #[serde(default)] workspaces: Vec, #[serde(default)] active: Option, #[serde(default)] selected: usize, #[serde(default)] agent_panel_scope: crate::app::state::AgentPanelScope, #[serde(default)] sidebar_width: Option, #[serde(default)] sidebar_section_split: Option, } fn migrate_snapshot(raw: RawSessionSnapshot) -> Result { Ok(SessionSnapshot { version: raw.version, workspaces: raw .workspaces .into_iter() .map(migrate_workspace) .collect::, _>>()?, active: raw.active, selected: raw.selected, agent_panel_scope: raw.agent_panel_scope, sidebar_width: raw.sidebar_width, sidebar_section_split: raw.sidebar_section_split, }) } fn migrate_workspace(raw: serde_json::Value) -> Result { if raw.get("identity_cwd").is_some() { return serde_json::from_value(raw).map_err(|e| e.to_string()); } if raw.get("layout").is_some() { let legacy = serde_json::from_value::(raw).map_err(|e| e.to_string())?; return Ok(legacy.into()); } Err("workspace snapshot is neither current nor legacy format".to_string()) } fn legacy_identity_cwd(snap: &LegacyWorkspaceSnapshot) -> PathBuf { let root_pane = snap .root_pane .or_else(|| first_pane_id_in_layout(&snap.layout)); root_pane .and_then(|pane_id| snap.panes.get(&pane_id)) .map(|pane| pane.cwd.clone()) .or_else(|| { first_pane_id_in_layout(&snap.layout) .and_then(|pane_id| snap.panes.get(&pane_id)) .map(|pane| pane.cwd.clone()) }) .or_else(|| { snap.panes .keys() .min() .and_then(|pane_id| snap.panes.get(pane_id)) .map(|pane| pane.cwd.clone()) }) .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| "/".into())) } fn first_pane_id_in_layout(layout: &LayoutSnapshot) -> Option { match layout { LayoutSnapshot::Pane(id) => Some(*id), LayoutSnapshot::Split { first, second, .. } => { first_pane_id_in_layout(first).or_else(|| first_pane_id_in_layout(second)) } } } /// Capture the current app state into a serializable snapshot. pub fn capture( workspaces: &[Workspace], terminals: &std::collections::HashMap< crate::terminal::TerminalId, crate::terminal::TerminalState, >, terminal_runtimes: &std::collections::HashMap< crate::terminal::TerminalId, crate::terminal::TerminalRuntime, >, active: Option, selected: usize, agent_panel_scope: crate::app::state::AgentPanelScope, sidebar_width: u16, sidebar_section_split: f32, ) -> SessionSnapshot { SessionSnapshot { version: SNAPSHOT_VERSION, workspaces: workspaces .iter() .map(|workspace| capture_workspace(workspace, terminals, terminal_runtimes)) .collect(), active, selected, agent_panel_scope, sidebar_width: Some(sidebar_width), sidebar_section_split: Some(sidebar_section_split), } } fn capture_workspace( ws: &Workspace, terminals: &std::collections::HashMap< crate::terminal::TerminalId, crate::terminal::TerminalState, >, terminal_runtimes: &std::collections::HashMap< crate::terminal::TerminalId, crate::terminal::TerminalRuntime, >, ) -> WorkspaceSnapshot { WorkspaceSnapshot { id: Some(ws.id.clone()), custom_name: ws.custom_name.clone(), identity_cwd: ws .resolved_identity_cwd_from(terminals, terminal_runtimes) .unwrap_or_else(|| ws.identity_cwd.clone()), tabs: ws .tabs .iter() .map(|tab| capture_tab(tab, terminals, terminal_runtimes)) .collect(), active_tab: ws.active_tab, } } fn capture_tab( tab: &crate::workspace::Tab, terminals: &std::collections::HashMap< crate::terminal::TerminalId, crate::terminal::TerminalState, >, terminal_runtimes: &std::collections::HashMap< crate::terminal::TerminalId, crate::terminal::TerminalRuntime, >, ) -> TabSnapshot { let mut panes = HashMap::new(); for id in tab.panes.keys() { let cwd = tab .cwd_for_pane(*id, terminals, terminal_runtimes) .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| "/".into())); let label = tab .panes .get(id) .and_then(|pane| terminals.get(&pane.attached_terminal_id)) .and_then(|terminal| terminal.manual_label.clone()); let agent_name = tab .panes .get(id) .and_then(|pane| terminals.get(&pane.attached_terminal_id)) .and_then(|terminal| terminal.agent_name.clone()); panes.insert( id.raw(), PaneSnapshot { cwd, label, agent_name, }, ); } TabSnapshot { custom_name: tab.custom_name.clone(), layout: capture_node(tab.layout.root()), panes, zoomed: tab.zoomed, focused: Some(tab.layout.focused().raw()), root_pane: Some(tab.root_pane.raw()), } } pub(super) 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)), }, } } pub(super) fn parse_snapshot(content: &str) -> Result { let raw = serde_json::from_str::(content).map_err(|e| e.to_string())?; if raw.version > SNAPSHOT_VERSION { return Err(format!( "snapshot version {} is newer than supported {}", raw.version, SNAPSHOT_VERSION )); } migrate_snapshot(raw) } pub(super) fn snapshot_file_version(content: &str) -> Option { serde_json::from_str::(content) .ok() .map(|raw| raw.version) } #[cfg(test)] mod tests { use std::collections::HashMap; use std::path::PathBuf; use ratatui::layout::{Direction, Rect}; use super::*; use crate::app::{state::AgentPanelScope, AppState, Mode}; use crate::layout::NavDirection; use crate::workspace::Workspace; fn session_fixture(name: &str) -> &'static str { match name { "current-herdr" => { include_str!("../../tests/fixtures/session/current-herdr-session.json") } "current-herdr-dev" => { include_str!("../../tests/fixtures/session/current-herdr-dev-session.json") } "legacy-pre-tabs-v2" => { include_str!("../../tests/fixtures/session/legacy-pre-tabs-v2.json") } other => panic!("unknown session fixture: {other}"), } } fn state_with_workspaces(names: &[&str]) -> AppState { let mut state = AppState::test_new(); state.workspaces = names.iter().map(|name| Workspace::test_new(name)).collect(); state.ensure_test_terminals(); if !state.workspaces.is_empty() { state.active = Some(0); state.selected = 0; state.mode = Mode::Terminal; } state } fn capture_from_state(state: &AppState) -> SessionSnapshot { capture( &state.workspaces, &state.terminals, &state.terminal_runtimes, state.active, state.selected, state.agent_panel_scope, state.sidebar_width, state.sidebar_section_split, ) } fn root_split_ratio(tab: &TabSnapshot) -> Option { match &tab.layout { LayoutSnapshot::Split { ratio, .. } => Some(*ratio), LayoutSnapshot::Pane(_) => None, } } #[test] fn round_trip_empty_session() { let snap = SessionSnapshot { version: SNAPSHOT_VERSION, workspaces: vec![], active: None, selected: 0, agent_panel_scope: AgentPanelScope::CurrentWorkspace, sidebar_width: Some(26), sidebar_section_split: Some(0.5), }; let json = serde_json::to_string(&snap).unwrap(); let restored = parse_snapshot(&json).unwrap(); assert!(restored.workspaces.is_empty()); assert_eq!(restored.active, None); assert_eq!(restored.sidebar_width, Some(26)); assert_eq!(restored.sidebar_section_split, Some(0.5)); } #[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(); 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"), label: None, agent_name: None, }, ); panes.insert( 1, PaneSnapshot { cwd: PathBuf::from("/home/can/Projects/website"), label: Some("website".into()), agent_name: None, }, ); let snap = SessionSnapshot { workspaces: vec![WorkspaceSnapshot { id: Some("wproj".to_string()), custom_name: Some("pi-mono".to_string()), identity_cwd: PathBuf::from("/home/can/Projects/herdr"), tabs: vec![TabSnapshot { custom_name: Some("api".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), root_pane: Some(0), }], active_tab: 0, }], active: Some(0), selected: 0, agent_panel_scope: AgentPanelScope::CurrentWorkspace, sidebar_width: Some(26), sidebar_section_split: Some(0.5), version: SNAPSHOT_VERSION, }; let json = serde_json::to_string_pretty(&snap).unwrap(); let restored = parse_snapshot(&json).unwrap(); assert_eq!(restored.workspaces.len(), 1); assert_eq!(restored.workspaces[0].id.as_deref(), Some("wproj")); assert_eq!( restored.workspaces[0].custom_name.as_deref(), Some("pi-mono") ); assert_eq!(restored.workspaces[0].tabs.len(), 1); assert_eq!(restored.workspaces[0].tabs[0].panes.len(), 2); assert_eq!( restored.workspaces[0].tabs[0].panes[&0].cwd, PathBuf::from("/home/can/Projects/herdr") ); assert_eq!( restored.workspaces[0].tabs[0].panes[&1].label.as_deref(), Some("website") ); assert_eq!( restored.agent_panel_scope, AgentPanelScope::CurrentWorkspace ); assert_eq!(restored.sidebar_width, Some(26)); assert_eq!(restored.sidebar_section_split, Some(0.5)); } #[test] fn current_session_fixture_parses() { let snap = parse_snapshot(session_fixture("current-herdr")).unwrap(); assert_eq!(snap.version, 3); assert_eq!(snap.workspaces.len(), 2); assert_eq!(snap.active, Some(0)); assert_eq!(snap.selected, 0); assert_eq!(snap.agent_panel_scope, AgentPanelScope::AllWorkspaces); assert_eq!(snap.sidebar_width, None); assert_eq!(snap.sidebar_section_split, None); assert_eq!(snap.workspaces[0].tabs.len(), 2); assert_eq!( snap.workspaces[1].identity_cwd, PathBuf::from("/home/test/projects/project-b") ); } #[test] fn current_dev_session_fixture_parses_additive_fields() { let snap = parse_snapshot(session_fixture("current-herdr-dev")).unwrap(); assert_eq!(snap.version, 3); assert_eq!(snap.workspaces.len(), 2); assert_eq!(snap.agent_panel_scope, AgentPanelScope::CurrentWorkspace); assert_eq!(snap.sidebar_section_split, Some(0.4)); assert_eq!(snap.workspaces[0].active_tab, 1); assert_eq!(snap.workspaces[1].tabs[0].panes.len(), 2); } #[test] fn old_snapshot_defaults_agent_panel_scope() { let json = serde_json::json!({ "version": SNAPSHOT_VERSION, "workspaces": [], "active": null, "selected": 0 }) .to_string(); let restored = parse_snapshot(&json).unwrap(); assert_eq!(restored.agent_panel_scope, AgentPanelScope::AllWorkspaces); assert_eq!(restored.sidebar_width, None); assert_eq!(restored.sidebar_section_split, None); } #[test] fn legacy_workspace_snapshot_migrates_to_single_tab() { let snap = parse_snapshot(session_fixture("legacy-pre-tabs-v2")).unwrap(); let ws = &snap.workspaces[0]; assert_eq!(snap.version, 2); assert_eq!(snap.workspaces.len(), 1); assert_eq!(ws.custom_name.as_deref(), Some("legacy")); assert_eq!(ws.identity_cwd, PathBuf::from("/tmp/pion")); assert_eq!(ws.active_tab, 0); assert_eq!(ws.tabs.len(), 1); assert_eq!(ws.tabs[0].focused, Some(1)); assert_eq!(ws.tabs[0].root_pane, Some(0)); assert_eq!(ws.tabs[0].panes[&0].cwd, PathBuf::from("/tmp/pion")); assert_eq!(ws.tabs[0].panes[&1].cwd, PathBuf::from("/tmp/herdr")); } #[test] fn capture_contract_tracks_workspace_order_active_and_selected() { let mut state = state_with_workspaces(&["a", "b", "c"]); state.active = Some(1); state.selected = 2; state.move_workspace(1, 0); let snapshot = capture_from_state(&state); let ids: Vec<_> = state.workspaces.iter().map(|ws| ws.id.clone()).collect(); let captured_ids: Vec<_> = snapshot .workspaces .iter() .map(|ws| ws.id.clone().unwrap()) .collect(); assert_eq!(captured_ids, ids); assert_eq!(snapshot.active, state.active); assert_eq!(snapshot.selected, state.selected); } #[test] fn capture_contract_tracks_workspace_and_tab_names_and_active_tab() { let mut state = state_with_workspaces(&["one"]); state.workspaces[0].set_custom_name("renamed-workspace".into()); let second_tab = state.workspaces[0].test_add_tab(Some("logs")); state.workspaces[0].switch_tab(second_tab); state.workspaces[0].tabs[0].set_custom_name("main".into()); let snapshot = capture_from_state(&state); let workspace = &snapshot.workspaces[0]; assert_eq!(workspace.custom_name.as_deref(), Some("renamed-workspace")); assert_eq!(workspace.active_tab, second_tab); assert_eq!(workspace.tabs[0].custom_name.as_deref(), Some("main")); assert_eq!(workspace.tabs[1].custom_name.as_deref(), Some("logs")); } #[test] fn capture_contract_tracks_workspace_closure() { let mut state = state_with_workspaces(&["one", "two"]); state.selected = 1; state.active = Some(1); state.close_selected_workspace(); let snapshot = capture_from_state(&state); assert_eq!(snapshot.workspaces.len(), 1); assert_eq!(snapshot.workspaces[0].custom_name.as_deref(), Some("one")); assert_eq!(snapshot.active, Some(0)); assert_eq!(snapshot.selected, 0); } #[test] fn capture_contract_tracks_sidebar_state() { let mut state = state_with_workspaces(&["one"]); state.sidebar_width = 31; state.sidebar_section_split = 0.4; state.agent_panel_scope = AgentPanelScope::AllWorkspaces; let snapshot = capture_from_state(&state); assert_eq!(snapshot.sidebar_width, Some(31)); assert_eq!(snapshot.sidebar_section_split, Some(0.4)); assert_eq!(snapshot.agent_panel_scope, AgentPanelScope::AllWorkspaces); } #[test] fn capture_contract_tracks_layout_focus_zoom_and_root_pane() { let mut state = state_with_workspaces(&["one"]); let root = state.workspaces[0].tabs[0].root_pane; let second = state.workspaces[0].test_split(Direction::Horizontal); state.workspaces[0].tabs[0].layout.focus_pane(second); state.toggle_zoom(); let snapshot = capture_from_state(&state); let tab = &snapshot.workspaces[0].tabs[0]; assert!(matches!(tab.layout, LayoutSnapshot::Split { .. })); assert_eq!(tab.focused, Some(second.raw())); assert_eq!(tab.root_pane, Some(root.raw())); assert!(tab.zoomed); assert_eq!(tab.panes.len(), 2); } #[test] fn capture_contract_tracks_focus_navigation() { let mut state = state_with_workspaces(&["one"]); let root = state.workspaces[0].tabs[0].root_pane; let second = state.workspaces[0].test_split(Direction::Horizontal); crate::ui::compute_view(&mut state, Rect::new(0, 0, 106, 20)); state.navigate_pane(NavDirection::Right); let snapshot = capture_from_state(&state); assert_eq!(snapshot.workspaces[0].tabs[0].focused, Some(second.raw())); assert_ne!(snapshot.workspaces[0].tabs[0].focused, Some(root.raw())); } #[test] fn capture_contract_tracks_resize_ratio_changes() { let mut state = state_with_workspaces(&["one"]); state.workspaces[0].test_split(Direction::Horizontal); crate::ui::compute_view(&mut state, Rect::new(0, 0, 106, 20)); let before = capture_from_state(&state); state.resize_pane(NavDirection::Right); let after = capture_from_state(&state); let before_ratio = root_split_ratio(&before.workspaces[0].tabs[0]).unwrap(); let after_ratio = root_split_ratio(&after.workspaces[0].tabs[0]).unwrap(); assert_ne!(before_ratio, after_ratio); } #[test] fn capture_contract_tracks_tab_closure() { let mut state = state_with_workspaces(&["one"]); let second_tab = state.workspaces[0].test_add_tab(Some("logs")); state.switch_tab(second_tab); state.close_tab(); let snapshot = capture_from_state(&state); let workspace = &snapshot.workspaces[0]; assert_eq!(workspace.tabs.len(), 1); assert_eq!(workspace.active_tab, 0); assert!(workspace.tabs[0].custom_name.is_none()); } #[test] fn capture_contract_tracks_pane_closure() { let mut state = state_with_workspaces(&["one"]); state.workspaces[0].test_split(Direction::Horizontal); state.close_pane(); let snapshot = capture_from_state(&state); let tab = &snapshot.workspaces[0].tabs[0]; assert_eq!(tab.panes.len(), 1); assert!(matches!(tab.layout, LayoutSnapshot::Pane(_))); assert!(!tab.zoomed); } #[test] fn capture_contract_tracks_workspace_identity_and_pane_cwds() { let mut state = state_with_workspaces(&["one"]); let root = state.workspaces[0].tabs[0].root_pane; state.workspaces[0].identity_cwd = PathBuf::from("/tmp/pion"); let second = state.workspaces[0].test_split(Direction::Horizontal); state.ensure_test_terminals(); let root_terminal_id = state.workspaces[0].tabs[0].panes[&root] .attached_terminal_id .clone(); state.terminals.get_mut(&root_terminal_id).unwrap().cwd = PathBuf::from("/tmp/pion"); let second_terminal_id = state.workspaces[0].tabs[0].panes[&second] .attached_terminal_id .clone(); state.terminals.get_mut(&second_terminal_id).unwrap().cwd = PathBuf::from("/tmp/herdr"); let snapshot = capture_from_state(&state); let workspace = &snapshot.workspaces[0]; let tab = &workspace.tabs[0]; assert_eq!(workspace.identity_cwd, PathBuf::from("/tmp/pion")); assert_eq!(tab.panes[&root.raw()].cwd, PathBuf::from("/tmp/pion")); assert_eq!(tab.panes[&second.raw()].cwd, PathBuf::from("/tmp/herdr")); } #[test] fn old_unversioned_snapshot_loads_as_version_0() { let json = r#"{"workspaces":[],"active":null,"selected":0}"#; let snap = parse_snapshot(json).unwrap(); assert_eq!(snap.version, 0); } #[test] fn future_version_is_rejected() { let json = r#"{"version":999,"workspaces":[],"active":null,"selected":0}"#; assert!(parse_snapshot(json).is_err()); } #[test] fn active_tab_default_is_zero() { let json = r#"{"custom_name":"test","identity_cwd":"/tmp","tabs":[]}"#; let ws: WorkspaceSnapshot = serde_json::from_str(json).unwrap(); assert_eq!(ws.active_tab, 0); } #[test] fn restore_falls_back_to_home_when_cwd_missing() { let mut panes = HashMap::new(); panes.insert( 0, PaneSnapshot { cwd: PathBuf::from("/tmp/this-directory-does-not-exist-for-herdr-test"), label: None, agent_name: None, }, ); panes.insert( 1, PaneSnapshot { cwd: std::env::var("HOME") .map(PathBuf::from) .unwrap_or_else(|_| PathBuf::from("/tmp")), label: None, agent_name: None, }, ); let snap = SessionSnapshot { version: SNAPSHOT_VERSION, workspaces: vec![WorkspaceSnapshot { id: Some("test-ws".to_string()), custom_name: Some("fallback test".to_string()), identity_cwd: PathBuf::from("/tmp"), tabs: vec![TabSnapshot { custom_name: None, 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), root_pane: Some(0), }], active_tab: 0, }], active: Some(0), selected: 0, agent_panel_scope: AgentPanelScope::CurrentWorkspace, sidebar_width: Some(26), sidebar_section_split: Some(0.5), }; let json = serde_json::to_string(&snap).unwrap(); let restored = parse_snapshot(&json).unwrap(); assert_eq!(restored.workspaces.len(), 1); assert_eq!( restored.workspaces[0].tabs[0].panes[&0].cwd, PathBuf::from("/tmp/this-directory-does-not-exist-for-herdr-test") ); } }