Add ability to restore previous layout.
This commit is contained in:
1 parent
5cb9bb117a
commit
0bf85b3b2a
6 files changed
+1029
-33
No files matched your search
@@ -0,0 +1,515 @@
|
||||
//! 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 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.fmt.bufPrintZ(buf, "{s}/playpen/session.json", .{dir}) catch null;
|
||||
}
|
||||
|
||||
fn stateDir(buf: []u8) ?[:0]const u8 {
|
||||
const dir = std.mem.span(glib.getUserStateDir());
|
||||
return std.fmt.bufPrintZ(buf, "{s}/playpen", .{dir}) 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.
|
||||
pub fn save(self: *Snapshot) SaveError!void {
|
||||
var path_buf: [max_path]u8 = undefined;
|
||||
const path = statePath(&path_buf) orelse return error.WriteFailed;
|
||||
|
||||
if (!self.any()) {
|
||||
remove(path);
|
||||
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;
|
||||
|
||||
const text = try self.serialize();
|
||||
defer self.arena.child_allocator.free(text);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// 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());
|
||||
}
|
||||
Reference in new issue
Block a user