Skip to main content

termesh_workspace/
session.rs

1//! Session persistence: recent workspaces, and which one to reopen (ARCHITECTURE.md §13,
2//! §16 Phase 02).
3//!
4//! Reads and writes through [`FileSystemService`] like everything else, so the whole
5//! thing is testable against the in-memory fake and the service boundary holds.
6//!
7//! Phase 02 persisted only the workspace roots. Phase 10 added the rest of what §23
8//! item 10 asks for — open buffers, the active tab, pane geometry, and terminal working
9//! directories — as a best-effort [`RestoredWorkspace`] alongside the MRU list.
10//!
11//! The agent session is the one piece that does **not** persist, and that is a protocol
12//! limit rather than a gap here: this client has no `session/load`, so a restored
13//! workspace starts a fresh session and keeps the prior transcript as read-only history
14//! (ADR-0014 §4, `docs/support.md`).
15//!
16//! The format is a table, and unknown keys written by a newer build survive a
17//! round-trip, so adding keys does not break old files in either direction.
18
19use std::collections::BTreeMap;
20use std::path::{Path, PathBuf};
21
22use serde::{Deserialize, Serialize};
23use termesh_filesystem::{FileSystemService, FsError};
24
25/// How many recent workspaces to remember.
26const MAX_RECENT: usize = 20;
27
28/// What survives a restart.
29#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
30#[serde(default)]
31pub struct Session {
32    /// The schema version this build writes and fully understands (ADR-0014 §2). No
33    /// transitions exist yet — a corrupt or unreadable file already falls back to
34    /// `Session::default()` above the version check, so there is nothing to migrate in
35    /// memory until the first breaking change gives this field work to do.
36    pub version: u32,
37    /// Most recently opened first.
38    pub recent: Vec<PathBuf>,
39    /// The one workspace that was open at clean shutdown. `recent` remains the MRU list;
40    /// this is the richer, best-effort state used only when reopening that workspace.
41    pub workspace: Option<RestoredWorkspace>,
42    /// Fields written by a newer build. They remain attached to the session so the next
43    /// legitimate save (for example, recording a newly opened workspace) cannot erase
44    /// data this build does not understand (ADR-0014 §2).
45    #[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/// Persisted pane percentages. Kept in the workspace crate so session persistence does
61/// not make the state crate depend on the UI crate.
62#[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/// Speaker identity for transcript history persisted independently of ACP wire state.
91#[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/// One display-only line from the prior agent session. It is never replayed to ACP.
100#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
101pub struct AgentHistoryLine {
102    pub speaker: AgentHistorySpeaker,
103    pub text: String,
104}
105
106/// Workspace-owned state that can be reconstructed without pretending to resume an OS
107/// process or ACP session (ADR-0014 §4).
108#[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    /// Working directories only. Restore starts fresh shells in these directories.
116    pub terminals: Vec<PathBuf>,
117    /// Read-only display history. It is deliberately separate from a live ACP session.
118    pub agent_history: Vec<AgentHistoryLine>,
119    /// Preserve fields written by a newer build when this build next saves the session.
120    #[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    /// Parse and migrate in memory without ever rewriting as a side effect of loading.
154    /// Unknown fields stay in `unknown`, so a later real save preserves them verbatim.
155    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            // There are no transitions yet; stamping the current version is the complete
181            // v0 -> v1 in-memory migration and is idempotent.
182            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    /// The workspace to reopen when started with no path.
210    pub fn last_root(&self) -> Option<&Path> {
211        self.recent.first().map(PathBuf::as_path)
212    }
213
214    /// Record a workspace as most-recently-used, de-duplicating and capping the list.
215    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    /// Drop entries that no longer exist, so a deleted project stops being offered.
222    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
230/// Service boundary: persist and restore workspace sessions.
231/// Widgets and the agent go through this trait, never the OS directly (ARCHITECTURE.md §7.4).
232pub trait SessionStore {
233    /// Load the stored session. A missing or corrupt file yields a default session —
234    /// losing session state must never stop the editor from starting.
235    fn load(&self) -> Session;
236
237    /// Load with the degradation details needed by the application status surface.
238    /// In-memory stores have no parsing boundary, so their default has no diagnostics.
239    fn load_with_diagnostics(&self) -> (Session, Vec<SessionDiagnostic>) {
240        (self.load(), Vec::new())
241    }
242
243    /// Persist the session. Returns the error rather than panicking; failing to save
244    /// recents is a nuisance, not a crash.
245    fn save(&self, session: &Session) -> Result<(), FsError>;
246}
247
248/// Stores the session as TOML at a fixed path, through the filesystem service.
249pub 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        // `create_file` refuses to clobber, so replace rather than overwrite in place.
302        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/// A store that keeps the session in memory. For tests, and for running with no home
309/// directory — persistence is a convenience, not a requirement.
310#[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        // Losing recents must never stop the editor from starting.
430        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}