1use std::collections::BTreeMap;
20use std::path::{Path, PathBuf};
21
22use serde::{Deserialize, Serialize};
23use termesh_filesystem::{FileSystemService, FsError};
24
25const MAX_RECENT: usize = 20;
27
28#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
30#[serde(default)]
31pub struct Session {
32 pub version: u32,
37 pub recent: Vec<PathBuf>,
39 pub workspace: Option<RestoredWorkspace>,
42 #[serde(flatten)]
46 unknown: BTreeMap<String, toml::Value>,
47}
48
49impl Default for Session {
50 fn default() -> Self {
51 Self {
52 version: Session::CURRENT_VERSION,
53 recent: Vec::new(),
54 workspace: None,
55 unknown: BTreeMap::new(),
56 }
57 }
58}
59
60#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
63#[serde(default)]
64pub struct PaneGeometry {
65 pub sidebar_pct: u16,
66 pub bottom_pct: u16,
67 pub agent_pct: u16,
68 #[serde(flatten)]
69 unknown: BTreeMap<String, toml::Value>,
70}
71
72impl Default for PaneGeometry {
73 fn default() -> Self {
74 Self { sidebar_pct: 22, bottom_pct: 32, agent_pct: 26, unknown: BTreeMap::new() }
75 }
76}
77
78impl PaneGeometry {
79 pub fn new(sidebar_pct: u16, bottom_pct: u16, agent_pct: u16) -> Self {
80 Self { sidebar_pct, bottom_pct, agent_pct, unknown: BTreeMap::new() }
81 }
82
83 pub fn set_percentages(&mut self, sidebar_pct: u16, bottom_pct: u16, agent_pct: u16) {
84 self.sidebar_pct = sidebar_pct;
85 self.bottom_pct = bottom_pct;
86 self.agent_pct = agent_pct;
87 }
88}
89
90#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
92#[serde(rename_all = "snake_case")]
93pub enum AgentHistorySpeaker {
94 You,
95 Agent,
96 Thought,
97}
98
99#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
101pub struct AgentHistoryLine {
102 pub speaker: AgentHistorySpeaker,
103 pub text: String,
104}
105
106#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
109#[serde(default)]
110pub struct RestoredWorkspace {
111 pub root: PathBuf,
112 pub open: Vec<PathBuf>,
113 pub active: Option<PathBuf>,
114 pub layout: PaneGeometry,
115 pub terminals: Vec<PathBuf>,
117 pub agent_history: Vec<AgentHistoryLine>,
119 #[serde(flatten)]
121 unknown: BTreeMap<String, toml::Value>,
122}
123
124impl Default for RestoredWorkspace {
125 fn default() -> Self {
126 Self {
127 root: PathBuf::new(),
128 open: Vec::new(),
129 active: None,
130 layout: PaneGeometry::default(),
131 terminals: Vec::new(),
132 agent_history: Vec::new(),
133 unknown: BTreeMap::new(),
134 }
135 }
136}
137
138impl RestoredWorkspace {
139 pub fn new(root: PathBuf) -> Self {
140 Self { root, ..Self::default() }
141 }
142}
143
144#[derive(Debug, Clone, PartialEq, Eq)]
145pub struct SessionDiagnostic {
146 pub problem: String,
147 pub fallback: String,
148}
149
150impl Session {
151 pub const CURRENT_VERSION: u32 = 1;
152
153 pub fn parse(text: &str) -> (Self, Vec<SessionDiagnostic>) {
156 let mut session: Session = match toml::from_str(text) {
157 Ok(session) => session,
158 Err(error) => {
159 return (
160 Session::default(),
161 vec![SessionDiagnostic {
162 problem: error.to_string(),
163 fallback: "using an empty session".into(),
164 }],
165 );
166 }
167 };
168 let mut diagnostics = Vec::new();
169
170 if session.version > Self::CURRENT_VERSION {
171 diagnostics.push(SessionDiagnostic {
172 problem: format!(
173 "version {} is newer than this build understands (current: {})",
174 session.version,
175 Self::CURRENT_VERSION
176 ),
177 fallback: "loaded what was understood and preserved newer fields".into(),
178 });
179 } else if session.version < Self::CURRENT_VERSION {
180 session.version = Self::CURRENT_VERSION;
183 }
184
185 for key in session.unknown.keys() {
186 diagnostics.push(SessionDiagnostic {
187 problem: format!("unknown session key '{key}'"),
188 fallback: "preserving it unchanged".into(),
189 });
190 }
191 if let Some(workspace) = &session.workspace {
192 for key in workspace.unknown.keys() {
193 diagnostics.push(SessionDiagnostic {
194 problem: format!("unknown session key 'workspace.{key}'"),
195 fallback: "preserving it unchanged".into(),
196 });
197 }
198 for key in workspace.layout.unknown.keys() {
199 diagnostics.push(SessionDiagnostic {
200 problem: format!("unknown session key 'workspace.layout.{key}'"),
201 fallback: "preserving it unchanged".into(),
202 });
203 }
204 }
205
206 (session, diagnostics)
207 }
208
209 pub fn last_root(&self) -> Option<&Path> {
211 self.recent.first().map(PathBuf::as_path)
212 }
213
214 pub fn record(&mut self, root: &Path) {
216 self.recent.retain(|p| p != root);
217 self.recent.insert(0, root.to_path_buf());
218 self.recent.truncate(MAX_RECENT);
219 }
220
221 pub fn prune_missing(&mut self, fs: &dyn FileSystemService) {
223 self.recent.retain(|p| fs.read_dir(p).is_ok());
224 if self.workspace.as_ref().is_some_and(|workspace| fs.read_dir(&workspace.root).is_err()) {
225 self.workspace = None;
226 }
227 }
228}
229
230pub trait SessionStore {
233 fn load(&self) -> Session;
236
237 fn load_with_diagnostics(&self) -> (Session, Vec<SessionDiagnostic>) {
240 (self.load(), Vec::new())
241 }
242
243 fn save(&self, session: &Session) -> Result<(), FsError>;
246}
247
248pub struct FileSessionStore<'a> {
250 fs: &'a dyn FileSystemService,
251 path: PathBuf,
252}
253
254impl<'a> FileSessionStore<'a> {
255 pub fn new(fs: &'a dyn FileSystemService, path: impl Into<PathBuf>) -> Self {
256 Self { fs, path: path.into() }
257 }
258}
259
260impl SessionStore for FileSessionStore<'_> {
261 fn load(&self) -> Session {
262 self.load_with_diagnostics().0
263 }
264
265 fn load_with_diagnostics(&self) -> (Session, Vec<SessionDiagnostic>) {
266 let bytes = match self.fs.read_file(&self.path) {
267 Ok(bytes) => bytes,
268 Err(FsError::NotFound(_)) => return (Session::default(), Vec::new()),
269 Err(error) => {
270 return (
271 Session::default(),
272 vec![SessionDiagnostic {
273 problem: error.to_string(),
274 fallback: "using an empty session".into(),
275 }],
276 );
277 }
278 };
279 let text = match String::from_utf8(bytes) {
280 Ok(text) => text,
281 Err(_) => {
282 return (
283 Session::default(),
284 vec![SessionDiagnostic {
285 problem: "session file is not valid UTF-8".into(),
286 fallback: "using an empty session".into(),
287 }],
288 );
289 }
290 };
291 Session::parse(&text)
292 }
293
294 fn save(&self, session: &Session) -> Result<(), FsError> {
295 let text = toml::to_string_pretty(session)
296 .map_err(|e| FsError::Other { path: self.path.clone(), message: e.to_string() })?;
297
298 if let Some(parent) = self.path.parent() {
299 self.fs.create_dir(parent)?;
300 }
301 let _ = self.fs.remove_file(&self.path);
303 self.fs.create_file(&self.path)?;
304 self.fs.write_file(&self.path, text.as_bytes())
305 }
306}
307
308#[derive(Debug, Default)]
311pub struct MemorySessionStore {
312 session: std::sync::Mutex<Session>,
313}
314
315impl MemorySessionStore {
316 pub fn new() -> Self {
317 Self::default()
318 }
319}
320
321impl SessionStore for MemorySessionStore {
322 fn load(&self) -> Session {
323 self.session.lock().unwrap().clone()
324 }
325 fn save(&self, session: &Session) -> Result<(), FsError> {
326 *self.session.lock().unwrap() = session.clone();
327 Ok(())
328 }
329}
330
331#[cfg(test)]
332mod tests {
333 use super::*;
334 use termesh_test_support::FakeFileSystem;
335
336 fn store(fs: &FakeFileSystem) -> FileSessionStore<'_> {
337 FileSessionStore::new(fs, "/cfg/termesh/session.toml")
338 }
339
340 #[test]
341 fn the_most_recent_workspace_comes_first() {
342 let mut s = Session::default();
343 s.record(Path::new("/a"));
344 s.record(Path::new("/b"));
345 assert_eq!(s.last_root(), Some(Path::new("/b")));
346 assert_eq!(s.recent, [PathBuf::from("/b"), PathBuf::from("/a")]);
347 }
348
349 #[test]
350 fn reopening_a_workspace_moves_it_to_the_front_without_duplicating() {
351 let mut s = Session::default();
352 s.record(Path::new("/a"));
353 s.record(Path::new("/b"));
354 s.record(Path::new("/a"));
355 assert_eq!(s.recent, [PathBuf::from("/a"), PathBuf::from("/b")]);
356 }
357
358 #[test]
359 fn the_recent_list_is_capped() {
360 let mut s = Session::default();
361 for i in 0..MAX_RECENT + 10 {
362 s.record(Path::new(&format!("/p{i}")));
363 }
364 assert_eq!(s.recent.len(), MAX_RECENT);
365 assert_eq!(s.last_root(), Some(Path::new(&format!("/p{}", MAX_RECENT + 9))));
366 }
367
368 #[test]
369 fn an_empty_session_has_nothing_to_reopen() {
370 assert_eq!(Session::default().last_root(), None);
371 }
372
373 #[test]
374 fn a_session_round_trips_through_the_file() {
375 let fs = FakeFileSystem::new();
376 fs.add_dir("/proj");
377 let mut s = Session::default();
378 s.record(Path::new("/proj"));
379
380 store(&fs).save(&s).unwrap();
381 assert_eq!(store(&fs).load(), s);
382 }
383
384 #[test]
385 fn restored_workspace_state_round_trips_without_losing_restart_owned_fields() {
386 let fs = FakeFileSystem::new();
387 let session = Session {
388 workspace: Some(RestoredWorkspace {
389 root: PathBuf::from("/proj"),
390 open: vec![PathBuf::from("/proj/src/main.rs"), PathBuf::from("/proj/src/lib.rs")],
391 active: Some(PathBuf::from("/proj/src/lib.rs")),
392 layout: PaneGeometry::new(28, 35, 24),
393 terminals: vec![PathBuf::from("/proj"), PathBuf::from("/proj/src")],
394 agent_history: vec![AgentHistoryLine {
395 speaker: AgentHistorySpeaker::Agent,
396 text: "Prior answer".into(),
397 }],
398 unknown: BTreeMap::new(),
399 }),
400 ..Session::default()
401 };
402
403 store(&fs).save(&session).unwrap();
404
405 assert_eq!(store(&fs).load(), session);
406 }
407
408 #[test]
409 fn saving_twice_replaces_rather_than_appends() {
410 let fs = FakeFileSystem::new();
411 let mut s = Session::default();
412 s.record(Path::new("/a"));
413 store(&fs).save(&s).unwrap();
414
415 s.record(Path::new("/b"));
416 store(&fs).save(&s).unwrap();
417
418 assert_eq!(store(&fs).load().recent, [PathBuf::from("/b"), PathBuf::from("/a")]);
419 }
420
421 #[test]
422 fn a_missing_session_file_loads_as_empty() {
423 let fs = FakeFileSystem::new();
424 assert_eq!(store(&fs).load(), Session::default());
425 }
426
427 #[test]
428 fn a_corrupt_session_file_loads_as_empty_rather_than_failing() {
429 let fs = FakeFileSystem::new();
431 fs.add_file("/cfg/termesh/session.toml", b"this is not valid toml {{{");
432 assert_eq!(store(&fs).load(), Session::default());
433 }
434
435 #[test]
436 fn a_session_file_with_no_version_key_is_treated_as_current() {
437 let fs = FakeFileSystem::new();
438 fs.add_file("/cfg/termesh/session.toml", b"recent = []\n");
439 assert_eq!(store(&fs).load().version, Session::CURRENT_VERSION);
440 }
441
442 #[test]
443 fn loading_a_session_does_not_rewrite_the_file() {
444 let fs = FakeFileSystem::new();
445 fs.add_file("/cfg/termesh/session.toml", b"# my note\nrecent = []\n");
446 let _ = store(&fs).load();
447 assert_eq!(
448 fs.read_file(Path::new("/cfg/termesh/session.toml")).unwrap(),
449 b"# my note\nrecent = []\n".to_vec()
450 );
451 }
452
453 #[test]
454 fn a_future_session_loads_known_fields_and_reports_the_fallback() {
455 let fs = FakeFileSystem::new();
456 fs.add_file(
457 "/cfg/termesh/session.toml",
458 format!("version = {}\nrecent = [\"/proj\"]\n", Session::CURRENT_VERSION + 1)
459 .as_bytes(),
460 );
461
462 let (session, diagnostics) = store(&fs).load_with_diagnostics();
463
464 assert_eq!(session.recent, [PathBuf::from("/proj")]);
465 assert_eq!(diagnostics.len(), 1);
466 assert!(diagnostics[0].problem.contains("newer"));
467 assert!(diagnostics[0].fallback.contains("understood"));
468 }
469
470 #[test]
471 fn an_unknown_session_key_is_reported_and_preserved_on_the_next_real_write() {
472 let fs = FakeFileSystem::new();
473 fs.add_file(
474 "/cfg/termesh/session.toml",
475 b"version = 1\nrecent = []\nfuture_layout = \"keep me\"\n",
476 );
477
478 let (mut session, diagnostics) = store(&fs).load_with_diagnostics();
479 assert_eq!(diagnostics.len(), 1);
480 assert!(diagnostics[0].problem.contains("future_layout"));
481
482 session.record(Path::new("/proj"));
483 store(&fs).save(&session).unwrap();
484 let saved =
485 String::from_utf8(fs.read_file(Path::new("/cfg/termesh/session.toml")).unwrap())
486 .unwrap();
487 assert!(saved.contains("future_layout = \"keep me\""), "{saved}");
488 }
489
490 #[test]
491 fn unknown_nested_workspace_keys_are_reported_and_preserved() {
492 let fs = FakeFileSystem::new();
493 fs.add_file(
494 "/cfg/termesh/session.toml",
495 br#"version = 1
496recent = []
497
498[workspace]
499root = "/proj"
500future_workspace = "keep workspace"
501
502[workspace.layout]
503sidebar_pct = 22
504bottom_pct = 32
505agent_pct = 26
506future_layout = "keep layout"
507"#,
508 );
509
510 let (session, diagnostics) = store(&fs).load_with_diagnostics();
511 assert_eq!(diagnostics.len(), 2, "{diagnostics:#?}");
512 assert!(diagnostics.iter().any(|item| item.problem.contains("future_workspace")));
513 assert!(diagnostics.iter().any(|item| item.problem.contains("future_layout")));
514
515 store(&fs).save(&session).unwrap();
516 let saved =
517 String::from_utf8(fs.read_file(Path::new("/cfg/termesh/session.toml")).unwrap())
518 .unwrap();
519 assert!(saved.contains("future_workspace = \"keep workspace\""), "{saved}");
520 assert!(saved.contains("future_layout = \"keep layout\""), "{saved}");
521 }
522
523 #[test]
524 fn missing_workspaces_are_pruned() {
525 let fs = FakeFileSystem::new();
526 fs.add_dir("/still/here");
527 let mut s = Session::default();
528 s.record(Path::new("/deleted"));
529 s.record(Path::new("/still/here"));
530
531 s.prune_missing(&fs);
532 assert_eq!(s.recent, [PathBuf::from("/still/here")]);
533 }
534
535 #[test]
536 fn a_missing_restored_workspace_is_pruned_without_costing_valid_recents() {
537 let fs = FakeFileSystem::new();
538 fs.add_dir("/still/here");
539 let mut session = Session {
540 recent: vec![PathBuf::from("/deleted"), PathBuf::from("/still/here")],
541 workspace: Some(RestoredWorkspace::new(PathBuf::from("/deleted"))),
542 ..Session::default()
543 };
544
545 session.prune_missing(&fs);
546
547 assert!(session.workspace.is_none());
548 assert_eq!(session.recent, [PathBuf::from("/still/here")]);
549 }
550
551 #[test]
552 fn the_memory_store_round_trips() {
553 let store = MemorySessionStore::new();
554 let mut s = Session::default();
555 s.record(Path::new("/x"));
556 store.save(&s).unwrap();
557 assert_eq!(store.load(), s);
558 }
559}