feat: implement prefs.rs — preferences with save/load/update
This commit is contained in:
@@ -1,3 +1,5 @@
|
|||||||
|
pub mod prefs;
|
||||||
|
pub mod recents;
|
||||||
pub mod state;
|
pub mod state;
|
||||||
pub mod utils;
|
pub mod utils;
|
||||||
|
|
||||||
|
|||||||
441
src-tauri/src/prefs.rs
Normal file
441
src-tauri/src/prefs.rs
Normal file
@@ -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<i32>,
|
||||||
|
pub y: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub last_library_id: Option<String>,
|
||||||
|
#[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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user