1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
//! 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());
}
}