aboutsummaryrefslogtreecommitdiff
path: root/src/persist
diff options
context:
space:
mode:
Diffstat (limited to 'src/persist')
-rw-r--r--src/persist/io.rs130
-rw-r--r--src/persist/restore.rs353
-rw-r--r--src/persist/snapshot.rs744
3 files changed, 1227 insertions, 0 deletions
diff --git a/src/persist/io.rs b/src/persist/io.rs
new file mode 100644
index 00000000..26e6fd4e
--- /dev/null
+++ b/src/persist/io.rs
@@ -0,0 +1,130 @@
+use std::path::{Path, PathBuf};
+
+use tracing::{error, info, warn};
+
+use super::snapshot::{parse_snapshot, snapshot_file_version, SessionSnapshot, SNAPSHOT_VERSION};
+
+fn session_path() -> PathBuf {
+ crate::config::config_dir().join("session.json")
+}
+
+pub(super) fn save_to_path(path: &Path, snapshot: &SessionSnapshot) -> std::io::Result<()> {
+ if let Some(parent) = path.parent() {
+ std::fs::create_dir_all(parent)?;
+ }
+ let json = serde_json::to_string_pretty(snapshot)?;
+ let tmp_path = path.with_extension("json.tmp");
+ std::fs::write(&tmp_path, &json)?;
+ if let Err(err) = std::fs::rename(&tmp_path, path) {
+ let _ = std::fs::remove_file(&tmp_path);
+ return Err(err);
+ }
+ Ok(())
+}
+
+pub(super) fn clear_path(path: &Path) -> std::io::Result<()> {
+ match std::fs::remove_file(path) {
+ Ok(()) => Ok(()),
+ Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
+ Err(err) => Err(err),
+ }
+}
+
+pub fn save(snapshot: &SessionSnapshot) {
+ let path = session_path();
+ if let Err(err) = save_to_path(&path, snapshot) {
+ error!(err = %err, path = %path.display(), "failed to save session");
+ return;
+ }
+ info!(workspaces = snapshot.workspaces.len(), "session saved");
+}
+
+pub fn clear() {
+ let path = session_path();
+ if let Err(err) = clear_path(&path) {
+ error!(err = %err, path = %path.display(), "failed to clear session");
+ return;
+ }
+ info!(path = %path.display(), "session cleared");
+}
+
+pub fn load() -> Option<SessionSnapshot> {
+ let path = session_path();
+ if !path.exists() {
+ return None;
+ }
+ let content = match std::fs::read_to_string(&path) {
+ Ok(content) => content,
+ Err(err) => {
+ warn!(err = %err, "failed to read session file");
+ return None;
+ }
+ };
+ match parse_snapshot(&content) {
+ Ok(snapshot) => Some(snapshot),
+ Err(err) => {
+ if let Some(version) = snapshot_file_version(&content) {
+ if version > SNAPSHOT_VERSION {
+ warn!(
+ file_version = version,
+ supported = SNAPSHOT_VERSION,
+ "session file is from a newer herdr version, ignoring"
+ );
+ return None;
+ }
+ }
+ warn!(err = %err, "failed to parse session file, ignoring");
+ None
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::app::state::AgentPanelScope;
+
+ fn temp_session_path(name: &str) -> PathBuf {
+ let unique = format!(
+ "herdr-session-tests-{}-{}-{}",
+ name,
+ std::process::id(),
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap()
+ .as_nanos()
+ );
+ std::env::temp_dir().join(unique).join("session.json")
+ }
+
+ fn empty_snapshot() -> SessionSnapshot {
+ 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),
+ }
+ }
+
+ #[test]
+ fn clear_path_removes_existing_session_file() {
+ let path = temp_session_path("clear-existing");
+ save_to_path(&path, &empty_snapshot()).unwrap();
+
+ clear_path(&path).unwrap();
+
+ assert!(!path.exists());
+ }
+
+ #[test]
+ fn clear_path_ignores_missing_session_file() {
+ let path = temp_session_path("clear-missing");
+
+ clear_path(&path).unwrap();
+
+ assert!(!path.exists());
+ }
+}
diff --git a/src/persist/restore.rs b/src/persist/restore.rs
new file mode 100644
index 00000000..eeef0af1
--- /dev/null
+++ b/src/persist/restore.rs
@@ -0,0 +1,353 @@
+use std::collections::{HashMap, HashSet};
+use std::path::PathBuf;
+use std::sync::atomic::AtomicBool;
+use std::sync::Arc;
+
+use ratatui::layout::Direction;
+use tokio::sync::{mpsc, Notify};
+use tracing::{error, warn};
+
+use crate::events::AppEvent;
+use crate::layout::{Node, PaneId, TileLayout};
+use crate::pane::{PaneRuntime, PaneState};
+use crate::workspace::Workspace;
+
+use super::{DirectionSnapshot, LayoutSnapshot, SessionSnapshot, TabSnapshot, WorkspaceSnapshot};
+
+/// Restore workspaces from a snapshot. Each pane gets a fresh shell in its saved cwd.
+pub fn restore(
+ snapshot: &SessionSnapshot,
+ rows: u16,
+ cols: u16,
+ scrollback_limit_bytes: usize,
+ events: mpsc::Sender<AppEvent>,
+ render_notify: Arc<Notify>,
+ render_dirty: Arc<AtomicBool>,
+) -> Vec<Workspace> {
+ snapshot
+ .workspaces
+ .iter()
+ .filter_map(|ws_snap| {
+ restore_workspace(
+ ws_snap,
+ rows,
+ cols,
+ scrollback_limit_bytes,
+ events.clone(),
+ render_notify.clone(),
+ render_dirty.clone(),
+ )
+ })
+ .collect()
+}
+
+fn restore_workspace(
+ snap: &WorkspaceSnapshot,
+ rows: u16,
+ cols: u16,
+ scrollback_limit_bytes: usize,
+ events: mpsc::Sender<AppEvent>,
+ render_notify: Arc<Notify>,
+ render_dirty: Arc<AtomicBool>,
+) -> Option<Workspace> {
+ let mut tabs = Vec::new();
+ let mut public_pane_numbers = HashMap::new();
+ let mut next_public_pane_number = 1;
+
+ for (idx, tab_snap) in snap.tabs.iter().enumerate() {
+ let tab = restore_tab(
+ tab_snap,
+ idx + 1,
+ rows,
+ cols,
+ scrollback_limit_bytes,
+ events.clone(),
+ render_notify.clone(),
+ render_dirty.clone(),
+ )?;
+ for pane_id in tab.layout.pane_ids() {
+ public_pane_numbers.insert(pane_id, next_public_pane_number);
+ next_public_pane_number += 1;
+ }
+ tabs.push(tab);
+ }
+
+ if tabs.is_empty() {
+ return None;
+ }
+
+ Some(Workspace {
+ id: snap
+ .id
+ .clone()
+ .unwrap_or_else(crate::workspace::generate_workspace_id),
+ custom_name: snap.custom_name.clone(),
+ identity_cwd: snap.identity_cwd.clone(),
+ cached_git_ahead_behind: None,
+ public_pane_numbers,
+ next_public_pane_number,
+ active_tab: snap.active_tab.min(tabs.len().saturating_sub(1)),
+ tabs,
+ })
+}
+
+fn restore_tab(
+ snap: &TabSnapshot,
+ number: usize,
+ rows: u16,
+ cols: u16,
+ scrollback_limit_bytes: usize,
+ events: mpsc::Sender<AppEvent>,
+ render_notify: Arc<Notify>,
+ render_dirty: Arc<AtomicBool>,
+) -> Option<crate::workspace::Tab> {
+ let (node, id_map) = restore_node_remapped(&snap.layout);
+ let reverse_id_map: HashMap<PaneId, u32> = id_map
+ .iter()
+ .map(|(&old_id, &new_id)| (new_id, old_id))
+ .collect();
+ let pane_ids = collect_pane_ids(&node);
+
+ let mut panes = HashMap::new();
+ let mut pane_cwds = HashMap::new();
+ let mut runtimes = HashMap::new();
+ for id in &pane_ids {
+ let saved_cwd = reverse_id_map
+ .get(id)
+ .and_then(|old_id| snap.panes.get(old_id))
+ .map(|p| p.cwd.clone())
+ .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| "/".into()));
+
+ let cwd = if saved_cwd.exists() {
+ saved_cwd
+ } else {
+ warn!(
+ cwd = %saved_cwd.display(),
+ "saved pane cwd does not exist, falling back to HOME"
+ );
+ let home = std::env::var("HOME")
+ .map(PathBuf::from)
+ .unwrap_or_else(|_| PathBuf::from("/"));
+ if home.exists() {
+ home
+ } else {
+ PathBuf::from("/")
+ }
+ };
+
+ match PaneRuntime::spawn(
+ *id,
+ rows,
+ cols,
+ cwd.clone(),
+ scrollback_limit_bytes,
+ crate::terminal_theme::TerminalTheme::default(),
+ events.clone(),
+ render_notify.clone(),
+ render_dirty.clone(),
+ ) {
+ Ok(runtime) => {
+ panes.insert(*id, PaneState::new());
+ pane_cwds.insert(*id, cwd.clone());
+ runtimes.insert(*id, runtime);
+ }
+ Err(e) => {
+ error!(
+ tab = ?snap.custom_name,
+ pane_id = id.raw(),
+ err = %e,
+ "failed to restore pane, skipping"
+ );
+ }
+ }
+ }
+
+ if panes.is_empty() {
+ warn!(
+ tab = ?snap.custom_name,
+ "no panes could be restored for tab, dropping it"
+ );
+ return None;
+ }
+
+ let surviving: HashSet<PaneId> = panes.keys().copied().collect();
+ let Some(node) = prune_restored_node(node, &surviving) else {
+ warn!(
+ tab = ?snap.custom_name,
+ "restored tab lost all panes after pruning missing layout nodes"
+ );
+ return None;
+ };
+ let pane_ids = collect_pane_ids(&node);
+ let focus = resolve_restored_pane(snap.focused, &id_map, &surviving, &pane_ids)?;
+ let root_pane = resolve_restored_pane(snap.root_pane, &id_map, &surviving, &pane_ids)?;
+ let layout = TileLayout::from_saved(node, focus);
+
+ Some(crate::workspace::Tab {
+ custom_name: snap.custom_name.clone(),
+ number,
+ root_pane,
+ layout,
+ panes,
+ pane_cwds,
+ runtimes,
+ zoomed: snap.zoomed,
+ events,
+ render_notify,
+ render_dirty,
+ })
+}
+
+pub(super) fn prune_restored_node(node: Node, surviving: &HashSet<PaneId>) -> Option<Node> {
+ match node {
+ Node::Pane(id) => surviving.contains(&id).then_some(Node::Pane(id)),
+ Node::Split {
+ direction,
+ ratio,
+ first,
+ second,
+ } => {
+ let first = prune_restored_node(*first, surviving);
+ let second = prune_restored_node(*second, surviving);
+ match (first, second) {
+ (Some(first), Some(second)) => Some(Node::Split {
+ direction,
+ ratio,
+ first: Box::new(first),
+ second: Box::new(second),
+ }),
+ (Some(remaining), None) | (None, Some(remaining)) => Some(remaining),
+ (None, None) => None,
+ }
+ }
+ }
+}
+
+pub(super) fn resolve_restored_pane(
+ saved_old_id: Option<u32>,
+ id_map: &HashMap<u32, PaneId>,
+ surviving: &HashSet<PaneId>,
+ pane_ids: &[PaneId],
+) -> Option<PaneId> {
+ saved_old_id
+ .and_then(|old_id| id_map.get(&old_id).copied())
+ .filter(|pane_id| surviving.contains(pane_id))
+ .or_else(|| pane_ids.first().copied())
+}
+
+/// 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.
+pub(super) 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),
+ }
+ }
+ }
+}
+
+pub(super) 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);
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn capture_and_restore_node_round_trip() {
+ 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 = super::super::snapshot::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 prune_restored_node_collapses_missing_branch() {
+ let keep = PaneId::from_raw(11);
+ let missing = PaneId::from_raw(12);
+ let node = Node::Split {
+ direction: Direction::Horizontal,
+ ratio: 0.5,
+ first: Box::new(Node::Pane(keep)),
+ second: Box::new(Node::Pane(missing)),
+ };
+ let surviving = std::collections::HashSet::from([keep]);
+
+ let pruned = prune_restored_node(node, &surviving).expect("remaining pane should survive");
+
+ assert!(matches!(pruned, Node::Pane(id) if id == keep));
+ }
+
+ #[test]
+ fn resolve_restored_pane_prefers_surviving_saved_id_and_falls_back_to_first_remaining() {
+ let first = PaneId::from_raw(21);
+ let second = PaneId::from_raw(22);
+ let id_map = HashMap::from([(0_u32, first), (1_u32, second)]);
+ let surviving = std::collections::HashSet::from([first]);
+ let pane_ids = vec![first];
+
+ assert_eq!(
+ resolve_restored_pane(Some(0), &id_map, &surviving, &pane_ids),
+ Some(first)
+ );
+ assert_eq!(
+ resolve_restored_pane(Some(1), &id_map, &surviving, &pane_ids),
+ Some(first)
+ );
+ }
+}
diff --git a/src/persist/snapshot.rs b/src/persist/snapshot.rs
new file mode 100644
index 00000000..1438cd6d
--- /dev/null
+++ b/src/persist/snapshot.rs
@@ -0,0 +1,744 @@
+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<WorkspaceSnapshot>,
+ pub active: Option<usize>,
+ pub selected: usize,
+ #[serde(default)]
+ pub agent_panel_scope: crate::app::state::AgentPanelScope,
+ #[serde(default)]
+ pub sidebar_width: Option<u16>,
+ #[serde(default)]
+ pub sidebar_section_split: Option<f32>,
+}
+
+#[derive(Serialize, Deserialize)]
+pub struct WorkspaceSnapshot {
+ #[serde(default)]
+ pub id: Option<String>,
+ #[serde(default)]
+ pub custom_name: Option<String>,
+ pub identity_cwd: PathBuf,
+ pub tabs: Vec<TabSnapshot>,
+ #[serde(default)]
+ pub active_tab: usize,
+}
+
+#[derive(Deserialize)]
+struct LegacyWorkspaceSnapshot {
+ #[serde(default)]
+ custom_name: Option<String>,
+ layout: LayoutSnapshot,
+ panes: HashMap<u32, PaneSnapshot>,
+ zoomed: bool,
+ #[serde(default)]
+ focused: Option<u32>,
+ #[serde(default)]
+ root_pane: Option<u32>,
+}
+
+#[derive(Serialize, Deserialize)]
+pub struct TabSnapshot {
+ #[serde(default)]
+ pub custom_name: Option<String>,
+ pub layout: LayoutSnapshot,
+ pub panes: HashMap<u32, PaneSnapshot>,
+ pub zoomed: bool,
+ #[serde(default)]
+ pub focused: Option<u32>,
+ #[serde(default)]
+ pub root_pane: 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,
+}
+
+impl From<LegacyWorkspaceSnapshot> 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_json::Value>,
+ #[serde(default)]
+ active: Option<usize>,
+ #[serde(default)]
+ selected: usize,
+ #[serde(default)]
+ agent_panel_scope: crate::app::state::AgentPanelScope,
+ #[serde(default)]
+ sidebar_width: Option<u16>,
+ #[serde(default)]
+ sidebar_section_split: Option<f32>,
+}
+
+fn migrate_snapshot(raw: RawSessionSnapshot) -> Result<SessionSnapshot, String> {
+ Ok(SessionSnapshot {
+ version: raw.version,
+ workspaces: raw
+ .workspaces
+ .into_iter()
+ .map(migrate_workspace)
+ .collect::<Result<Vec<_>, _>>()?,
+ 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<WorkspaceSnapshot, String> {
+ 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::<LegacyWorkspaceSnapshot>(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<u32> {
+ 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],
+ active: Option<usize>,
+ 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(capture_workspace).collect(),
+ active,
+ selected,
+ agent_panel_scope,
+ sidebar_width: Some(sidebar_width),
+ sidebar_section_split: Some(sidebar_section_split),
+ }
+}
+
+fn capture_workspace(ws: &Workspace) -> WorkspaceSnapshot {
+ WorkspaceSnapshot {
+ id: Some(ws.id.clone()),
+ custom_name: ws.custom_name.clone(),
+ identity_cwd: ws
+ .resolved_identity_cwd()
+ .unwrap_or_else(|| ws.identity_cwd.clone()),
+ tabs: ws.tabs.iter().map(capture_tab).collect(),
+ active_tab: ws.active_tab,
+ }
+}
+
+fn capture_tab(tab: &crate::workspace::Tab) -> TabSnapshot {
+ let mut panes = HashMap::new();
+ for id in tab.panes.keys() {
+ let cwd = tab
+ .cwd_for_pane(*id)
+ .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| "/".into()));
+ panes.insert(id.raw(), PaneSnapshot { cwd });
+ }
+ 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<SessionSnapshot, String> {
+ let raw = serde_json::from_str::<RawSessionSnapshot>(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<u32> {
+ serde_json::from_str::<RawSessionSnapshot>(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, Node, PaneId};
+ 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();
+ 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.active,
+ state.selected,
+ state.agent_panel_scope,
+ state.sidebar_width,
+ state.sidebar_section_split,
+ )
+ }
+
+ fn root_split_ratio(tab: &TabSnapshot) -> Option<f32> {
+ 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"),
+ },
+ );
+ panes.insert(
+ 1,
+ PaneSnapshot {
+ cwd: PathBuf::from("/home/can/Projects/website"),
+ },
+ );
+
+ 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.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::CurrentWorkspace);
+ 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::CurrentWorkspace
+ );
+ 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_fullscreen();
+
+ 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].tabs[0]
+ .pane_cwds
+ .insert(root, PathBuf::from("/tmp/pion"));
+ let second = state.workspaces[0].test_split(Direction::Horizontal);
+ state.workspaces[0].tabs[0]
+ .pane_cwds
+ .insert(second, 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"),
+ },
+ );
+ panes.insert(
+ 1,
+ PaneSnapshot {
+ cwd: std::env::var("HOME")
+ .map(PathBuf::from)
+ .unwrap_or_else(|_| PathBuf::from("/tmp")),
+ },
+ );
+
+ 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")
+ );
+ }
+}