Files
playpen/src/Snapshot.zig
T
2026-10-28 09:22:29 -04:00

526 lines
21 KiB
Zig

//! 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());
}