1use 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
25pub 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#[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
102pub 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 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
187pub 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 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 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}