Skip to main content

strop_engine/
session.rs

1//! Owned per-project snapshots and transactional restoration.
2//! Scratch and readonly documents never persist. Trust storage is independent.
3
4use crate::editor::{Document, Editor};
5use serde::{Deserialize, Serialize};
6use std::path::{Path, PathBuf};
7use strop_core::{history::History, Buffer};
8
9mod trust;
10pub use trust::{is_trusted, is_trusted_remote, trust, trust_remote};
11mod persistence;
12pub(crate) mod remotes;
13#[cfg(test)]
14mod tests;
15
16const UNDO_CAP: usize = 200;
17const UNDO_BYTES: usize = 1024 * 1024;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
20pub enum SessionPolicy {
21    Automatic,
22    Disabled,
23}
24
25/// Resolve once at live startup. Replay receives this value in the seed;
26/// trust access does not imply automatic session restoration or persistence.
27pub fn state_root() -> Option<PathBuf> {
28    std::env::var_os("XDG_STATE_HOME")
29        .map(PathBuf::from)
30        .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".local/state")))
31}
32
33#[derive(Debug, thiserror::Error)]
34pub enum SessionError {
35    #[error("session I/O at {path:?}: {source}")]
36    Io {
37        path: PathBuf,
38        #[source]
39        source: std::io::Error,
40    },
41    #[error("session JSON: {0}")]
42    Json(#[from] serde_json::Error),
43    #[error("invalid session: {0}")]
44    Invalid(String),
45    #[error(transparent)]
46    History(#[from] strop_core::history::HistoryError),
47    #[error(transparent)]
48    Path(#[from] strop_core::path_serde::PathError),
49    #[error("{original}; temporary cleanup also failed: {cleanup}")]
50    Cleanup {
51        original: Box<SessionError>,
52        cleanup: Box<SessionError>,
53    },
54}
55
56fn io(path: &Path, source: std::io::Error) -> SessionError {
57    SessionError::Io {
58        path: path.to_owned(),
59        source,
60    }
61}
62
63#[derive(Debug, Default, Serialize, Deserialize)]
64pub struct Session {
65    buffers: Vec<BufferState>,
66    current: usize,
67    #[serde(skip)]
68    captured: Vec<ropey::Rope>,
69}
70
71#[derive(Debug, Serialize, Deserialize)]
72struct BufferState {
73    #[serde(with = "strop_core::path_serde")]
74    path: PathBuf,
75    line: usize,
76    col: usize,
77    view_top: usize,
78    undo: Option<History>,
79    #[serde(default)]
80    undo_hash: u64,
81}
82
83/// Fully owned work item: move this to a blocking persistence worker.
84#[derive(Debug)]
85pub struct SaveRequest {
86    path: PathBuf,
87    session: Session,
88}
89
90fn session_path(base_dir: Option<&Path>, cwd: &Path) -> Option<PathBuf> {
91    let base = base_dir?;
92    let mut hasher = std::hash::DefaultHasher::new();
93    std::hash::Hash::hash(cwd, &mut hasher);
94    let key = format!("{:016x}", std::hash::Hasher::finish(&hasher));
95    Some(
96        base.join("strop")
97            .join("sessions")
98            .join(format!("{key}.json")),
99    )
100}
101
102/// Capture owns all paths and history; it never borrows the editor afterward.
103pub fn capture(editor: &Editor) -> Option<Session> {
104    let mut buffers = Vec::new();
105    let mut current = 0;
106    let mut captured = Vec::new();
107    for (id, doc) in editor.docs.iter() {
108        let buf = &doc.buf;
109        if buf.readonly {
110            continue;
111        }
112        let Some(path) = &buf.path else {
113            continue;
114        };
115        let active = id == editor.current();
116        if active {
117            current = buffers.len();
118        }
119        let (undo, undo_hash) = if buf.history().depth() > 0 {
120            let h = buf.history().snapshot(UNDO_CAP, UNDO_BYTES);
121            (Some(h), 0)
122        } else {
123            (None, 0)
124        };
125        captured.push(buf.snapshot());
126        buffers.push(BufferState {
127            path: path.clone(),
128            line: if active {
129                buf.line_of(editor.head())
130            } else {
131                0
132            },
133            col: if active { buf.col_of(editor.head()) } else { 0 },
134            view_top: if active { editor.view_top() } else { 0 },
135            undo,
136            undo_hash,
137        });
138    }
139    if buffers.is_empty() {
140        None
141    } else {
142        Some(Session {
143            buffers,
144            current,
145            captured,
146        })
147    }
148}
149
150pub fn capture_save(editor: &Editor) -> Option<SaveRequest> {
151    if editor.session_policy == SessionPolicy::Disabled {
152        return None;
153    }
154    let path = session_path(editor.state_dir.as_deref(), &editor.cwd)?;
155    Some(SaveRequest {
156        path,
157        session: capture(editor)?,
158    })
159}
160
161impl SaveRequest {
162    /// Blocking I/O; schedule on a worker, not the async executor thread.
163    pub fn persist(mut self) -> Result<(), SessionError> {
164        self.session.finish_capture();
165        self.session.validate()?;
166        persistence::write(&self.path, &self.session)
167    }
168}
169
170fn content_hash(text: &ropey::Rope) -> u64 {
171    let mut h = 0xcbf29ce484222325u64;
172    for b in text.chunks().flat_map(str::bytes) {
173        h ^= u64::from(b);
174        h = h.wrapping_mul(0x100000001b3);
175    }
176    h
177}
178
179impl Session {
180    fn finish_capture(&mut self) {
181        for (buffer, text) in self.buffers.iter_mut().zip(self.captured.drain(..)) {
182            buffer.undo_hash = content_hash(&text);
183        }
184    }
185}
186
187/// Read/decode without an editor, suitable for a blocking worker.
188/// Missing state and disabled persistence are ordinary absence, not errors.
189pub fn load(base_dir: Option<&Path>, cwd: &Path) -> Result<Option<Session>, SessionError> {
190    let Some(path) = session_path(base_dir, cwd) else {
191        return Ok(None);
192    };
193    let bytes = match persistence::read(&path) {
194        Ok(bytes) => bytes,
195        Err(SessionError::Io { source, .. }) if source.kind() == std::io::ErrorKind::NotFound => {
196            return Ok(None)
197        }
198        Err(e) => return Err(e),
199    };
200    let session: Session = serde_json::from_slice(&bytes)?;
201    session.validate()?;
202    Ok(Some(session))
203}
204
205impl Session {
206    fn validate(&self) -> Result<(), SessionError> {
207        if self.buffers.is_empty() || self.current >= self.buffers.len() {
208            return Err(SessionError::Invalid(
209                "empty buffers or invalid current index".into(),
210            ));
211        }
212        for b in &self.buffers {
213            strop_core::path_serde::validate(&b.path)?;
214            if let Some(h) = &b.undo {
215                h.validate()?;
216            }
217        }
218        Ok(())
219    }
220
221    /// Open and validate every buffer before changing any live editor state.
222    pub fn restore(mut self, editor: &mut Editor) -> Result<(), SessionError> {
223        self.finish_capture();
224        self.validate()?;
225        let mut documents = Vec::with_capacity(self.buffers.len());
226        let mut warning = None;
227        for b in &self.buffers {
228            let path = if b.path.is_absolute() {
229                b.path.clone()
230            } else {
231                editor.cwd.join(&b.path)
232            };
233            let mut buf = Buffer::open(&path).map_err(|e| io(&path, e))?;
234            if let Some(h) = &b.undo {
235                if content_hash(buf.text()) == b.undo_hash {
236                    buf.restore_history(h.clone())?;
237                } else {
238                    warning = Some(format!(
239                        "{}: changed since capture — undo history dropped",
240                        b.path.display()
241                    ));
242                }
243            }
244            documents.push(Document::new(buf));
245        }
246        let b = &self.buffers[self.current];
247        let selected = &documents[self.current].buf;
248        let last_line = selected.len_lines().saturating_sub(1);
249        let line = b.line.min(last_line);
250        let start = selected.line_start(line);
251        let end = if line < last_line {
252            selected.line_start(line + 1).saturating_sub(1)
253        } else {
254            selected.len_bytes()
255        };
256        let head = selected.clamp_boundary(start.saturating_add(b.col).min(end));
257        let top = b.view_top.min(last_line);
258        // All fallible work is complete. Insert first, then remove old IDs, so
259        // arena generations cannot accidentally alias an outstanding old ID.
260        let old: Vec<_> = editor.docs.iter().map(|(id, _)| id).collect();
261        for &id in &old {
262            editor.lsp_close_document(id);
263        }
264        let mut ids = Vec::with_capacity(documents.len());
265        for doc in documents {
266            ids.push(editor.docs.insert(doc));
267        }
268        let current = ids[self.current];
269        for pane in &mut editor.panes {
270            pane.doc = current;
271            pane.view_top = 0;
272            pane.sels = Default::default();
273        }
274        editor.view_mut().doc = current;
275        for id in old {
276            editor.docs.remove(id);
277        }
278        editor.mru = ids;
279        editor.touch_mru(current);
280        editor.set_head(head);
281        editor.view_mut().view_top = top;
282        editor.clamp_cursor();
283        editor.generation += 1;
284        editor.focus_epoch += 1;
285        if let Some(message) = warning {
286            editor.message = message;
287        }
288        Ok(())
289    }
290}
291
292pub fn restore(editor: &mut Editor) -> Result<bool, SessionError> {
293    let Some(session) = load(editor.state_dir.as_deref(), &editor.cwd)? else {
294        return Ok(false);
295    };
296    session.restore(editor)?;
297    Ok(true)
298}