inkhaven 1.2.19

Inkhaven — TUI literary work editor for Typst books
//! Persisted TUI session: what paragraph was open, where the cursors sat,
//! which tree branches were collapsed, which pane had focus. Stored in
//! `<project>/.session.json` and re-applied on next startup.
//!
//! Loaders ignore missing/corrupt files quietly — sessions are a UX nicety,
//! not a correctness requirement.

use std::collections::HashMap;
use std::path::Path;

use serde::{Deserialize, Serialize};

pub const SESSION_FILE: &str = ".session.json";

#[derive(Debug, Default, Serialize, Deserialize)]
pub struct SessionState {
    #[serde(default)]
    pub tree: TreeSession,
    #[serde(default)]
    pub editor: Option<EditorSession>,
    /// One of "Tree", "Editor", "Ai", "SearchBar", "AiPrompt". Anything else
    /// is treated as "Tree" on restore.
    #[serde(default)]
    pub focus: String,
    /// Cursor/scroll positions per paragraph UUID. Updated whenever the
    /// editor loses focus, the user switches paragraphs, or the app exits —
    /// so re-opening any paragraph drops the cursor back where the user
    /// left it, even after a full restart.
    #[serde(default)]
    pub paragraph_cursors: HashMap<String, ParagraphCursor>,
    /// 1.2.7+ — visited-paragraph history (browser-style
    /// back/forward via Alt+Left / Alt+Right). UUIDs in
    /// visit order; cursor points at the current one.
    #[serde(default)]
    pub visited_history: Vec<String>,
    #[serde(default)]
    pub visited_cursor: usize,
    /// 1.2.7+ — per-book timeline view state. Restored when
    /// the user reopens the swim-lane view (Ctrl+V Shift+T)
    /// for the same book, so collapsed tracks + expanded
    /// track + zoom + scroll survive across opens AND
    /// across restarts.
    #[serde(default)]
    pub timeline_views: HashMap<String, TimelineViewSnapshot>,
}

/// 1.2.7+ — serialisable snapshot of the swim-lane view's
/// per-book state. Persisted in `.session.json` so the
/// `Ctrl+V Shift+T` re-open lands on the same configuration
/// the user left.
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct TimelineViewSnapshot {
    #[serde(default)]
    pub collapsed_tracks: Vec<String>,
    #[serde(default)]
    pub expanded_track: Option<String>,
    #[serde(default)]
    pub track_highlight: Option<String>,
    #[serde(default)]
    pub ticks_per_cell: f64,
    #[serde(default)]
    pub scroll_ticks: i64,
    #[serde(default)]
    pub cursor_ticks: i64,
}

#[derive(Debug, Default, Clone, Copy, Serialize, Deserialize)]
pub struct ParagraphCursor {
    #[serde(default)]
    pub cursor_row: usize,
    #[serde(default)]
    pub cursor_col: usize,
    #[serde(default)]
    pub scroll_row: usize,
    #[serde(default)]
    pub scroll_col: usize,
}

#[derive(Debug, Default, Serialize, Deserialize)]
pub struct TreeSession {
    /// UUID of the node the tree cursor was on.
    #[serde(default)]
    pub cursor_id: Option<String>,
    /// UUIDs of branches whose subtrees were collapsed.
    #[serde(default)]
    pub collapsed_nodes: Vec<String>,
}

#[derive(Debug, Default, Serialize, Deserialize)]
pub struct EditorSession {
    pub opened_id: String,
    #[serde(default)]
    pub cursor_row: usize,
    #[serde(default)]
    pub cursor_col: usize,
}

impl SessionState {
    pub fn load(project_root: &Path) -> Option<Self> {
        let path = project_root.join(SESSION_FILE);
        let raw = std::fs::read_to_string(&path).ok()?;
        serde_json::from_str(&raw).ok()
    }

    pub fn save(&self, project_root: &Path) -> std::io::Result<()> {
        let path = project_root.join(SESSION_FILE);
        let json = serde_json::to_string_pretty(self)?;
        // 1.2.15+ Phase S.4 — atomic write.  Session
        // state (cursor pos, scroll, history) isn't
        // load-bearing, but a corrupt session file
        // surfaces as `Session::load returned None`
        // which silently resets every per-paragraph
        // cursor to (0, 0).  The user would never
        // know — atomic write avoids the data loss
        // entirely.
        crate::io_atomic::write(&path, json.as_bytes())
    }
}