1use 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
58const APP: &str = "quvyta/code";
60
61const FILE: &str = "session.toml";
63
64const LEGACY_WORKSPACE: &str = "project";
66
67#[derive(Debug, Clone, PartialEq, Eq)]
69pub enum SessionTabKind {
70 Shell,
72 Profile(String),
74 New,
76 Image(String),
79 Markdown(String),
81 Editor(String),
83 Pdf(String),
86 Office(String),
88 Sound(String),
90 Desktop(String),
92}
93
94#[derive(Debug, Clone, PartialEq, Eq)]
96pub struct SessionTab {
97 pub kind: SessionTabKind,
99 pub conversation: Option<String>,
101 pub opened: u64,
103 pub number: Option<u32>,
105 pub name: Option<String>,
107}
108
109#[derive(Debug, Clone, PartialEq, Eq)]
111pub struct SessionWorkspace {
112 pub id: WorkspaceId,
114 pub active_tab: usize,
116 pub tabs: Vec<SessionTab>,
118}
119
120#[derive(Debug, Clone, Default, PartialEq, Eq)]
122pub struct Session {
123 pub active: Option<WorkspaceId>,
125 pub workspaces: Vec<SessionWorkspace>,
127}
128
129impl Session {
130 #[must_use]
133 pub fn file() -> Option<PathBuf> {
134 data_dir(APP).map(|dir| dir.join(FILE))
135 }
136
137 #[must_use]
139 pub fn is_empty(&self) -> bool {
140 self.workspaces.is_empty()
141 }
142
143 #[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 #[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 #[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 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 #[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 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
262fn 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 .entries(LEGACY_WORKSPACE, legacy)
290}
291
292fn 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
305fn workspace(entry: &Table, diagnostics: &mut Vec<Diagnostic>) -> Option<SessionWorkspace> {
307 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
322fn 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 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 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 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 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 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}