inkhaven 1.4.3

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,
    /// PANE-1 — the active right-side pane: "Output" or "Ai". Empty / unknown
    /// keeps the launch default on restore.
    #[serde(default)]
    pub right_pane: String,
    /// PANE-1 filtering (road to 1.4.0) — the Output pane's active filter
    /// (source / severity / open-paragraph). Default = show everything.
    #[serde(default)]
    pub output_filter: crate::pane::output::OutputFilter,
    /// 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())
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::pane::output::{OutputFilter, Severity};

    #[test]
    fn output_filter_round_trips_through_session_json() {
        let s = SessionState {
            output_filter: OutputFilter {
                source: Some("socrates".into()),
                min_severity: Some(Severity::Warning),
                only_open_paragraph: true,
            },
            ..SessionState::default()
        };
        let json = serde_json::to_string(&s).expect("serialize");
        let back: SessionState = serde_json::from_str(&json).expect("deserialize");
        assert_eq!(back.output_filter, s.output_filter);
    }

    #[test]
    fn legacy_session_without_filter_defaults_to_off() {
        // Old `.session.json` files predate the filter key — must default to "off".
        let back: SessionState =
            serde_json::from_str(r#"{"right_pane":"Output"}"#).expect("deserialize legacy");
        assert!(!back.output_filter.is_active());
    }
}