Skip to main content

qcode/store/
session.rs

1//! `session.toml`: which workspaces were open, in what order, and the tabs of each, so the home
2//! screen's "Continue" can put the person back where they left off, the way a browser brings
3//! back its windows and their tabs.
4//!
5//! The file is state, not a setting: it lives in the application's data folder, beside nothing
6//! the person edits, and it is rewritten whenever what is open changes. It is still read the way
7//! every file of the family is read — as a [`Document`], so a broken file is reported with its
8//! line and column, what can be read is used and nothing ever panics.
9//!
10//! ```toml
11//! active = "firefly"
12//!
13//! [[workspace]]
14//! id = "firefly"
15//! active-tab = 1
16//!
17//! [[workspace.tab]]
18//! kind = "shell"
19//! opened = 1758196800
20//!
21//! [[workspace.tab]]
22//! kind = "profile"
23//! profile = "claude-sub"
24//! conversation = "4f1c…"
25//! number = 2
26//! name = "reviewer"
27//! opened = 1758197000
28//!
29//! [[workspace.tab]]
30//! kind = "new"
31//! opened = 1758197300
32//!
33//! [[workspace.tab]]
34//! kind = "markdown"
35//! file = "guide/harbour.md"
36//! opened = 1758197400
37//! ```
38//!
39//! A tab keeps its id inside the workspace (`number`) and the name the person gave it (`name`), so
40//! an agent that learnt which tab is which finds them where they were. A file written before tabs
41//! had either brings its tabs back with new ids.
42//!
43//! A tab that opens one of the workspace's files — `image`, `markdown`, `editor`, `pdf`, `office` or
44//! `sound` — names the file
45//! by its path inside `Work/`, written with `/`.
46
47use std::fmt::Write as _;
48use std::io;
49use std::path::{Path, PathBuf};
50
51use qframe::diagnostics::Diagnostic;
52use qframe::document::{Document, Shape, Table, ValueKind};
53use qframe::storage::{atomic_write, data_dir};
54
55use super::workspace::quoted;
56use super::{Loaded, WorkspaceId};
57
58/// The folder of the application's own data, named the way its settings folder is.
59const APP: &str = "quvyta/code";
60
61/// The name of the session file in that folder.
62const FILE: &str = "session.toml";
63
64/// What a workspace entry was called before workspaces had their name.
65const LEGACY_WORKSPACE: &str = "project";
66
67/// What a tab of a saved session opens.
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub enum SessionTabKind {
70    /// A shell in the workspace's own container.
71    Shell,
72    /// The harness of the profile of this name.
73    Profile(String),
74    /// A blank tab, still asking what it should open.
75    New,
76    /// A picture of the workspace, drawn in the workspace's own container, by its path inside
77    /// `Work/`.
78    Image(String),
79    /// A Markdown document of the workspace, shown by QCode itself.
80    Markdown(String),
81    /// A file of the workspace, open in the chosen editor in the workspace's own container.
82    Editor(String),
83    /// A PDF of the workspace, its text shown by QCode and its pages drawn in the workspace's own
84    /// container.
85    Pdf(String),
86    /// A word processor's document of the workspace, its text shown by QCode.
87    Office(String),
88    /// A sound of the workspace, played in a container of its own or described.
89    Sound(String),
90    /// The window of the desktop harness of the profile of this name.
91    Desktop(String),
92}
93
94/// One tab of a saved session.
95#[derive(Debug, Clone, PartialEq, Eq)]
96pub struct SessionTab {
97    /// What the tab opens.
98    pub kind: SessionTabKind,
99    /// The harness conversation the tab was showing, when it is known.
100    pub conversation: Option<String>,
101    /// When the tab was opened, in seconds since the Unix epoch.
102    pub opened: u64,
103    /// The tab's id inside its workspace, when the file says it.
104    pub number: Option<u32>,
105    /// The name the person gave the tab, when they gave it one.
106    pub name: Option<String>,
107}
108
109/// One workspace of a saved session, with its tabs in the order they were shown.
110#[derive(Debug, Clone, PartialEq, Eq)]
111pub struct SessionWorkspace {
112    /// The workspace.
113    pub id: WorkspaceId,
114    /// The position of the tab that was open.
115    pub active_tab: usize,
116    /// The tabs, in order.
117    pub tabs: Vec<SessionTab>,
118}
119
120/// The workspaces that were open, in the order the rail showed them.
121#[derive(Debug, Clone, Default, PartialEq, Eq)]
122pub struct Session {
123    /// The workspace the rail had open, when there was one.
124    pub active: Option<WorkspaceId>,
125    /// The workspaces, in rail order.
126    pub workspaces: Vec<SessionWorkspace>,
127}
128
129impl Session {
130    /// Where the session of this user is kept: `session.toml` in QCode's data folder, or `None`
131    /// on a machine that names no home, where the session is not kept at all.
132    #[must_use]
133    pub fn file() -> Option<PathBuf> {
134        data_dir(APP).map(|dir| dir.join(FILE))
135    }
136
137    /// Whether the session has no workspace to go back to.
138    #[must_use]
139    pub fn is_empty(&self) -> bool {
140        self.workspaces.is_empty()
141    }
142
143    /// The position of the workspace that was open, which is the first one when the file names
144    /// none or names one it does not list.
145    #[must_use]
146    pub fn active_index(&self) -> usize {
147        self.active
148            .as_ref()
149            .and_then(|id| self.workspaces.iter().position(|workspace| workspace.id == *id))
150            .unwrap_or(0)
151    }
152
153    /// Reads the session file at `path`.
154    ///
155    /// A file that is not there is no session and no problem: nothing was saved yet. A file that
156    /// cannot be read is no session either, with a diagnostic that says why.
157    #[must_use]
158    pub fn load(path: &Path) -> Loaded<Option<Self>> {
159        match std::fs::read_to_string(path) {
160            Ok(text) => {
161                let name = path.file_name().and_then(|name| name.to_str()).unwrap_or(FILE);
162                let read = Self::parse(name, &text);
163                Loaded { value: Some(read.value), diagnostics: read.diagnostics }
164            }
165            Err(error) if error.kind() == io::ErrorKind::NotFound => Loaded { value: None, diagnostics: Vec::new() },
166            Err(error) => Loaded {
167                value: None,
168                diagnostics: vec![Diagnostic::error(None, format!("{} could not be read: {error}", path.display()))],
169            },
170        }
171    }
172
173    /// Reads a session from `text`, reporting problems against `file`.
174    ///
175    /// A workspace whose id is unusable and a tab that does not say what it opens are skipped with
176    /// a diagnostic; everything else of the file is kept.
177    #[must_use]
178    pub fn parse(file: &str, text: &str) -> Loaded<Self> {
179        let document = Document::parse(file, text, &shape());
180        let mut diagnostics = document.diagnostics().to_vec();
181        let root = document.root();
182        let active = root.text("active").and_then(|text| id(root, "active", text, &mut diagnostics));
183        // A file has one name or the other, never both: whichever carries entries is the one read.
184        let found = root.entries("workspace");
185        let entries = if found.is_empty() { root.entries(LEGACY_WORKSPACE) } else { found };
186        let workspaces = entries.iter().filter_map(|entry| workspace(entry, &mut diagnostics)).collect();
187        Loaded { value: Self { active, workspaces }, diagnostics }
188    }
189
190    /// The file as it is written to disk.
191    #[must_use]
192    pub fn to_toml(&self) -> String {
193        let mut out = String::new();
194        if let Some(active) = &self.active {
195            let _ = writeln!(out, "active = {}", quoted(active.as_str()));
196        }
197        for workspace in &self.workspaces {
198            let _ = write!(
199                out,
200                "\n[[workspace]]\nid = {}\nactive-tab = {}\n",
201                quoted(workspace.id.as_str()),
202                workspace.active_tab
203            );
204            for tab in &workspace.tabs {
205                out.push_str("\n[[workspace.tab]]\n");
206                match &tab.kind {
207                    SessionTabKind::Shell => out.push_str("kind = \"shell\"\n"),
208                    SessionTabKind::New => out.push_str("kind = \"new\"\n"),
209                    SessionTabKind::Profile(name) => {
210                        let _ = write!(out, "kind = \"profile\"\nprofile = {}\n", quoted(name));
211                    }
212                    SessionTabKind::Image(file) => {
213                        let _ = write!(out, "kind = \"image\"\nfile = {}\n", quoted(file));
214                    }
215                    SessionTabKind::Markdown(file) => {
216                        let _ = write!(out, "kind = \"markdown\"\nfile = {}\n", quoted(file));
217                    }
218                    SessionTabKind::Editor(file) => {
219                        let _ = write!(out, "kind = \"editor\"\nfile = {}\n", quoted(file));
220                    }
221                    SessionTabKind::Pdf(file) => {
222                        let _ = write!(out, "kind = \"pdf\"\nfile = {}\n", quoted(file));
223                    }
224                    SessionTabKind::Office(file) => {
225                        let _ = write!(out, "kind = \"office\"\nfile = {}\n", quoted(file));
226                    }
227                    SessionTabKind::Sound(file) => {
228                        let _ = write!(out, "kind = \"sound\"\nfile = {}\n", quoted(file));
229                    }
230                    SessionTabKind::Desktop(name) => {
231                        let _ = write!(out, "kind = \"desktop\"\nprofile = {}\n", quoted(name));
232                    }
233                }
234                if let Some(conversation) = &tab.conversation {
235                    let _ = writeln!(out, "conversation = {}", quoted(conversation));
236                }
237                if let Some(number) = tab.number {
238                    let _ = writeln!(out, "number = {number}");
239                }
240                if let Some(name) = &tab.name {
241                    let _ = writeln!(out, "name = {}", quoted(name));
242                }
243                let _ = writeln!(out, "opened = {}", tab.opened);
244            }
245        }
246        out
247    }
248
249    /// Writes the session to `path` atomically, making its folder first when it is not there.
250    ///
251    /// # Errors
252    ///
253    /// Returns the I/O error when the folder or the file cannot be written.
254    pub fn save(&self, path: &Path) -> io::Result<()> {
255        if let Some(dir) = path.parent() {
256            std::fs::create_dir_all(dir)?;
257        }
258        atomic_write(path, self.to_toml().as_bytes())
259    }
260}
261
262/// What a `session.toml` holds.
263fn shape() -> Shape {
264    let tab = Shape::new()
265        .required(
266            "kind",
267            ValueKind::choice([
268                "shell", "profile", "new", "image", "markdown", "editor", "pdf", "office", "sound", "desktop",
269            ]),
270        )
271        .optional("profile", ValueKind::text())
272        .optional("file", ValueKind::text())
273        .optional("conversation", ValueKind::text())
274        .optional("number", ValueKind::integer())
275        .optional("name", ValueKind::text())
276        .required("opened", ValueKind::integer());
277    let workspace = Shape::new()
278        .required("id", ValueKind::text())
279        .optional("active-tab", ValueKind::integer())
280        .entries("tab", tab.clone());
281    let legacy =
282        Shape::new().required("id", ValueKind::text()).optional("active-tab", ValueKind::integer()).entries("tab", tab);
283    Shape::new()
284        .optional("active", ValueKind::text())
285        .entries("workspace", workspace)
286        // A session written before workspaces had their name says `project`. It is part of the
287        // document so that such a file is read whole instead of reported as unknown, and the
288        // first write after that puts `workspace` there.
289        .entries(LEGACY_WORKSPACE, legacy)
290}
291
292/// Reads the workspace id under `key`, reporting text that is not one.
293fn id(table: &Table, key: &str, text: &str, diagnostics: &mut Vec<Diagnostic>) -> Option<WorkspaceId> {
294    match WorkspaceId::parse(text) {
295        Ok(id) => Some(id),
296        Err(problem) => {
297            let at = table.value_location(key).cloned();
298            diagnostics
299                .push(Diagnostic::warning(at, format!("`{key}` is not a workspace id: {problem:?}; it is skipped")));
300            None
301        }
302    }
303}
304
305/// One `[[workspace]]` entry, or `None` when it names no workspace.
306fn workspace(entry: &Table, diagnostics: &mut Vec<Diagnostic>) -> Option<SessionWorkspace> {
307    // A missing id was reported by the document as a required key; only a bad one is ours.
308    let id = id(entry, "id", entry.text("id")?, diagnostics)?;
309    let tabs: Vec<SessionTab> = entry.entries("tab").iter().filter_map(|tab| session_tab(tab, diagnostics)).collect();
310    let active_tab = match entry.integer("active-tab").map(usize::try_from) {
311        Some(Ok(index)) if index < tabs.len() || tabs.is_empty() && index == 0 => index,
312        Some(_) => {
313            let at = entry.value_location("active-tab").cloned();
314            diagnostics.push(Diagnostic::warning(at, "`active-tab` names no tab; the first one is open"));
315            0
316        }
317        None => 0,
318    };
319    Some(SessionWorkspace { id, active_tab, tabs })
320}
321
322/// One `[[workspace.tab]]` entry, or `None` when it does not say what it opens.
323fn session_tab(entry: &Table, diagnostics: &mut Vec<Diagnostic>) -> Option<SessionTab> {
324    let kind = match entry.text("kind")? {
325        "shell" => SessionTabKind::Shell,
326        "new" => SessionTabKind::New,
327        kind @ ("image" | "markdown" | "editor" | "pdf" | "office" | "sound") => {
328            let Some(file) = entry.text("file") else {
329                let at = entry.value_location("kind").cloned();
330                diagnostics.push(Diagnostic::warning(at, format!("an {kind} tab names no `file`; it is skipped")));
331                return None;
332            };
333            let file = file.to_owned();
334            match kind {
335                "image" => SessionTabKind::Image(file),
336                "markdown" => SessionTabKind::Markdown(file),
337                "pdf" => SessionTabKind::Pdf(file),
338                "office" => SessionTabKind::Office(file),
339                "sound" => SessionTabKind::Sound(file),
340                _ => SessionTabKind::Editor(file),
341            }
342        }
343        // A window tab and a harness tab are both a profile's, and both are named the same way.
344        // Any other kind that names a profile is read as a harness tab, as it was read before
345        // windows existed.
346        kind => match entry.text("profile") {
347            Some(name) if kind == "desktop" => SessionTabKind::Desktop(name.to_owned()),
348            Some(name) => SessionTabKind::Profile(name.to_owned()),
349            None => {
350                let at = entry.value_location("kind").cloned();
351                diagnostics.push(Diagnostic::warning(at, "a profile tab names no `profile`; it is skipped"));
352                return None;
353            }
354        },
355    };
356    // A missing time was reported by the document; the tab is still worth bringing back.
357    let opened = match entry.integer("opened").map(u64::try_from) {
358        Some(Ok(seconds)) => seconds,
359        Some(Err(_)) => {
360            let at = entry.value_location("opened").cloned();
361            diagnostics.push(Diagnostic::warning(at, "`opened` is before 1970; it is read as unknown"));
362            0
363        }
364        None => 0,
365    };
366    // An id the workspace could never have given is read as none, and the tab is given a new one.
367    let number = match entry.integer("number").map(u32::try_from) {
368        Some(Ok(number)) if number > 0 => Some(number),
369        Some(_) => {
370            let at = entry.value_location("number").cloned();
371            diagnostics.push(Diagnostic::warning(at, "`number` is not a tab's id; the tab is given a new one"));
372            None
373        }
374        None => None,
375    };
376    let name = entry.text("name").map(str::trim).filter(|name| !name.is_empty()).map(str::to_owned);
377    Some(SessionTab { kind, conversation: entry.text("conversation").map(str::to_owned), opened, number, name })
378}
379
380#[cfg(test)]
381mod tests {
382    use super::*;
383
384    const NAME: &str = "session.toml";
385
386    fn located(diagnostic: &Diagnostic) -> String {
387        diagnostic.location.as_ref().map_or_else(|| "nowhere".to_owned(), ToString::to_string)
388    }
389
390    fn id(text: &str) -> WorkspaceId {
391        WorkspaceId::parse(text).expect("a usable workspace id")
392    }
393
394    fn sample() -> Session {
395        Session {
396            active: Some(id("moth")),
397            workspaces: vec![
398                SessionWorkspace {
399                    id: id("firefly"),
400                    active_tab: 1,
401                    tabs: vec![
402                        SessionTab {
403                            kind: SessionTabKind::Shell,
404                            conversation: None,
405                            opened: 1_758_196_800,
406                            number: None,
407                            name: None,
408                        },
409                        SessionTab {
410                            kind: SessionTabKind::Profile("claude-sub".to_owned()),
411                            conversation: Some("4f1c \"quoted\"".to_owned()),
412                            opened: 1_758_197_000,
413                            number: Some(7),
414                            name: Some("the \"reviewer\"".to_owned()),
415                        },
416                        SessionTab {
417                            kind: SessionTabKind::New,
418                            conversation: None,
419                            opened: 1_758_197_300,
420                            number: None,
421                            name: None,
422                        },
423                        SessionTab {
424                            kind: SessionTabKind::Image("art/logo \"one\".png".to_owned()),
425                            conversation: None,
426                            opened: 1_758_197_400,
427                            number: None,
428                            name: None,
429                        },
430                        SessionTab {
431                            kind: SessionTabKind::Markdown("guide/harbour.md".to_owned()),
432                            conversation: None,
433                            opened: 1_758_197_500,
434                            number: None,
435                            name: None,
436                        },
437                        SessionTab {
438                            kind: SessionTabKind::Editor("src/main.rs".to_owned()),
439                            conversation: None,
440                            opened: 1_758_197_600,
441                            number: None,
442                            name: None,
443                        },
444                        SessionTab {
445                            kind: SessionTabKind::Pdf("papers/tide tables.pdf".to_owned()),
446                            conversation: None,
447                            opened: 1_758_197_700,
448                            number: None,
449                            name: None,
450                        },
451                        SessionTab {
452                            kind: SessionTabKind::Office("letters/to the harbour master.docx".to_owned()),
453                            conversation: None,
454                            opened: 1_758_197_800,
455                            number: None,
456                            name: None,
457                        },
458                        SessionTab {
459                            kind: SessionTabKind::Sound("sounds/foghorn.ogg".to_owned()),
460                            conversation: None,
461                            opened: 1_758_197_900,
462                            number: None,
463                            name: None,
464                        },
465                        SessionTab {
466                            kind: SessionTabKind::Desktop("antigravity".to_owned()),
467                            conversation: None,
468                            opened: 1_758_198_000,
469                            number: None,
470                            name: None,
471                        },
472                    ],
473                },
474                SessionWorkspace { id: id("moth"), active_tab: 0, tabs: Vec::new() },
475            ],
476        }
477    }
478
479    #[test]
480    fn a_written_session_reads_back_the_same() {
481        let session = sample();
482        let text = session.to_toml();
483        let read = Session::parse(NAME, &text);
484        assert!(read.is_clean(), "{:?}\n{text}", read.diagnostics);
485        assert_eq!(read.value, session);
486        assert_eq!(read.value.active_index(), 1);
487    }
488
489    #[test]
490    fn saving_makes_the_folder_and_loading_reads_it_back() {
491        let dir = std::env::temp_dir().join(format!("qcode-session-save-{}", std::process::id()));
492        let _ = std::fs::remove_dir_all(&dir);
493        let path = dir.join("deeper").join(NAME);
494        assert_eq!(Session::load(&path), Loaded { value: None, diagnostics: Vec::new() }, "no file is no session");
495        sample().save(&path).expect("the session is written");
496        let read = Session::load(&path);
497        assert!(read.is_clean(), "{:?}", read.diagnostics);
498        assert_eq!(read.value, Some(sample()));
499        let _ = std::fs::remove_dir_all(&dir);
500    }
501
502    #[test]
503    fn an_unreadable_file_is_no_session_and_says_why() {
504        let dir = std::env::temp_dir().join(format!("qcode-session-dir-{}", std::process::id()));
505        std::fs::create_dir_all(&dir).expect("a folder");
506        // A folder where the file should be cannot be read as text.
507        let read = Session::load(&dir);
508        assert_eq!(read.value, None);
509        assert_eq!(read.diagnostics.len(), 1, "{:?}", read.diagnostics);
510        let _ = std::fs::remove_dir_all(&dir);
511    }
512
513    #[test]
514    fn a_syntax_error_points_at_its_line_and_the_rest_is_kept() {
515        let text = "active = \"firefly\"\n\n[[workspace]]\nid = \"firefly\"\nactive-tab = \n\n\
516                    [[workspace.tab]]\nkind = \"shell\"\nopened = 5\n";
517        let read = Session::parse(NAME, text);
518        assert!(read.diagnostics.iter().any(|d| located(d).starts_with("session.toml:5:")), "{:?}", read.diagnostics);
519        assert_eq!(read.value.workspaces.len(), 1);
520        assert_eq!(read.value.workspaces[0].tabs.len(), 1);
521    }
522
523    #[test]
524    fn unknown_and_broken_entries_are_skipped_with_their_place() {
525        let text = "colour = \"red\"\n\n\
526                    [[workspace]]\nid = \"Not An Id\"\n\n\
527                    [[workspace]]\nid = \"moth\"\nactive-tab = 9\n\n\
528                    [[workspace.tab]]\nkind = \"browser\"\nopened = 1\n\n\
529                    [[workspace.tab]]\nkind = \"profile\"\nopened = 2\n\n\
530                    [[workspace.tab]]\nkind = \"profile\"\nprofile = \"opencode\"\nopened = -4\n";
531        let read = Session::parse(NAME, text);
532        let places: Vec<String> = read.diagnostics.iter().map(located).collect();
533        for place in ["session.toml:1:1", "session.toml:4:6", "session.toml:8:14", "session.toml:11:8"] {
534            assert!(places.iter().any(|at| at == place), "{place} in {places:?}");
535        }
536        let session = read.value;
537        assert_eq!(session.workspaces.len(), 1, "the workspace with a bad id is skipped");
538        let moth = &session.workspaces[0];
539        assert_eq!(moth.tabs.len(), 1, "an unknown kind and a profile tab without a profile are skipped");
540        assert_eq!(moth.tabs[0].kind, SessionTabKind::Profile("opencode".to_owned()));
541        assert_eq!(moth.tabs[0].opened, 0, "a time before the epoch is unknown");
542        assert_eq!(moth.active_tab, 0, "an active tab past the end falls back to the first");
543    }
544
545    #[test]
546    fn a_file_tab_without_its_file_is_skipped_with_its_place() {
547        let text = "[[workspace]]\nid = \"moth\"\n\n\
548                    [[workspace.tab]]\nkind = \"markdown\"\nopened = 1\n\n\
549                    [[workspace.tab]]\nkind = \"editor\"\nfile = \"notes.txt\"\nopened = 2\n";
550        let read = Session::parse(NAME, text);
551        let places: Vec<String> = read.diagnostics.iter().map(located).collect();
552        assert_eq!(places, ["session.toml:5:8"], "{:?}", read.diagnostics);
553        let tabs = &read.value.workspaces[0].tabs;
554        assert_eq!(tabs.len(), 1);
555        assert_eq!(tabs[0].kind, SessionTabKind::Editor("notes.txt".to_owned()));
556    }
557
558    #[test]
559    fn a_tab_keeps_its_id_and_name_and_an_id_it_could_not_have_is_dropped() {
560        let text = "[[workspace]]\nid = \"moth\"\n\n\
561                    [[workspace.tab]]\nkind = \"shell\"\nnumber = 4\nname = \"  logs  \"\nopened = 1\n\n\
562                    [[workspace.tab]]\nkind = \"shell\"\nnumber = 0\nopened = 2\n\n\
563                    [[workspace.tab]]\nkind = \"shell\"\nnumber = -3\nname = \"\"\nopened = 3\n";
564        let read = Session::parse(NAME, text);
565        let places: Vec<String> = read.diagnostics.iter().map(located).collect();
566        assert_eq!(places, ["session.toml:12:10", "session.toml:17:10"], "{:?}", read.diagnostics);
567        let tabs = &read.value.workspaces[0].tabs;
568        assert_eq!((tabs[0].number, tabs[0].name.as_deref()), (Some(4), Some("logs")));
569        assert_eq!((tabs[1].number, tabs[1].name.as_deref()), (None, None));
570        assert_eq!((tabs[2].number, tabs[2].name.as_deref()), (None, None), "an empty name is no name");
571    }
572
573    #[test]
574    fn garbage_never_panics() {
575        for text in [
576            "",
577            "\u{0}",
578            "[[workspace]]\n[[workspace.tab]]\n",
579            "workspace = 3\n",
580            "[[workspace]]]]\nid=\n",
581            "active = 7",
582        ] {
583            let read = Session::parse(NAME, text);
584            assert!(read.value.workspaces.iter().all(|workspace| workspace.active_tab <= workspace.tabs.len()));
585        }
586    }
587
588    #[test]
589    fn a_window_of_a_profile_is_one_of_the_tabs_that_come_back() {
590        // A window tab is written like every other tab, so it has to read back like every other
591        // tab: a tab that is saved and then dropped on the way in loses the person's window
592        // without ever saying so.
593        let text = "[[workspace]]\nid = \"moth\"\n\n[[workspace.tab]]\nkind = \"desktop\"\n\
594                    profile = \"antigravity\"\nopened = 12\n";
595        let read = Session::parse(NAME, text);
596        assert!(read.is_clean(), "{:?}", read.diagnostics);
597        let tabs = &read.value.workspaces[0].tabs;
598        assert_eq!(tabs.len(), 1, "the window tab is kept");
599        assert_eq!(tabs[0].kind, SessionTabKind::Desktop("antigravity".to_owned()));
600    }
601
602    #[test]
603    fn an_active_workspace_that_is_not_listed_opens_the_first() {
604        let mut session = sample();
605        session.active = Some(id("gone"));
606        assert_eq!(session.active_index(), 0);
607        session.active = None;
608        assert_eq!(session.active_index(), 0);
609    }
610}