aboutsummaryrefslogtreecommitdiff
path: root/src/persist.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/persist.rs')
-rw-r--r--src/persist.rs1060
1 files changed, 9 insertions, 1051 deletions
diff --git a/src/persist.rs b/src/persist.rs
index 6704dd17..1753d679 100644
--- a/src/persist.rs
+++ b/src/persist.rs
@@ -2,1054 +2,12 @@
//!
//! Stored at `~/.config/herdr/session.json`.
-use std::collections::HashMap;
-use std::path::PathBuf;
-use std::sync::atomic::AtomicBool;
-use std::sync::Arc;
-
-use ratatui::layout::Direction;
-use serde::{Deserialize, Serialize};
-use tracing::{error, info, warn};
-
-use tokio::sync::{mpsc, Notify};
-
-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 = 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 ---
-
-/// 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()),
- }
-}
-
-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,
- 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 pane_ids = collect_pane_ids(&node);
- 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 pane_cwds = 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.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, err = %e, "failed to restore pane");
- return None;
- }
- }
- }
-
- let root_pane = snap
- .root_pane
- .and_then(|old_raw| id_map.get(&old_raw).copied())
- .or_else(|| pane_ids.first().copied())
- .unwrap_or(PaneId::from_raw(0));
-
- 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,
- })
-}
-
-/// 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 {
- crate::config::config_dir().join("session.json")
-}
-
-fn save_to_path(path: &std::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(())
-}
-
-fn clear_path(path: &std::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");
-}
-
-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 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 parse_snapshot(&content) {
- Ok(snap) => Some(snap),
- Err(e) => {
- if let Ok(raw) = serde_json::from_str::<RawSessionSnapshot>(&content) {
- if raw.version > SNAPSHOT_VERSION {
- warn!(
- file_version = raw.version,
- supported = SNAPSHOT_VERSION,
- "session file is from a newer herdr version, ignoring"
- );
- return None;
- }
- }
- warn!(err = %e, "failed to parse session file, ignoring");
- None
- }
- }
-}
-
-// --- Tests ---
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use ratatui::layout::Rect;
-
- 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 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")
- }
-
- #[test]
- fn round_trip_empty_session() {
- let snap = SessionSnapshot {
- version: SNAPSHOT_VERSION,
- workspaces: vec![],
- active: None,
- selected: 0,
- agent_panel_scope: crate::app::state::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 clear_path_removes_existing_session_file() {
- let path = temp_session_path("clear-existing");
- save_to_path(
- &path,
- &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),
- },
- )
- .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());
- }
-
- #[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 {
- 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: crate::app::state::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,
- crate::app::state::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,
- crate::app::state::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,
- crate::app::state::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,
- crate::app::state::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_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);
- }
-
- 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 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() {
- // Simulate a snapshot from before versioning was added
- 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);
- }
-}
+mod io;
+mod restore;
+mod snapshot;
+
+pub use self::io::{clear, load, save};
+pub use self::restore::restore;
+pub use self::snapshot::{
+ capture, DirectionSnapshot, LayoutSnapshot, SessionSnapshot, TabSnapshot, WorkspaceSnapshot,
+};