use super::{ArrangementManager, WindowArrangement};
use anyhow::{Context, Result};
use par_term_config::Config;
use std::path::PathBuf;
pub fn arrangements_path() -> PathBuf {
Config::config_dir().join("arrangements.yaml")
}
pub fn load_arrangements() -> Result<ArrangementManager> {
load_arrangements_from(arrangements_path())
}
pub fn load_arrangements_from(path: PathBuf) -> Result<ArrangementManager> {
crate::debug_info!("ARRANGEMENT", "Loading arrangements from {:?}", path);
if !path.exists() {
crate::debug_info!(
"ARRANGEMENT",
"No arrangements file found at {:?}, starting with empty arrangements",
path
);
return Ok(ArrangementManager::new());
}
let contents = std::fs::read_to_string(&path)
.with_context(|| format!("Failed to read arrangements from {:?}", path))?;
crate::debug_info!(
"ARRANGEMENT",
"Read {} bytes from arrangements file",
contents.len()
);
if contents.trim().is_empty() {
crate::debug_info!(
"ARRANGEMENT",
"Arrangements file is empty, starting with empty arrangements"
);
return Ok(ArrangementManager::new());
}
let arrangements: Vec<WindowArrangement> = serde_yaml_ng::from_str(&contents)
.with_context(|| format!("Failed to parse arrangements from {:?}", path))?;
crate::debug_info!(
"ARRANGEMENT",
"Parsed {} arrangements from {:?}",
arrangements.len(),
path
);
for a in &arrangements {
crate::debug_info!(
"ARRANGEMENT",
" - {}: {} ({} windows)",
a.id,
a.name,
a.windows.len()
);
}
Ok(ArrangementManager::from_arrangements(arrangements))
}
pub fn save_arrangements(manager: &ArrangementManager) -> Result<()> {
save_arrangements_to(manager, arrangements_path())
}
pub fn save_arrangements_to(manager: &ArrangementManager, path: PathBuf) -> Result<()> {
let arrangements = manager.to_vec();
crate::atomic_save::save_yaml_atomic(&path, &arrangements)
.with_context(|| format!("Failed to write arrangements to {:?}", path))?;
log::info!("Saved {} arrangements to {:?}", arrangements.len(), path);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::arrangements::{MonitorInfo, TabSnapshot, WindowArrangement, WindowSnapshot};
use tempfile::tempdir;
use uuid::Uuid;
#[test]
fn test_load_nonexistent_file() {
let temp = tempdir().expect("failed to create temp dir");
let path = temp.path().join("nonexistent.yaml");
let manager = load_arrangements_from(path)
.expect("loading from nonexistent path should return empty manager");
assert!(manager.is_empty());
}
#[test]
fn test_load_empty_file() {
let temp = tempdir().expect("failed to create temp dir");
let path = temp.path().join("empty.yaml");
std::fs::write(&path, "").expect("failed to write empty file");
let manager =
load_arrangements_from(path).expect("loading empty file should return empty manager");
assert!(manager.is_empty());
}
#[test]
fn test_save_and_load_roundtrip() {
let temp = tempdir().expect("failed to create temp dir");
let path = temp.path().join("arrangements.yaml");
let mut manager = ArrangementManager::new();
manager.add(WindowArrangement {
id: Uuid::new_v4(),
name: "Work Setup".to_string(),
monitor_layout: vec![MonitorInfo {
name: Some("Main".to_string()),
index: 0,
position: (0, 0),
size: (1920, 1080),
scale_factor: 1.0,
}],
windows: vec![WindowSnapshot {
monitor: MonitorInfo {
name: Some("Main".to_string()),
index: 0,
position: (0, 0),
size: (1920, 1080),
scale_factor: 1.0,
},
position_relative: (100, 100),
size: (800, 600),
tabs: vec![TabSnapshot {
cwd: Some("/home/user/work".to_string()),
title: "work".to_string(),
custom_color: None,
user_title: None,
custom_icon: None,
}],
active_tab_index: 0,
tmux_session_name: None,
}],
created_at: "2024-01-01T00:00:00Z".to_string(),
order: 0,
});
save_arrangements_to(&manager, path.clone()).expect("failed to save arrangements");
let loaded = load_arrangements_from(path).expect("failed to load saved arrangements");
assert_eq!(loaded.len(), 1);
let arrangements = loaded.arrangements_ordered();
assert_eq!(arrangements[0].name, "Work Setup");
assert_eq!(arrangements[0].windows.len(), 1);
assert_eq!(arrangements[0].windows[0].tabs.len(), 1);
assert_eq!(
arrangements[0].windows[0].tabs[0].cwd,
Some("/home/user/work".to_string())
);
}
#[test]
fn test_roundtrip_preserves_custom_tab_properties() {
let temp = tempdir().expect("failed to create temp dir");
let path = temp.path().join("arrangements.yaml");
let mut manager = ArrangementManager::new();
manager.add(WindowArrangement {
id: Uuid::new_v4(),
name: "Custom Props".to_string(),
monitor_layout: vec![MonitorInfo {
name: Some("Main".to_string()),
index: 0,
position: (0, 0),
size: (1920, 1080),
scale_factor: 1.0,
}],
windows: vec![WindowSnapshot {
monitor: MonitorInfo {
name: Some("Main".to_string()),
index: 0,
position: (0, 0),
size: (1920, 1080),
scale_factor: 1.0,
},
position_relative: (0, 0),
size: (800, 600),
tabs: vec![
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()),
},
TabSnapshot {
cwd: Some("/tmp".to_string()),
title: "Tab 2".to_string(),
custom_color: None,
user_title: None,
custom_icon: Some("📁".to_string()),
},
TabSnapshot {
cwd: None,
title: "Colored Only".to_string(),
custom_color: Some([0, 200, 100]),
user_title: None,
custom_icon: None,
},
],
active_tab_index: 1,
tmux_session_name: None,
}],
created_at: "2024-01-01T00:00:00Z".to_string(),
order: 0,
});
save_arrangements_to(&manager, path.clone())
.expect("failed to save custom-props arrangements");
let loaded =
load_arrangements_from(path).expect("failed to load custom-props arrangements");
let arrangements = loaded.arrangements_ordered();
let tabs = &arrangements[0].windows[0].tabs;
assert_eq!(tabs[0].custom_color, Some([255, 128, 0]));
assert_eq!(tabs[0].user_title, Some("My Custom Tab".to_string()));
assert_eq!(tabs[0].custom_icon, Some("🔥".to_string()));
assert_eq!(tabs[1].custom_color, None);
assert_eq!(tabs[1].user_title, None);
assert_eq!(tabs[1].custom_icon, Some("📁".to_string()));
assert_eq!(tabs[2].custom_color, Some([0, 200, 100]));
assert_eq!(tabs[2].user_title, None);
assert_eq!(tabs[2].custom_icon, None);
}
#[test]
fn test_save_creates_parent_directory() {
let temp = tempdir().expect("failed to create temp dir");
let path = temp
.path()
.join("nested")
.join("dir")
.join("arrangements.yaml");
let manager = ArrangementManager::new();
save_arrangements_to(&manager, path.clone())
.expect("failed to save arrangements to nested dir");
assert!(path.exists());
}
#[cfg(unix)]
#[test]
fn test_saved_arrangements_are_owner_only() {
use std::os::unix::fs::PermissionsExt;
let temp = tempdir().expect("failed to create temp dir");
let path = temp.path().join("arrangements.yaml");
std::fs::write(&path, "[]").expect("failed to seed arrangements file");
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644))
.expect("failed to chmod");
save_arrangements_to(&ArrangementManager::new(), path.clone())
.expect("failed to save arrangements");
let mode = std::fs::metadata(&path)
.expect("failed to stat arrangements")
.permissions()
.mode()
& 0o777;
assert_eq!(mode, 0o600, "expected 0600, got {mode:o}");
}
#[test]
fn test_load_corrupt_file_returns_error() {
let temp = tempdir().expect("failed to create temp dir");
let path = temp.path().join("corrupt.yaml");
std::fs::write(&path, "not: valid: yaml: [[[").expect("failed to write corrupt file");
let result = load_arrangements_from(path);
assert!(result.is_err());
}
}