use super::SessionState;
use anyhow::{Context, Result};
use std::path::PathBuf;
pub fn session_path() -> PathBuf {
dirs::config_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("par-term")
.join("last_session.yaml")
}
pub fn save_session(state: &SessionState) -> Result<()> {
save_session_to(state, session_path())
}
pub fn save_session_to(state: &SessionState, path: PathBuf) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("Failed to create config directory {:?}", parent))?;
}
let contents = serde_yaml_ng::to_string(state).context("Failed to serialize session state")?;
#[cfg(unix)]
{
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
let mut f = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(&path)
.with_context(|| format!("Failed to open session state file {:?}", path))?;
f.write_all(contents.as_bytes())
.with_context(|| format!("Failed to write session state to {:?}", path))?;
}
#[cfg(not(unix))]
{
std::fs::write(&path, contents)
.with_context(|| format!("Failed to write session state to {:?}", path))?;
}
log::info!(
"Saved session state ({} windows) to {:?}",
state.windows.len(),
path
);
Ok(())
}
pub fn load_session() -> Result<Option<SessionState>> {
load_session_from(session_path())
}
pub fn load_session_from(path: PathBuf) -> Result<Option<SessionState>> {
if !path.exists() {
return Ok(None);
}
let contents = std::fs::read_to_string(&path)
.with_context(|| format!("Failed to read session state from {:?}", path))?;
if contents.trim().is_empty() {
return Ok(None);
}
let state: SessionState = serde_yaml_ng::from_str(&contents)
.with_context(|| format!("Failed to parse session state from {:?}", path))?;
log::info!(
"Loaded session state ({} windows) from {:?}",
state.windows.len(),
path
);
Ok(Some(state))
}
pub fn clear_session() -> Result<()> {
let path = session_path();
if path.exists() {
std::fs::remove_file(&path)
.with_context(|| format!("Failed to remove session state file {:?}", path))?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::session::{SessionState, SessionTab, SessionWindow};
use par_term_config::snapshot_types::TabSnapshot;
use tempfile::tempdir;
fn sample_session() -> SessionState {
SessionState {
saved_at: "2025-01-01T00:00:00Z".to_string(),
windows: vec![SessionWindow {
position: (100, 200),
size: (800, 600),
tabs: vec![SessionTab {
snapshot: TabSnapshot {
cwd: Some("/home/user/work".to_string()),
title: "work".to_string(),
custom_color: None,
user_title: None,
custom_icon: None,
},
pane_layout: None,
}],
active_tab_index: 0,
tmux_session_name: None,
}],
}
}
#[test]
fn test_load_nonexistent_file() {
let temp = tempdir().unwrap();
let path = temp.path().join("nonexistent.yaml");
let result = load_session_from(path).unwrap();
assert!(result.is_none());
}
#[test]
fn test_load_empty_file() {
let temp = tempdir().unwrap();
let path = temp.path().join("empty.yaml");
std::fs::write(&path, "").unwrap();
let result = load_session_from(path).unwrap();
assert!(result.is_none());
}
#[test]
fn test_load_corrupt_file() {
let temp = tempdir().unwrap();
let path = temp.path().join("corrupt.yaml");
std::fs::write(&path, "not: valid: yaml: [[[").unwrap();
let result = load_session_from(path);
assert!(result.is_err());
}
#[test]
fn test_save_and_load_roundtrip() {
let temp = tempdir().unwrap();
let path = temp.path().join("session.yaml");
let state = sample_session();
save_session_to(&state, path.clone()).unwrap();
let loaded = load_session_from(path).unwrap().unwrap();
assert_eq!(loaded.windows.len(), 1);
assert_eq!(loaded.windows[0].position, (100, 200));
assert_eq!(loaded.windows[0].size, (800, 600));
assert_eq!(loaded.windows[0].tabs.len(), 1);
assert_eq!(
loaded.windows[0].tabs[0].snapshot.cwd,
Some("/home/user/work".to_string())
);
assert_eq!(loaded.windows[0].tabs[0].snapshot.title, "work");
}
#[test]
fn test_roundtrip_preserves_custom_tab_properties() {
let temp = tempdir().unwrap();
let path = temp.path().join("session.yaml");
let state = SessionState {
saved_at: "2025-01-01T00:00:00Z".to_string(),
windows: vec![SessionWindow {
position: (0, 0),
size: (1920, 1080),
tabs: vec![
SessionTab {
snapshot: TabSnapshot {
cwd: Some("/home/user".to_string()),
title: "My Custom Tab".to_string(),
custom_color: Some([255, 128, 0]),
user_title: Some("My Custom Tab".to_string()),
custom_icon: Some("🔥".to_string()),
},
pane_layout: None,
},
SessionTab {
snapshot: TabSnapshot {
cwd: Some("/tmp".to_string()),
title: "Tab 2".to_string(),
custom_color: None,
user_title: None,
custom_icon: Some("📁".to_string()),
},
pane_layout: None,
},
SessionTab {
snapshot: TabSnapshot {
cwd: None,
title: "Colored Only".to_string(),
custom_color: Some([0, 200, 100]),
user_title: None,
custom_icon: None,
},
pane_layout: None,
},
],
active_tab_index: 1,
tmux_session_name: None,
}],
};
save_session_to(&state, path.clone()).unwrap();
let loaded = load_session_from(path).unwrap().unwrap();
let tabs = &loaded.windows[0].tabs;
assert_eq!(tabs[0].snapshot.custom_color, Some([255, 128, 0]));
assert_eq!(
tabs[0].snapshot.user_title,
Some("My Custom Tab".to_string())
);
assert_eq!(tabs[0].snapshot.custom_icon, Some("🔥".to_string()));
assert_eq!(tabs[1].snapshot.custom_color, None);
assert_eq!(tabs[1].snapshot.user_title, None);
assert_eq!(tabs[1].snapshot.custom_icon, Some("📁".to_string()));
assert_eq!(tabs[2].snapshot.custom_color, Some([0, 200, 100]));
assert_eq!(tabs[2].snapshot.user_title, None);
assert_eq!(tabs[2].snapshot.custom_icon, None);
}
#[test]
fn test_save_creates_parent_directory() {
let temp = tempdir().unwrap();
let path = temp.path().join("nested").join("dir").join("session.yaml");
let state = sample_session();
save_session_to(&state, path.clone()).unwrap();
assert!(path.exists());
}
#[test]
fn test_serialization_with_pane_layout() {
use crate::pane::SplitDirection;
use crate::session::SessionPaneNode;
let state = SessionState {
saved_at: "2025-01-01T00:00:00Z".to_string(),
windows: vec![SessionWindow {
position: (0, 0),
size: (1920, 1080),
tabs: vec![SessionTab {
snapshot: TabSnapshot {
cwd: Some("/home/user".to_string()),
title: "dev".to_string(),
custom_color: None,
user_title: None,
custom_icon: None,
},
pane_layout: Some(SessionPaneNode::Split {
direction: SplitDirection::Vertical,
ratio: 0.5,
first: Box::new(SessionPaneNode::Leaf {
cwd: Some("/home/user/code".to_string()),
}),
second: Box::new(SessionPaneNode::Split {
direction: SplitDirection::Horizontal,
ratio: 0.6,
first: Box::new(SessionPaneNode::Leaf {
cwd: Some("/home/user/logs".to_string()),
}),
second: Box::new(SessionPaneNode::Leaf {
cwd: Some("/home/user/tests".to_string()),
}),
}),
}),
}],
active_tab_index: 0,
tmux_session_name: None,
}],
};
let temp = tempdir().unwrap();
let path = temp.path().join("pane_session.yaml");
save_session_to(&state, path.clone()).unwrap();
let loaded = load_session_from(path).unwrap().unwrap();
let tab = &loaded.windows[0].tabs[0];
assert!(tab.pane_layout.is_some());
match tab.pane_layout.as_ref().unwrap() {
SessionPaneNode::Split {
direction, ratio, ..
} => {
assert_eq!(*direction, SplitDirection::Vertical);
assert!((ratio - 0.5).abs() < f32::EPSILON);
}
_ => panic!("Expected Split at root"),
}
}
#[test]
fn test_split_direction_serde() {
use crate::pane::SplitDirection;
let h = SplitDirection::Horizontal;
let v = SplitDirection::Vertical;
let h_yaml = serde_yaml_ng::to_string(&h).unwrap();
let v_yaml = serde_yaml_ng::to_string(&v).unwrap();
let h_back: SplitDirection = serde_yaml_ng::from_str(&h_yaml).unwrap();
let v_back: SplitDirection = serde_yaml_ng::from_str(&v_yaml).unwrap();
assert_eq!(h, h_back);
assert_eq!(v, v_back);
}
#[test]
fn test_clear_session() {
let temp = tempdir().unwrap();
let path = temp.path().join("to_clear.yaml");
std::fs::write(&path, "test").unwrap();
assert!(path.exists());
std::fs::remove_file(&path).unwrap();
assert!(!path.exists());
}
}