par_term/session/mod.rs
1//! Session state types for save/restore on startup.
2//!
3//! This module provides **automatic session persistence**: save the current window
4//! layout, tabs, and pane splits on clean exit, then restore them on next launch.
5//!
6//! # Relationship to `crate::arrangements`
7//!
8//! par-term has two overlapping session persistence mechanisms:
9//!
10//! | Feature | Module | Trigger | Scope |
11//! |---------|--------|---------|-------|
12//! | Auto session restore | `crate::session` (this module) | Automatic on clean exit / next launch | Last-session state only (single slot) |
13//! | Named arrangements | `crate::arrangements` | User-initiated save/restore via UI | Multiple named snapshots, monitor-aware |
14//!
15//! Both capture window positions, sizes, tab CWDs, and tab titles using similar
16//! serialization patterns (serde JSON via `storage` submodule). The key distinction
17//! is lifecycle: session state is ephemeral (overwritten on each clean exit), while
18//! arrangements are user-named and persist indefinitely.
19//!
20//! # Crash recovery
21//!
22//! Neither of those survives a panic: both are written on the way out, and a
23//! panicking process never gets there. [`crash_guard`] adds a third file,
24//! `crash_session.yaml`, written from a panic hook out of a snapshot the event
25//! loop publishes periodically. It is deliberately a separate file — a process
26//! that has just panicked must not overwrite the good session — and
27//! `WindowManager::restore_session` prefers it when present, then deletes it.
28//! Read that module's header before changing anything here: it is explicit about
29//! which panic classes it covers and which it cannot.
30//!
31//! # Shared types
32//!
33//! The common per-tab fields (`cwd`, `title`, `custom_color`, `user_title`,
34//! `custom_icon`) are defined once in [`par_term_config::snapshot_types::TabSnapshot`]
35//! and are embedded into [`SessionTab`] via `#[serde(flatten)]`. The arrangements
36//! module re-exports the same type directly, eliminating the previous duplication.
37//! Existing YAML session files are fully backward-compatible — all fields remain at
38//! the same nesting level.
39
40pub mod capture;
41pub mod crash_guard;
42pub mod restore;
43pub mod storage;
44
45// Re-export TabSnapshot so session consumers can use `crate::session::TabSnapshot`.
46use crate::pane::SplitDirection;
47pub use par_term_config::snapshot_types::TabSnapshot;
48use serde::{Deserialize, Serialize};
49
50/// Top-level session state: all windows at the time of save
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct SessionState {
53 /// Timestamp when the session was saved (ISO 8601)
54 pub saved_at: String,
55 /// All windows in the session
56 pub windows: Vec<SessionWindow>,
57}
58
59/// A single window in the saved session
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct SessionWindow {
62 /// Window position (x, y) in physical pixels
63 pub position: (i32, i32),
64 /// Window size (width, height) in physical pixels
65 pub size: (u32, u32),
66 /// Tabs in this window
67 pub tabs: Vec<SessionTab>,
68 /// Index of the active tab
69 pub active_tab_index: usize,
70 /// tmux session name to auto-reconnect on restore (if any)
71 #[serde(default, skip_serializing_if = "Option::is_none")]
72 pub tmux_session_name: Option<String>,
73 /// par-mux session name to reattach on restore (if any). Distinct from
74 /// `tmux_session_name`: restoring a mux name through the tmux gateway
75 /// spawns a real `tmux -CC` session of the same name instead of
76 /// reattaching to the daemon.
77 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub mux_session_name: Option<String>,
79}
80
81/// A single tab in a saved session.
82///
83/// The common tab fields (`cwd`, `title`, `custom_color`, `user_title`,
84/// `custom_icon`) are inherited from [`TabSnapshot`] via `#[serde(flatten)]`
85/// so that the serialized YAML layout is unchanged from before this refactor.
86/// The session-specific field `pane_layout` is appended alongside the flattened
87/// fields in the output.
88#[derive(Debug, Clone, Serialize, Deserialize, Default)]
89pub struct SessionTab {
90 /// Common tab snapshot fields shared with the arrangements module
91 #[serde(flatten)]
92 pub snapshot: TabSnapshot,
93 /// Pane layout tree (None = single pane, use cwd above)
94 #[serde(default, skip_serializing_if = "Option::is_none")]
95 pub pane_layout: Option<SessionPaneNode>,
96}
97
98/// Recursive pane tree node for session persistence
99#[derive(Debug, Clone, Serialize, Deserialize)]
100pub enum SessionPaneNode {
101 /// A terminal pane leaf
102 Leaf {
103 /// Working directory of this pane
104 cwd: Option<String>,
105 /// User-set title of this pane (present only when the pane was
106 /// user-named at save time; automatic titles are re-derived)
107 #[serde(default, skip_serializing_if = "Option::is_none")]
108 user_title: Option<String>,
109 },
110 /// A split containing two children
111 Split {
112 /// Split direction
113 direction: SplitDirection,
114 /// Split ratio (0.0-1.0)
115 ratio: f32,
116 /// First child (top/left)
117 first: Box<SessionPaneNode>,
118 /// Second child (bottom/right)
119 second: Box<SessionPaneNode>,
120 },
121}
122
123#[cfg(test)]
124mod pane_title_persistence_tests {
125 use super::SessionPaneNode;
126
127 /// Card 01a0d95dd318 criterion 4: a leaf's user title round-trips, and
128 /// a pre-rename session file (no `user_title` key) still loads.
129 #[test]
130 fn leaf_user_title_round_trips_and_old_files_load() {
131 let leaf = SessionPaneNode::Leaf {
132 cwd: Some("/tmp".to_string()),
133 user_title: Some("build box".to_string()),
134 };
135 let yaml = serde_yaml_ng::to_string(&leaf).expect("serialize");
136 let back: SessionPaneNode = serde_yaml_ng::from_str(&yaml).expect("deserialize");
137 let SessionPaneNode::Leaf { cwd, user_title } = back else {
138 panic!("round-trip must stay a leaf")
139 };
140 assert_eq!(cwd.as_deref(), Some("/tmp"));
141 assert_eq!(
142 user_title.as_deref(),
143 Some("build box"),
144 "the user title must survive the YAML round-trip"
145 );
146 assert!(
147 !yaml.contains("user_title: null"),
148 "absent titles are omitted"
149 );
150
151 // The pre-field form (saved by older par-term: same serializer,
152 // user_title absent via skip_serializing_if) loads with
153 // `user_title: None` via serde default.
154 let untitled = SessionPaneNode::Leaf {
155 cwd: Some("/tmp".to_string()),
156 user_title: None,
157 };
158 let old_yaml = serde_yaml_ng::to_string(&untitled).expect("serialize untitled");
159 assert!(
160 !old_yaml.contains("user_title"),
161 "an absent title is not written: {old_yaml}"
162 );
163 let parsed: SessionPaneNode =
164 serde_yaml_ng::from_str(&old_yaml).expect("old-format leaf loads");
165 let SessionPaneNode::Leaf { cwd, user_title } = parsed else {
166 panic!("old-format parse must stay a leaf")
167 };
168 assert_eq!(cwd.as_deref(), Some("/tmp"));
169 assert_eq!(user_title, None);
170 }
171}