diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 29763c6..06265f2 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,3 +1,5 @@ +pub mod prefs; +pub mod recents; pub mod state; pub mod utils; diff --git a/src-tauri/src/prefs.rs b/src-tauri/src/prefs.rs new file mode 100644 index 0000000..2c55fa6 --- /dev/null +++ b/src-tauri/src/prefs.rs @@ -0,0 +1,441 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::path::Path; +use std::time::SystemTime; + +use crate::state::{atomic_write_json, load_json_with_fallbacks, BACKUP_COUNT}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WindowState { + pub width: i32, + pub height: i32, + pub x: Option, + pub y: Option, +} + +impl Default for WindowState { + fn default() -> Self { + Self { + width: 1320, + height: 860, + x: None, + y: None, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Prefs { + #[serde(default = "default_version")] + pub version: u32, + #[serde(default = "default_ui_zoom")] + pub ui_zoom: f64, + #[serde(default = "default_split_ratio")] + pub split_ratio: f64, + #[serde(default = "default_dock_ratio")] + pub dock_ratio: f64, + #[serde(default)] + pub always_on_top: bool, + #[serde(default)] + pub window: WindowState, + #[serde(default)] + pub last_folder_path: Option, + #[serde(default)] + pub last_library_id: Option, + #[serde(default)] + pub updated_at: u64, +} + +fn default_version() -> u32 { + 19 +} +fn default_ui_zoom() -> f64 { + 1.0 +} +fn default_split_ratio() -> f64 { + 0.62 +} +fn default_dock_ratio() -> f64 { + 0.62 +} + +impl Default for Prefs { + fn default() -> Self { + Self { + version: 19, + ui_zoom: 1.0, + split_ratio: 0.62, + dock_ratio: 0.62, + always_on_top: false, + window: WindowState::default(), + last_folder_path: None, + last_library_id: None, + updated_at: 0, + } + } +} + +impl Prefs { + /// Load preferences from `{state_dir}/prefs.json`, merging with defaults. + /// + /// Window fields are merged individually so that a saved file with only + /// `{"window": {"width": 800}}` keeps the default height, x, and y. + pub fn load(state_dir: &Path) -> Prefs { + let path = state_dir.join("prefs.json"); + let loaded = load_json_with_fallbacks(&path, BACKUP_COUNT); + + let raw = match loaded { + Some(v) => v, + None => return Prefs::default(), + }; + + let raw_obj = match raw.as_object() { + Some(obj) => obj, + None => return Prefs::default(), + }; + + // Start with defaults + let mut prefs = Prefs::default(); + + // Merge window fields individually if present + if let Some(Value::Object(win_obj)) = raw_obj.get("window") { + if let Some(Value::Number(n)) = win_obj.get("width") { + if let Some(v) = n.as_i64() { + prefs.window.width = v as i32; + } + } + if let Some(Value::Number(n)) = win_obj.get("height") { + if let Some(v) = n.as_i64() { + prefs.window.height = v as i32; + } + } + if let Some(val) = win_obj.get("x") { + prefs.window.x = val.as_i64().map(|v| v as i32); + } + if let Some(val) = win_obj.get("y") { + prefs.window.y = val.as_i64().map(|v| v as i32); + } + } + + // Merge all other top-level keys + if let Some(Value::Number(n)) = raw_obj.get("version") { + if let Some(v) = n.as_u64() { + prefs.version = v as u32; + } + } + if let Some(Value::Number(n)) = raw_obj.get("ui_zoom") { + if let Some(v) = n.as_f64() { + prefs.ui_zoom = v; + } + } + if let Some(Value::Number(n)) = raw_obj.get("split_ratio") { + if let Some(v) = n.as_f64() { + prefs.split_ratio = v; + } + } + if let Some(Value::Number(n)) = raw_obj.get("dock_ratio") { + if let Some(v) = n.as_f64() { + prefs.dock_ratio = v; + } + } + if let Some(Value::Bool(b)) = raw_obj.get("always_on_top") { + prefs.always_on_top = *b; + } + if let Some(val) = raw_obj.get("last_folder_path") { + prefs.last_folder_path = val.as_str().map(|s| s.to_string()); + } + if let Some(val) = raw_obj.get("last_library_id") { + prefs.last_library_id = val.as_str().map(|s| s.to_string()); + } + if let Some(Value::Number(n)) = raw_obj.get("updated_at") { + if let Some(v) = n.as_u64() { + prefs.updated_at = v; + } + } + + prefs + } + + /// Save preferences to `{state_dir}/prefs.json`. + pub fn save(&self, state_dir: &Path) { + let path = state_dir.join("prefs.json"); + let value = serde_json::to_value(self).expect("failed to serialize Prefs"); + atomic_write_json(&path, &value, BACKUP_COUNT); + } + + /// Apply a partial JSON update, merge window fields individually, set + /// `updated_at` to the current unix timestamp, and save. + pub fn update(&mut self, patch: &Value, state_dir: &Path) { + if let Some(obj) = patch.as_object() { + // Merge window fields individually + if let Some(Value::Object(win_obj)) = obj.get("window") { + if let Some(Value::Number(n)) = win_obj.get("width") { + if let Some(v) = n.as_i64() { + self.window.width = v as i32; + } + } + if let Some(Value::Number(n)) = win_obj.get("height") { + if let Some(v) = n.as_i64() { + self.window.height = v as i32; + } + } + if let Some(val) = win_obj.get("x") { + self.window.x = val.as_i64().map(|v| v as i32); + } + if let Some(val) = win_obj.get("y") { + self.window.y = val.as_i64().map(|v| v as i32); + } + } + + // Apply other top-level keys + if let Some(Value::Number(n)) = obj.get("version") { + if let Some(v) = n.as_u64() { + self.version = v as u32; + } + } + if let Some(Value::Number(n)) = obj.get("ui_zoom") { + if let Some(v) = n.as_f64() { + self.ui_zoom = v; + } + } + if let Some(Value::Number(n)) = obj.get("split_ratio") { + if let Some(v) = n.as_f64() { + self.split_ratio = v; + } + } + if let Some(Value::Number(n)) = obj.get("dock_ratio") { + if let Some(v) = n.as_f64() { + self.dock_ratio = v; + } + } + if let Some(Value::Bool(b)) = obj.get("always_on_top") { + self.always_on_top = *b; + } + if let Some(val) = obj.get("last_folder_path") { + self.last_folder_path = val.as_str().map(|s| s.to_string()); + } + if let Some(val) = obj.get("last_library_id") { + self.last_library_id = val.as_str().map(|s| s.to_string()); + } + + // Set updated_at to current unix timestamp + self.updated_at = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + self.save(state_dir); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use tempfile::TempDir; + + #[test] + fn test_default_prefs() { + let prefs = Prefs::default(); + assert_eq!(prefs.version, 19); + assert_eq!(prefs.ui_zoom, 1.0); + assert_eq!(prefs.split_ratio, 0.62); + assert_eq!(prefs.dock_ratio, 0.62); + assert!(!prefs.always_on_top); + assert_eq!(prefs.window.width, 1320); + assert_eq!(prefs.window.height, 860); + assert_eq!(prefs.window.x, None); + assert_eq!(prefs.window.y, None); + assert_eq!(prefs.last_folder_path, None); + assert_eq!(prefs.last_library_id, None); + assert_eq!(prefs.updated_at, 0); + } + + #[test] + fn test_default_window_state() { + let ws = WindowState::default(); + assert_eq!(ws.width, 1320); + assert_eq!(ws.height, 860); + assert_eq!(ws.x, None); + assert_eq!(ws.y, None); + } + + #[test] + fn test_save_and_load_round_trip() { + let dir = TempDir::new().unwrap(); + let mut prefs = Prefs::default(); + prefs.ui_zoom = 1.5; + prefs.split_ratio = 0.75; + prefs.always_on_top = true; + prefs.window.width = 1920; + prefs.window.height = 1080; + prefs.window.x = Some(100); + prefs.window.y = Some(200); + prefs.last_folder_path = Some("/my/folder".to_string()); + prefs.last_library_id = Some("lib-abc".to_string()); + prefs.updated_at = 1234567890; + + prefs.save(dir.path()); + + let loaded = Prefs::load(dir.path()); + assert_eq!(loaded.version, 19); + assert_eq!(loaded.ui_zoom, 1.5); + assert_eq!(loaded.split_ratio, 0.75); + assert_eq!(loaded.dock_ratio, 0.62); + assert!(loaded.always_on_top); + assert_eq!(loaded.window.width, 1920); + assert_eq!(loaded.window.height, 1080); + assert_eq!(loaded.window.x, Some(100)); + assert_eq!(loaded.window.y, Some(200)); + assert_eq!(loaded.last_folder_path, Some("/my/folder".to_string())); + assert_eq!(loaded.last_library_id, Some("lib-abc".to_string())); + assert_eq!(loaded.updated_at, 1234567890); + } + + #[test] + fn test_load_nonexistent_returns_defaults() { + let dir = TempDir::new().unwrap(); + let prefs = Prefs::load(dir.path()); + assert_eq!(prefs.version, 19); + assert_eq!(prefs.ui_zoom, 1.0); + assert_eq!(prefs.window.width, 1320); + } + + #[test] + fn test_partial_update() { + let dir = TempDir::new().unwrap(); + let mut prefs = Prefs::default(); + + let patch = json!({ + "ui_zoom": 2.0, + "always_on_top": true + }); + prefs.update(&patch, dir.path()); + + assert_eq!(prefs.ui_zoom, 2.0); + assert!(prefs.always_on_top); + // Unchanged fields stay at defaults + assert_eq!(prefs.split_ratio, 0.62); + assert_eq!(prefs.dock_ratio, 0.62); + assert_eq!(prefs.window.width, 1320); + // updated_at should have been set + assert!(prefs.updated_at > 0); + } + + #[test] + fn test_update_saves_to_disk() { + let dir = TempDir::new().unwrap(); + let mut prefs = Prefs::default(); + + let patch = json!({"ui_zoom": 3.0}); + prefs.update(&patch, dir.path()); + + let loaded = Prefs::load(dir.path()); + assert_eq!(loaded.ui_zoom, 3.0); + } + + #[test] + fn test_window_merge_on_load() { + let dir = TempDir::new().unwrap(); + // Write a JSON file with only partial window data + let partial = json!({ + "window": {"width": 800} + }); + let path = dir.path().join("prefs.json"); + atomic_write_json(&path, &partial, BACKUP_COUNT); + + let prefs = Prefs::load(dir.path()); + // Width should be overridden + assert_eq!(prefs.window.width, 800); + // Height should remain at default + assert_eq!(prefs.window.height, 860); + // x, y should remain None (defaults) + assert_eq!(prefs.window.x, None); + assert_eq!(prefs.window.y, None); + } + + #[test] + fn test_window_merge_on_update() { + let dir = TempDir::new().unwrap(); + let mut prefs = Prefs::default(); + prefs.window.width = 1000; + prefs.window.height = 700; + prefs.window.x = Some(50); + prefs.window.y = Some(60); + + // Patch only width — height, x, y should remain + let patch = json!({ + "window": {"width": 1600} + }); + prefs.update(&patch, dir.path()); + + assert_eq!(prefs.window.width, 1600); + assert_eq!(prefs.window.height, 700); + assert_eq!(prefs.window.x, Some(50)); + assert_eq!(prefs.window.y, Some(60)); + } + + #[test] + fn test_window_merge_with_position() { + let dir = TempDir::new().unwrap(); + // Saved file has window with x/y set + let saved = json!({ + "version": 19, + "window": {"width": 1320, "height": 860, "x": 150, "y": 250} + }); + let path = dir.path().join("prefs.json"); + atomic_write_json(&path, &saved, BACKUP_COUNT); + + let prefs = Prefs::load(dir.path()); + assert_eq!(prefs.window.x, Some(150)); + assert_eq!(prefs.window.y, Some(250)); + } + + #[test] + fn test_load_with_missing_fields_uses_defaults() { + let dir = TempDir::new().unwrap(); + // Write a JSON with only version — everything else should be defaults + let partial = json!({"version": 20}); + let path = dir.path().join("prefs.json"); + atomic_write_json(&path, &partial, BACKUP_COUNT); + + let prefs = Prefs::load(dir.path()); + assert_eq!(prefs.version, 20); + assert_eq!(prefs.ui_zoom, 1.0); + assert_eq!(prefs.split_ratio, 0.62); + assert_eq!(prefs.window.width, 1320); + assert_eq!(prefs.window.height, 860); + assert_eq!(prefs.last_folder_path, None); + } + + #[test] + fn test_update_with_non_object_is_noop() { + let dir = TempDir::new().unwrap(); + let mut prefs = Prefs::default(); + let original_zoom = prefs.ui_zoom; + + prefs.update(&json!("not an object"), dir.path()); + + // Nothing should have changed + assert_eq!(prefs.ui_zoom, original_zoom); + assert_eq!(prefs.updated_at, 0); + } + + #[test] + fn test_window_null_xy_in_update() { + let dir = TempDir::new().unwrap(); + let mut prefs = Prefs::default(); + prefs.window.x = Some(100); + prefs.window.y = Some(200); + + // Setting x/y to null should clear them + let patch = json!({ + "window": {"x": null, "y": null} + }); + prefs.update(&patch, dir.path()); + + assert_eq!(prefs.window.x, None); + assert_eq!(prefs.window.y, None); + } +}