use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::Path;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct UiState {
pub view: String,
#[serde(default)]
pub active_track: String,
#[serde(default)]
pub tracks: HashMap<String, TrackUiState>,
#[serde(default)]
pub last_search: Option<String>,
#[serde(default)]
pub note_wrap_override: Option<bool>,
#[serde(default)]
pub search_history: Vec<String>,
#[serde(default)]
pub project_search_history: Vec<String>,
#[serde(default)]
pub board_mode: Option<String>,
#[serde(default)]
pub board_focus_column: Option<usize>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct TrackUiState {
#[serde(default)]
pub cursor: usize,
#[serde(default)]
pub expanded: HashSet<String>,
#[serde(default)]
pub scroll_offset: usize,
}
pub fn read_ui_state(frame_dir: &Path) -> Option<UiState> {
let path = frame_dir.join(".state.json");
let content = fs::read_to_string(&path).ok()?;
serde_json::from_str(&content).ok()
}
pub fn write_ui_state(frame_dir: &Path, state: &UiState) -> Result<(), std::io::Error> {
let path = frame_dir.join(".state.json");
let content = serde_json::to_string_pretty(state)?;
crate::io::recovery::atomic_write(&path, content.as_bytes())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn write_and_read_round_trip() {
let dir = TempDir::new().unwrap();
let mut state = UiState {
view: "track".into(),
active_track: "effects".into(),
last_search: Some("pattern".into()),
note_wrap_override: Some(false),
search_history: vec!["foo".into(), "bar".into()],
..Default::default()
};
let mut track_state = TrackUiState {
cursor: 5,
scroll_offset: 10,
..Default::default()
};
track_state.expanded.insert("T-001".into());
state.tracks.insert("effects".into(), track_state);
write_ui_state(dir.path(), &state).unwrap();
let loaded = read_ui_state(dir.path()).unwrap();
assert_eq!(loaded.view, "track");
assert_eq!(loaded.active_track, "effects");
assert_eq!(loaded.last_search, Some("pattern".into()));
assert_eq!(loaded.note_wrap_override, Some(false));
assert_eq!(loaded.search_history, vec!["foo", "bar"]);
let ts = loaded.tracks.get("effects").unwrap();
assert_eq!(ts.cursor, 5);
assert_eq!(ts.scroll_offset, 10);
assert!(ts.expanded.contains("T-001"));
}
#[test]
fn read_missing_file_returns_none() {
let dir = TempDir::new().unwrap();
assert!(read_ui_state(dir.path()).is_none());
}
#[test]
fn read_malformed_json_returns_none() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join(".state.json"), "not json {{{").unwrap();
assert!(read_ui_state(dir.path()).is_none());
}
#[test]
fn serde_defaults_on_minimal_object() {
let state: UiState = serde_json::from_str(r#"{"view":"track"}"#).unwrap();
assert_eq!(state.view, "track");
assert_eq!(state.active_track, "");
assert!(state.tracks.is_empty());
assert!(state.last_search.is_none());
assert!(state.note_wrap_override.is_none());
assert!(state.search_history.is_empty());
}
#[test]
fn track_ui_state_serde_defaults() {
let ts: TrackUiState = serde_json::from_str("{}").unwrap();
assert_eq!(ts.cursor, 0);
assert!(ts.expanded.is_empty());
assert_eq!(ts.scroll_offset, 0);
}
}