//! The session snapshot: the arrangement that was on screen when the app last //! exited, kept so that quitting in the middle of something is recoverable. //! //! This is the other half of the startup list, and the two are answers to //! different questions. A startup entry is a *recipe* — open this layout //! against these values — and it is a thing you maintain: three worktrees you //! always want, written down once. A snapshot is a *photograph*, taken without //! being asked, of wherever the tabs had actually got to. Neither one can stand //! in for the other, which is why the startup list did not simply grow a //! "remember my tabs" switch: a recipe cannot describe a shell that has been //! `cd`-ed three directories deep, and a photograph is not something you would //! ever sit down and edit. //! //! What is in the photograph is the same split tree a layout holds — the shape, //! the divider ratios, each terminal's working directory, each web pane's URL, //! the repository a review was bound to — plus the name and emoji the row was //! wearing. What is deliberately *not* in it is anything that was running: //! scrollback, shell history, a half-typed command and the processes themselves //! all go when the shells do. Restoring puts the arrangement back and leaves the //! prompts empty, which is the honest version of the feature; a snapshot that //! claimed to bring a build back would be lying about the one thing you would //! check. //! //! It lives in the *state* directory rather than beside `layouts.json` and //! `settings.json` in the config one. Those two are files a person writes; //! this one is written behind their back every few seconds and on every exit, and putting churn like //! that in a config directory — which plenty of people keep in version control //! — would make every quit look like an edit. `$XDG_STATE_HOME` is where the //! spec puts exactly this: state that should carry across restarts but that //! nobody would miss if it went. //! //! The node grammar is `Layouts`', through its `parseNode` and `writeNode`, so //! a snapshot's trees and a layout's trees can never drift into two dialects of //! the same shape. File access goes through GLib for the reasons `Layouts` //! gives: it knows the XDG directories, and `g_file_set_contents` writes to a //! temporary and renames, so a snapshot interrupted half-written leaves the //! previous one intact rather than a truncated file that won't parse. const std = @import("std"); const glib = @import("glib"); const Layouts = @import("Layouts.zig"); const Snapshot = @This(); /// Bumped only if the on-disk shape changes incompatibly. A snapshot is the one /// file here that it would be reasonable to simply drop on a format change — /// the next exit writes a new one — but it carries a version anyway, so that /// dropping it can be a decision rather than a misparse. pub const format_version = 1; const max_path = 4096; /// Ample for any window someone would sit in front of: a tab's tree is a few /// hundred bytes and this allows for thousands of them. A snapshot larger than /// this is not one we wrote, and no banner is a better answer than a partial /// read of one. const max_file_size = 1024 * 1024; /// One tab as it stood at exit. pub const Tab = struct { /// The shape of the tab, in the same grammar a saved layout's `root` uses. /// Every `cwd` and `url` in it is a literal — a snapshot is taken after /// substitution, so there is nothing left in it to expand. root: *Layouts.Node, /// The name pinned on the row, or empty when the label was following /// whatever the panes were reporting. name: []const u8 = "", /// The emoji shown in place of the pane icon, or empty for none. emoji: []const u8 = "", /// The saved layout this tab was opened from, and the values it was opened /// with. Not what restores it — the tree above does that, and it is the more /// faithful record — but the recipe rides along so that a restored tab can /// still be written into the startup list. Without it, restoring a session /// and then asking to keep those tabs at launch would quietly capture a /// window of plain shells. layout: []const u8 = "", values: []const Layouts.Binding = &.{}, }; arena: std.heap.ArenaAllocator, /// The tabs, in sidebar order — which is the order they are put back in. tabs: std.ArrayListUnmanaged(Tab) = .empty, pub fn init(alloc: std.mem.Allocator) Snapshot { return .{ .arena = .init(alloc) }; } pub fn deinit(self: *Snapshot) void { self.tabs.deinit(self.arena.child_allocator); self.arena.deinit(); } fn allocator(self: *Snapshot) std.mem.Allocator { return self.arena.allocator(); } /// Whether there is anything to offer. A snapshot with no tabs in it is not an /// error and not a banner — it is what closing every tab by hand leaves behind. pub fn any(self: *const Snapshot) bool { return self.tabs.items.len > 0; } /// Where a captured tree should be built, so that it lands in this snapshot's /// arena and is freed with it. `View.capture` takes one of these. pub fn builder(self: *Snapshot) Layouts.Builder { return .{ .alloc = self.allocator() }; } /// Record one tab. Every string is copied, so the caller can hand over slices /// borrowed from a tab it is about to destroy — which on the exit path is /// exactly what it is doing. pub fn add(self: *Snapshot, tab: Tab) !void { const alloc = self.allocator(); const values = try alloc.alloc(Layouts.Binding, tab.values.len); for (tab.values, 0..) |v, i| { values[i] = .{ .name = try alloc.dupe(u8, v.name), .value = try alloc.dupe(u8, v.value), }; } try self.tabs.append(self.arena.child_allocator, .{ .root = tab.root, .name = try alloc.dupe(u8, tab.name), .emoji = try alloc.dupe(u8, tab.emoji), .layout = try alloc.dupe(u8, tab.layout), .values = values, }); } // ------------------------------------------------------------------------- // Paths /// `$XDG_STATE_HOME/playpen/session.json`, or `~/.local/state/playpen/...` /// when that isn't set — whichever GLib reports as the user's state dir. fn statePath(buf: []u8) ?[:0]const u8 { const dir = std.mem.span(glib.getUserStateDir()); return std.mem.printSentinel(buf, "{s}/playpen/session.json", .{dir}, 0) catch null; } fn stateDir(buf: []u8) ?[:0]const u8 { const dir = std.mem.span(glib.getUserStateDir()); return std.mem.printSentinel(buf, "{s}/playpen", .{dir}, 0) catch null; } // ------------------------------------------------------------------------- // Loading /// Read the snapshot from disk, replacing whatever is held. /// /// Every failure lands on the same behavior — no tabs, so no offer — because /// there is nothing better to do with one. A snapshot is a convenience that /// nobody asked for; a launch that stopped to complain about being unable to /// read one would be a worse feature than not having it. A file that is there /// and unreadable is logged, since it is about to be overwritten. pub fn load(self: *Snapshot) void { self.tabs.clearRetainingCapacity(); _ = self.arena.reset(.retain_capacity); var path_buf: [max_path]u8 = undefined; const path = statePath(&path_buf) orelse return; var contents: [*]u8 = undefined; var length: usize = 0; var err: ?*glib.Error = null; if (glib.fileGetContents(path.ptr, &contents, &length, &err) == 0) { defer if (err) |e| e.free(); // No file yet is the normal state on a first launch, and after any // launch that ended with every tab closed. if (err) |e| { const missing = e.f_domain == glib.fileErrorQuark() and e.f_code == @intFromEnum(glib.FileError.noent); if (!missing) { std.log.warn("could not read the session snapshot: {s}", .{ e.f_message orelse "unknown", }); } } return; } defer glib.free(contents); if (length > max_file_size) { std.log.warn("the session snapshot is implausibly large; ignoring it", .{}); return; } self.parse(contents[0..length]) catch |parse_err| { std.log.warn("could not parse {s}: {s}", .{ path, @errorName(parse_err) }); // A half-read snapshot is worse than none: it would offer to restore // four tabs of a nine-tab window and look like that was all there was. self.tabs.clearRetainingCapacity(); _ = self.arena.reset(.retain_capacity); }; } fn parse(self: *Snapshot, text: []const u8) Layouts.ParseError!void { const alloc = self.allocator(); // Parsed into the arena and left there: every string kept below points into // this tree, so it has to outlive the call. const parsed = std.json.parseFromSliceLeaky( std.json.Value, alloc, text, .{}, ) catch return error.Malformed; const root = switch (parsed) { .object => |o| o, else => return error.Malformed, }; const list = switch (root.get("tabs") orelse return) { .array => |a| a, else => return error.Malformed, }; for (list.items) |entry| { const obj = switch (entry) { .object => |o| o, else => return error.Malformed, }; var tab: Tab = .{ .root = try Layouts.parseNode( alloc, obj.get("root") orelse return error.Malformed, ), .name = try Layouts.optionalString(alloc, obj.get("name")), .emoji = try Layouts.optionalString(alloc, obj.get("emoji")), .layout = try Layouts.optionalString(alloc, obj.get("layout")), }; if (obj.get("values")) |raw| { const array = switch (raw) { .array => |a| a, else => return error.Malformed, }; const values = try alloc.alloc(Layouts.Binding, array.items.len); for (array.items, 0..) |item, i| { const v = switch (item) { .object => |o| o, else => return error.Malformed, }; values[i] = .{ .name = try Layouts.optionalString(alloc, v.get("name")), .value = try Layouts.optionalString(alloc, v.get("value")), }; } tab.values = values; } // Appended directly rather than through `add`: everything above is // already in this arena, and copying it again would only duplicate it. try self.tabs.append(self.arena.child_allocator, tab); } } // ------------------------------------------------------------------------- // Saving pub const SaveError = error{ OutOfMemory, WriteFailed }; /// Write the snapshot out, creating the state directory if needed. /// /// A snapshot with no tabs in it *removes* the file rather than writing an empty /// one. Closing every tab is how the window is quit deliberately, and there is /// nothing about that state worth offering to restore — so the offer should not /// appear at the next launch, and the stale one it would otherwise still be /// holding should not either. /// /// `written` is a hash of what the caller last had written, and is updated to /// this one's. A snapshot that would write the same bytes again is skipped: the /// window is photographed every few seconds while it is open, and nearly every /// one of those photographs is the same as the last. pub fn save(self: *Snapshot, written: *u64) SaveError!void { var path_buf: [max_path]u8 = undefined; const path = statePath(&path_buf) orelse return error.WriteFailed; if (!self.any()) { remove(path); written.* = 0; return; } const text = try self.serialize(); defer self.arena.child_allocator.free(text); const hash = std.hash.Wyhash.hash(0, text); if (hash == written.*) return; var dir_buf: [max_path]u8 = undefined; const dir = stateDir(&dir_buf) orelse return error.WriteFailed; if (glib.mkdirWithParents(dir.ptr, 0o700) != 0) return error.WriteFailed; var err: ?*glib.Error = null; if (glib.fileSetContents(path.ptr, text.ptr, @intCast(text.len), &err) == 0) { if (err) |e| e.free(); return error.WriteFailed; } written.* = hash; } /// Delete the file. Failures are ignored on purpose: the common one is it not /// being there, which is the state this is trying to reach, and the rest happen /// while the process is on its way out with nowhere left to report them. /// /// Through libc rather than GLib, which exposes `g_unlink` only through the /// gstdio header — not the introspected API these bindings are generated from. fn remove(path: [:0]const u8) void { _ = std.c.unlink(path.ptr); } fn serialize(self: *Snapshot) SaveError![]u8 { var out: std.Io.Writer.Allocating = .init(self.arena.child_allocator); errdefer out.deinit(); var json: std.json.Stringify = .{ .writer = &out.writer, .options = .{ .whitespace = .indent_2 }, }; try json.beginObject(); try json.objectField("version"); try json.write(format_version); try json.objectField("tabs"); try json.beginArray(); for (self.tabs.items) |tab| { try json.beginObject(); // Only what is set. The file is not meant to be hand-edited, but it is // very much meant to be *read* when a restore comes back wrong, and a // page of empty strings is the enemy of that. if (tab.name.len > 0) { try json.objectField("name"); try json.write(tab.name); } if (tab.emoji.len > 0) { try json.objectField("emoji"); try json.write(tab.emoji); } if (tab.layout.len > 0) { try json.objectField("layout"); try json.write(tab.layout); } if (tab.values.len > 0) { try json.objectField("values"); try json.beginArray(); for (tab.values) |v| { try json.beginObject(); try json.objectField("name"); try json.write(v.name); try json.objectField("value"); try json.write(v.value); try json.endObject(); } try json.endArray(); } try json.objectField("root"); try Layouts.writeNode(&json, tab.root); try json.endObject(); } try json.endArray(); try json.endObject(); // A trailing newline, so the file behaves in an editor. try out.writer.writeByte('\n'); return out.toOwnedSlice(); } // ------------------------------------------------------------------------- // Tests // // Reading and writing the file, minus the file. `parse` and `serialize` are // where the on-disk shape lives, and they need nothing but an allocator. // // What these are really here for is the round trip. A snapshot is written once // per exit and read once per launch, by code that never runs in the same // process — so a field that survives the write and not the read costs a whole // session and shows up as a tab quietly coming back wrong. const testing = std.testing; test "a snapshot round-trips" { var snapshot: Snapshot = .init(testing.allocator); defer snapshot.deinit(); try snapshot.parse( \\{"version":1,"tabs":[ \\ {"name":"deploy","emoji":"🚀","layout":"Work", \\ "values":[{"name":"path","value":"/src/app"}], \\ "root":{"split":"horizontal","ratio":0.25, \\ "first":{"kind":"terminal","cwd":"/src/app"}, \\ "second":{"kind":"review","cwd":"/src/app"}}}, \\ {"root":{"kind":"web","url":"https://example.com"}}]} ); try testing.expectEqual(@as(usize, 2), snapshot.tabs.items.len); try testing.expect(snapshot.any()); const first = snapshot.tabs.items[0]; try testing.expectEqualStrings("deploy", first.name); try testing.expectEqualStrings("🚀", first.emoji); try testing.expectEqualStrings("Work", first.layout); try testing.expectEqual(@as(usize, 1), first.values.len); try testing.expectEqualStrings("path", first.values[0].name); try testing.expectEqualStrings("/src/app", first.values[0].value); // The tree comes back through the same grammar a layout's does, ratio and // all — the divider you left is the divider you get. const split = first.root.split; try testing.expectEqual(Layouts.Orientation.horizontal, split.orientation); try testing.expectEqual(@as(f64, 0.25), split.ratio); try testing.expectEqualStrings("/src/app", split.first.pane.cwd); try testing.expectEqual(Layouts.Kind.review, split.second.pane.kind); // A tab that was wearing nothing says nothing, rather than coming back with // an empty name pinned on it — which would stop the label following the // panes for good. const second = snapshot.tabs.items[1]; try testing.expectEqualStrings("", second.name); try testing.expectEqualStrings("", second.emoji); try testing.expectEqualStrings("", second.layout); try testing.expectEqual(@as(usize, 0), second.values.len); try testing.expectEqualStrings("https://example.com", second.root.pane.url); // And it all survives being written back out, which is the half of the trip // the next launch depends on. const text = try snapshot.serialize(); defer testing.allocator.free(text); var reread: Snapshot = .init(testing.allocator); defer reread.deinit(); try reread.parse(text); try testing.expectEqual(@as(usize, 2), reread.tabs.items.len); try testing.expectEqualStrings("deploy", reread.tabs.items[0].name); try testing.expectEqualStrings("🚀", reread.tabs.items[0].emoji); try testing.expectEqualStrings("Work", reread.tabs.items[0].layout); try testing.expectEqualStrings("path", reread.tabs.items[0].values[0].name); try testing.expectEqual(@as(f64, 0.25), reread.tabs.items[0].root.split.ratio); try testing.expectEqualStrings( "https://example.com", reread.tabs.items[1].root.pane.url, ); } // Everything a tab was not wearing stays out of the file. It is not read by // hand often, but the once it is will be because a restore came back wrong, and // a page of empty strings is the enemy of that. test "a bare tab writes only its tree" { var snapshot: Snapshot = .init(testing.allocator); defer snapshot.deinit(); const root = try snapshot.builder().pane(.{ .kind = .terminal, .cwd = "/tmp" }); try snapshot.add(.{ .root = root }); const text = try snapshot.serialize(); defer testing.allocator.free(text); try testing.expect(std.mem.indexOf(u8, text, "\"name\"") == null); try testing.expect(std.mem.indexOf(u8, text, "\"emoji\"") == null); try testing.expect(std.mem.indexOf(u8, text, "\"layout\"") == null); try testing.expect(std.mem.indexOf(u8, text, "\"values\"") == null); try testing.expect(std.mem.indexOf(u8, text, "\"cwd\": \"/tmp\"") != null); } // `add` copies, because the exit path hands it strings borrowed from tabs it is // about to destroy. A snapshot that pointed back into them would serialize // freed memory — and only on the way out, where nobody is watching. test "add copies what it is given" { var snapshot: Snapshot = .init(testing.allocator); defer snapshot.deinit(); var name: [6]u8 = "deploy".*; const root = try snapshot.builder().pane(.{ .kind = .terminal }); try snapshot.add(.{ .root = root, .name = &name, .values = &.{.{ .name = "path", .value = "/src" }}, }); @memset(&name, 'x'); try testing.expectEqualStrings("deploy", snapshot.tabs.items[0].name); try testing.expectEqualStrings("/src", snapshot.tabs.items[0].values[0].value); } // A file that isn't a snapshot costs the offer and nothing else. `load` turns // this into an empty list, which is the same state a first launch is in. test "a malformed snapshot is refused" { var snapshot: Snapshot = .init(testing.allocator); defer snapshot.deinit(); try testing.expectError(error.Malformed, snapshot.parse("[1,2,3]")); try testing.expectError(error.Malformed, snapshot.parse( \\{"version":1,"tabs":[{"name":"x"}]} )); } // A snapshot with nothing in it is not an offer. Closing every tab is how the // window gets quit deliberately, and there is nothing about that worth putting // a banner up for at the next launch. test "an empty snapshot offers nothing" { var snapshot: Snapshot = .init(testing.allocator); defer snapshot.deinit(); try snapshot.parse( \\{"version":1,"tabs":[]} ); try testing.expect(!snapshot.any()); // And a file with no `tabs` at all is the same thing, not a parse failure: // it is what an older or hand-emptied file looks like. try snapshot.parse( \\{"version":1} ); try testing.expect(!snapshot.any()); }