526 lines
21 KiB
Zig
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());
|
|
}
|