Add PDF state persistence

This commit is contained in:
2026-03-30 09:29:19 +02:00
parent d1bb79570d
commit 4613b8e5dd
15 changed files with 380 additions and 55 deletions

View File

@@ -2,7 +2,9 @@
use std::path::Path;
use crate::config::{GlobalConfig, ProjectConfig};
use crate::config::{GlobalConfig, ProjectConfig, SessionConfig};
use crate::state::AppState;
use tauri::State;
/// Load the global config from `~/.config/brittle/config.toml`.
/// Returns the default config if the file does not yet exist.
@@ -80,6 +82,32 @@ pub fn get_layout_config() -> Result<crate::config::LayoutConfig, String> {
.map_err(|e| e.to_string())
}
/// Return the session state for the currently open project.
///
/// Returns `Default` if no session has been saved yet.
/// Errors if no repository is open.
#[tauri::command]
pub fn get_session(state: State<AppState>) -> Result<SessionConfig, String> {
let guard = state.brittle.lock().map_err(|_| "lock poisoned".to_string())?;
let brittle = guard.as_ref().ok_or_else(|| "no repository open".to_string())?;
ProjectConfig::load(brittle.repository_root())
.map(|c| c.session)
.map_err(|e| e.to_string())
}
/// Persist the session state for the currently open project.
///
/// Errors if no repository is open.
#[tauri::command]
pub fn save_session(state: State<AppState>, session: SessionConfig) -> Result<(), String> {
let guard = state.brittle.lock().map_err(|_| "lock poisoned".to_string())?;
let brittle = guard.as_ref().ok_or_else(|| "no repository open".to_string())?;
let root = brittle.repository_root();
let mut config = ProjectConfig::load(root).map_err(|e| e.to_string())?;
config.session = session;
config.save(root).map_err(|e| e.to_string())
}
/// Return the user's keybinding overrides from the global config.
///
/// Map keys are action names in snake_case (e.g. `"tab_next"`); values are

View File

@@ -10,7 +10,7 @@ mod global;
mod project;
pub use global::GlobalConfig;
pub use project::ProjectConfig;
pub use project::{PdfTabEntry, PdfTabState, ProjectConfig, SessionConfig};
use std::collections::HashMap;
@@ -104,8 +104,8 @@ pub struct MergedConfig {
pub appearance: AppearanceConfig,
pub layout: LayoutConfig,
pub keybindings: KeybindingsConfig,
/// Reference IDs of tabs that should be restored on launch.
pub open_tabs: Vec<String>,
/// Tabs that should be restored on launch.
pub open_tabs: Vec<PdfTabEntry>,
}
impl MergedConfig {
@@ -157,7 +157,7 @@ pub enum ConfigError {
#[cfg(test)]
mod tests {
use super::*;
use crate::config::project::SessionConfig;
use crate::config::project::{PdfTabEntry, SessionConfig};
// ── AppearanceConfig ──────────────────────────────────────────────────────
@@ -273,12 +273,18 @@ mod tests {
let global = GlobalConfig::default();
let project = ProjectConfig {
session: SessionConfig {
open_tabs: vec!["tab-a".to_string(), "tab-b".to_string()],
open_tabs: vec![
PdfTabEntry { ref_id: "tab-a".to_string(), title: "a2024".to_string() },
PdfTabEntry { ref_id: "tab-b".to_string(), title: "b2024".to_string() },
],
..Default::default()
},
..Default::default()
};
let merged = MergedConfig::merge(&global, Some(&project));
assert_eq!(merged.open_tabs, ["tab-a", "tab-b"]);
assert_eq!(merged.open_tabs.len(), 2);
assert_eq!(merged.open_tabs[0].ref_id, "tab-a");
assert_eq!(merged.open_tabs[1].ref_id, "tab-b");
}
#[test]

View File

@@ -1,16 +1,37 @@
//! Per-project configuration.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use super::{AppearanceOverride, ConfigError};
/// Reference IDs of tabs to restore when the project is next opened.
/// Persisted zoom and scroll state for a single PDF tab.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct PdfTabState {
pub zoom: f64,
pub scroll_top: f64,
}
/// A PDF tab to restore on next launch.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct PdfTabEntry {
pub ref_id: String,
pub title: String,
}
/// Session state restored when a project is next opened.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct SessionConfig {
pub open_tabs: Vec<String>,
/// PDF tabs to reopen, in order (Library tab is implicit).
pub open_tabs: Vec<PdfTabEntry>,
/// Index into [Library, ...open_tabs] of the tab that was active.
pub active_tab: usize,
/// Per-reference zoom and scroll position, keyed by ref_id.
pub pdf_states: HashMap<String, PdfTabState>,
}
/// Per-project configuration, stored at `{repo}/.brittle/config.toml`.
@@ -77,6 +98,8 @@ mod tests {
let cfg = ProjectConfig::default();
assert!(cfg.appearance.is_none());
assert!(cfg.session.open_tabs.is_empty());
assert_eq!(cfg.session.active_tab, 0);
assert!(cfg.session.pdf_states.is_empty());
}
#[test]
@@ -87,7 +110,13 @@ mod tests {
font_size: Some(16),
}),
session: SessionConfig {
open_tabs: vec!["abc-123".to_string()],
open_tabs: vec![PdfTabEntry {
ref_id: "abc-123".to_string(),
title: "smith2024".to_string(),
}],
active_tab: 1,
pdf_states: [("abc-123".to_string(), PdfTabState { zoom: 1.5, scroll_top: 320.0 })]
.into_iter().collect(),
},
};
let s = toml::to_string_pretty(&cfg).unwrap();
@@ -130,7 +159,8 @@ mod tests {
font_size: None,
}),
session: SessionConfig {
open_tabs: vec!["ref-1".to_string()],
open_tabs: vec![PdfTabEntry { ref_id: "ref-1".to_string(), title: "ref2024".to_string() }],
..Default::default()
},
};
original.save_to(&path).unwrap();
@@ -146,7 +176,8 @@ mod tests {
let original = ProjectConfig {
session: SessionConfig {
open_tabs: vec!["x".to_string()],
open_tabs: vec![PdfTabEntry { ref_id: "x".to_string(), title: "x2024".to_string() }],
..Default::default()
},
..Default::default()
};

View File

@@ -32,6 +32,8 @@ pub fn run() {
commands::config::get_keybindings,
commands::config::get_layout_config,
commands::config::get_last_project,
commands::config::get_session,
commands::config::save_session,
// repository
commands::repository::create_repository,
commands::repository::open_repository,