use std::{
collections::HashMap,
path::{Path, PathBuf},
};
use anyhow::{Context as _, Result};
use serde::{Deserialize, Serialize};
use crate::{DisplayId, WindowState};
#[derive(Debug)]
pub struct SessionStore {
app_id: String,
storage_dir: PathBuf,
}
impl SessionStore {
pub fn new(app_id: impl Into<String>) -> Result<Self> {
let app_id = app_id.into();
let storage_dir = session_storage_dir(&app_id)?;
std::fs::create_dir_all(&storage_dir).with_context(|| {
format!(
"failed to create session storage directory: {}",
storage_dir.display()
)
})?;
Ok(Self {
app_id,
storage_dir,
})
}
pub fn app_id(&self) -> &str {
&self.app_id
}
pub fn storage_dir(&self) -> &Path {
&self.storage_dir
}
pub fn save_window_states(&self, states: &HashMap<String, WindowState>) -> Result<()> {
let snapshot = SessionSnapshot {
window_states: states.clone(),
app_data: self
.load_snapshot()
.ok()
.and_then(|snapshot| snapshot.app_data),
};
self.save_snapshot(&snapshot)
}
pub fn load_window_states(&self) -> Result<HashMap<String, WindowState>> {
Ok(self.load_snapshot()?.window_states)
}
pub fn clear_window_states(&self) -> Result<()> {
let snapshot_path = self.snapshot_path();
if snapshot_path.exists() {
std::fs::remove_file(&snapshot_path)
.with_context(|| format!("failed to remove {}", snapshot_path.display()))?;
}
let legacy_path = self.window_state_path();
if legacy_path.exists() {
std::fs::remove_file(&legacy_path)
.with_context(|| format!("failed to remove {}", legacy_path.display()))?;
}
Ok(())
}
pub fn save_snapshot(&self, snapshot: &SessionSnapshot) -> Result<()> {
write_json_atomically(&self.snapshot_path(), snapshot, "session snapshot")
}
pub fn load_snapshot(&self) -> Result<SessionSnapshot> {
let snapshot_path = self.snapshot_path();
if snapshot_path.exists() {
let json = std::fs::read_to_string(&snapshot_path).with_context(|| {
format!(
"failed to read session snapshot from {}",
snapshot_path.display()
)
})?;
return serde_json::from_str(&json).context("failed to deserialize session snapshot");
}
let legacy_path = self.window_state_path();
if legacy_path.exists() {
let json = std::fs::read_to_string(&legacy_path).with_context(|| {
format!(
"failed to read legacy window states from {}",
legacy_path.display()
)
})?;
let states: HashMap<String, WindowState> = serde_json::from_str(&json)
.context("failed to deserialize legacy window states")?;
return Ok(SessionSnapshot {
window_states: states,
app_data: None,
});
}
Ok(SessionSnapshot::default())
}
pub fn clear_snapshot(&self) -> Result<()> {
self.clear_window_states()
}
pub fn relocate_disconnected_displays(
&self,
states: &mut HashMap<String, WindowState>,
available_display_ids: &[DisplayId],
) {
self.relocate_disconnected_displays_to_primary(states, available_display_ids, None);
}
pub fn relocate_disconnected_displays_to_primary(
&self,
states: &mut HashMap<String, WindowState>,
available_display_ids: &[DisplayId],
primary_display_id: Option<DisplayId>,
) {
for state in states.values_mut() {
if let Some(display_id) = state.display_id {
if !available_display_ids.contains(&display_id) {
state.display_id = primary_display_id;
}
}
}
}
pub fn restore_window_states(
&self,
available_display_ids: &[DisplayId],
primary_display_id: Option<DisplayId>,
) -> Result<HashMap<String, WindowState>> {
let mut states = self.load_window_states()?;
self.relocate_disconnected_displays_to_primary(
&mut states,
available_display_ids,
primary_display_id,
);
Ok(states)
}
fn window_state_path(&self) -> PathBuf {
self.storage_dir.join("window_state.json")
}
fn snapshot_path(&self) -> PathBuf {
self.storage_dir.join("session_snapshot.json")
}
}
fn session_storage_dir(app_id: &str) -> Result<PathBuf> {
let base = crate::util::base_data_dir()?;
Ok(base.join(app_id).join("sessions"))
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct SessionSnapshot {
pub window_states: HashMap<String, WindowState>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub app_data: Option<serde_json::Value>,
}
fn write_json_atomically<T: Serialize>(path: &Path, value: &T, label: &str) -> Result<()> {
let temp_path = path.with_extension("json.tmp");
let json = serde_json::to_string_pretty(value)
.with_context(|| format!("failed to serialize {label}"))?;
std::fs::write(&temp_path, json)
.with_context(|| format!("failed to write {label} to {}", temp_path.display()))?;
std::fs::rename(&temp_path, path).with_context(|| {
format!(
"failed to finalize {label} from {} to {}",
temp_path.display(),
path.display()
)
})?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::WindowBounds;
#[test]
fn test_session_store_roundtrip() {
let temp_dir =
std::env::temp_dir().join(format!("gpui_session_test_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&temp_dir);
std::fs::create_dir_all(&temp_dir).unwrap();
let store = SessionStore {
app_id: "test-app".to_string(),
storage_dir: temp_dir.clone(),
};
let mut states = HashMap::new();
states.insert(
"main".to_string(),
WindowState {
bounds: WindowBounds::Windowed(crate::Bounds::new(
crate::point(crate::px(100.0), crate::px(200.0)),
crate::size(crate::px(800.0), crate::px(600.0)),
)),
display_id: Some(DisplayId(1)),
fullscreen: false,
},
);
store.save_window_states(&states).unwrap();
let loaded = store.load_window_states().unwrap();
let _ = std::fs::remove_dir_all(&temp_dir);
assert_eq!(states, loaded);
}
#[test]
fn test_relocate_disconnected_display() {
let store = SessionStore {
app_id: "test-app".to_string(),
storage_dir: PathBuf::from("/tmp"),
};
let mut states = HashMap::new();
states.insert(
"main".to_string(),
WindowState {
bounds: WindowBounds::Windowed(crate::Bounds::default()),
display_id: Some(DisplayId(99)),
fullscreen: false,
},
);
store.relocate_disconnected_displays(&mut states, &[DisplayId(1), DisplayId(2)]);
assert_eq!(states["main"].display_id, None);
}
#[test]
fn test_session_snapshot_serialization_roundtrip() {
let snapshot = SessionSnapshot {
window_states: HashMap::new(),
app_data: Some(serde_json::json!({ "theme": "dark" })),
};
let json = serde_json::to_string(&snapshot).unwrap();
let deserialized: SessionSnapshot = serde_json::from_str(&json).unwrap();
assert_eq!(snapshot, deserialized);
}
#[test]
fn test_session_store_snapshot_roundtrip() {
let temp_dir =
std::env::temp_dir().join(format!("gpui_session_snapshot_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&temp_dir);
std::fs::create_dir_all(&temp_dir).unwrap();
let store = SessionStore {
app_id: "test-app".to_string(),
storage_dir: temp_dir.clone(),
};
let snapshot = SessionSnapshot {
window_states: HashMap::new(),
app_data: Some(serde_json::json!({ "theme": "dark" })),
};
store.save_snapshot(&snapshot).unwrap();
let loaded = store.load_snapshot().unwrap();
let _ = std::fs::remove_dir_all(&temp_dir);
assert_eq!(snapshot, loaded);
}
#[test]
fn test_restore_window_states_relocates_to_primary_display() {
let temp_dir =
std::env::temp_dir().join(format!("gpui_session_restore_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&temp_dir);
std::fs::create_dir_all(&temp_dir).unwrap();
let store = SessionStore {
app_id: "test-app".to_string(),
storage_dir: temp_dir.clone(),
};
let mut states = HashMap::new();
states.insert(
"main".to_string(),
WindowState {
bounds: WindowBounds::Windowed(crate::Bounds::default()),
display_id: Some(DisplayId(99)),
fullscreen: false,
},
);
store.save_window_states(&states).unwrap();
let restored = store
.restore_window_states(&[DisplayId(1), DisplayId(2)], Some(DisplayId(1)))
.unwrap();
let _ = std::fs::remove_dir_all(&temp_dir);
assert_eq!(restored["main"].display_id, Some(DisplayId(1)));
}
#[test]
fn test_load_snapshot_falls_back_to_legacy_window_state_file() {
let temp_dir =
std::env::temp_dir().join(format!("gpui_session_legacy_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&temp_dir);
std::fs::create_dir_all(&temp_dir).unwrap();
let store = SessionStore {
app_id: "test-app".to_string(),
storage_dir: temp_dir.clone(),
};
let mut states = HashMap::new();
states.insert(
"main".to_string(),
WindowState {
bounds: WindowBounds::Windowed(crate::Bounds::default()),
display_id: Some(DisplayId(7)),
fullscreen: false,
},
);
std::fs::write(
store.window_state_path(),
serde_json::to_string(&states).unwrap(),
)
.unwrap();
let snapshot = store.load_snapshot().unwrap();
let _ = std::fs::remove_dir_all(&temp_dir);
assert_eq!(snapshot.window_states, states);
assert_eq!(snapshot.app_data, None);
}
}