Add ability to restore previous layout.

This commit is contained in:
Greyson Parrelli
2026-08-27 10:55:04 -04:00
parent 5cb9bb117a
commit 0bf85b3b2a
6 changed files with 1029 additions and 33 deletions
+80
View File
@@ -441,6 +441,81 @@ GPU. Here, each frame walks the visible rows, groups cells into runs of
identical style, and hands each run to Pango. That is far more work per frame
in principle, but a terminal grid is small.
## Restoring a session
Quitting in the middle of something should not cost you the arrangement you were
in the middle of. Every exit writes a **session snapshot** — a photograph of the
window as it stood — and the next launch that finds one puts a banner at the foot
of the sidebar: *Restore session*, and how many tabs are in it. Click it and
those tabs come back; press the ✕ and it goes away. Doing nothing is the same as
dismissing it.
This is the counterpart to the [startup list](#startup-tabs), not a replacement
for it, and the difference is the whole point. A startup entry is a **recipe** you
maintain: open this layout against these values, written down once because you
want it every morning. A snapshot is a **photograph** nobody asked for, of
wherever the tabs had actually got to. 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 — so the app keeps both.
What is in the photograph is each tab's split tree: the shape, the divider
ratios, every terminal's working directory, every web pane's URL, the repository
a review was bound to, and 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, the processes themselves. Restoring puts the
arrangement back and leaves the prompts empty. That is the honest version of the
feature: a snapshot that claimed to bring a build back would be lying about the
first thing you would check.
Restoring **replaces** the tabs the window opened for itself, rather than adding
to them — a window holding both your startup tabs and the session they were
standing in for is two of everything. That is also why the offer only stands
until the tab set changes: open or close a tab yourself and the banner goes,
because "the tabs the window opened" has stopped being a set anyone can point at,
and closing it would be closing your work. The banner is the launch-time gesture
it looks like.
Closing every tab by hand takes the snapshot with it. That route out is a
deliberate one — there is nothing left worth offering to put back, and a banner
at the next launch offering the session before that would be answering a question
nobody asked.
The file is `session.json`, and it lives in the **state** directory rather than
beside `layouts.json` and `settings.json`:
```
~/.local/state/playpen/session.json ($XDG_STATE_HOME/playpen/session.json)
```
Those two are files a person writes; this one is written behind your back on
every exit, and putting that much churn in a config directory — which plenty of
people keep in version control — would make every quit look like an edit. Its
per-tab `root` is the same split-tree grammar a saved layout's is, so the two can
never drift into dialects of one shape, with the difference that a snapshot is
taken *after* parameter substitution: every path in it is literal, because it is
the directory a shell was really sitting in.
```json
{
"version": 1,
"tabs": [
{ "name": "signal", "emoji": "🚀", "layout": "Project",
"root": {
"split": "horizontal", "ratio": 0.35,
"first": { "kind": "terminal", "cwd": "/home/you/src/signal" },
"second": { "kind": "review", "cwd": "/home/you/src/signal" }
} },
{ "name": "scratch",
"root": { "kind": "terminal", "cwd": "/home/you" } }
]
}
```
The `layout` a tab came from rides along even though it is not what reopens it.
It is there so that a restored window can still be captured by *Use current
tabs* — without it, restoring a session and then asking to keep those tabs at
launch would quietly write down a window of plain shells.
## Quitting
Closing the window asks first, and does so by default. A window here is not one
@@ -509,6 +584,11 @@ to closing the moment the window manager says so:
- **A startup list**: the tabs to open at launch, each a layout with its
parameters filled in, fillable from the tabs you have open now. See
[Startup tabs](#startup-tabs)
- **A session to pick up where you left off**: every exit photographs the
window, and the next launch offers it back from a banner at the foot of the
sidebar — click to reopen those tabs, or dismiss it and carry on. Unlike the
startup list this is the arrangement you actually had, directories and all.
See [Restoring a session](#restoring-a-session)
- **Pane status in the tab strip**, driven by OSC 9;4, so a tab can say whether
it is working, waiting on you, or finished and still unanswered. See
[Agent status](#agent-status)
+16
View File
@@ -134,6 +134,22 @@ pub fn build(b: *std.Build) void {
const test_step = b.step("test", "Run the tests");
test_step.dependOn(&b.addRunArtifact(tests).step);
// The session snapshot is its own root for the same reason, and is the same
// kind of thing: a file the app writes and reads back, whose shape is worth
// being sure about. It imports Layouts.zig for the node grammar the two
// share, and needs libc for the one call GLib does not expose — see
// `Snapshot.remove`.
const snapshot_tests = b.addTest(.{
.root_module = b.createModule(.{
.root_source_file = b.path("src/Snapshot.zig"),
.target = target,
.optimize = optimize,
.link_libc = true,
}),
});
snapshot_tests.root_module.addImport("glib", gobject.module("glib2"));
test_step.dependOn(&b.addRunArtifact(snapshot_tests).step);
// The directory field's rules are their own root, for the same reason as
// the layouts file: which directory a half-typed path names, which entries
// in it are offered and how far they agree is text and filesystem, and the
+36 -22
View File
@@ -198,7 +198,7 @@ fn setLoadError(self: *Layouts, comptime fmt: []const u8, args: anytype) void {
self.load_error = std.fmt.allocPrint(self.allocator(), fmt, args) catch "layouts failed to load";
}
const ParseError = error{ OutOfMemory, Malformed };
pub const ParseError = error{ OutOfMemory, Malformed };
fn parse(self: *Layouts, text: []const u8) ParseError!void {
// Parsed into the arena and left there: every string we keep points into
@@ -234,8 +234,8 @@ fn parseLayout(self: *Layouts, obj: std.json.ObjectMap) ParseError!void {
const layout = try alloc.create(Layout);
layout.* = .{
.name = try self.dupeString(obj.get("name") orelse return error.Malformed),
.root = try self.parseNode(obj.get("root") orelse return error.Malformed),
.name = try dupeString(alloc, obj.get("name") orelse return error.Malformed),
.root = try parseNode(alloc, obj.get("root") orelse return error.Malformed),
};
if (obj.get("parameters")) |raw| {
@@ -250,9 +250,9 @@ fn parseLayout(self: *Layouts, obj: std.json.ObjectMap) ParseError!void {
else => return error.Malformed,
};
params[i] = .{
.name = try self.dupeString(p.get("name") orelse return error.Malformed),
.description = try self.optionalString(p.get("description")),
.default = try self.optionalString(p.get("default")),
.name = try dupeString(alloc, p.get("name") orelse return error.Malformed),
.description = try optionalString(alloc, p.get("description")),
.default = try optionalString(alloc, p.get("default")),
.type = parseType(p.get("type")),
};
}
@@ -262,13 +262,20 @@ fn parseLayout(self: *Layouts, obj: std.json.ObjectMap) ParseError!void {
try self.items.append(self.arena.child_allocator, layout);
}
fn parseNode(self: *Layouts, raw: std.json.Value) ParseError!*Node {
/// Read one node of a split tree, and everything under it.
///
/// Public, and over a plain allocator rather than a `*Layouts`, because this
/// grammar is written by two files rather than one: a layout's `root` and a
/// session snapshot's per-tab tree are the same shape, and `Snapshot` reads
/// them with this. Everything it returns is allocated from `alloc` and freed
/// with it — an arena at both call sites.
pub fn parseNode(alloc: std.mem.Allocator, raw: std.json.Value) ParseError!*Node {
const obj = switch (raw) {
.object => |o| o,
else => return error.Malformed,
};
const node = try self.allocator().create(Node);
const node = try alloc.create(Node);
// A node is a split if it says which way it splits; otherwise it is a leaf.
if (obj.get("split")) |split_raw| {
@@ -283,8 +290,8 @@ fn parseNode(self: *Layouts, raw: std.json.Value) ParseError!*Node {
node.* = .{ .split = .{
.orientation = orientation,
.ratio = clampRatio(numberOr(obj.get("ratio"), 0.5)),
.first = try self.parseNode(obj.get("first") orelse return error.Malformed),
.second = try self.parseNode(obj.get("second") orelse return error.Malformed),
.first = try parseNode(alloc, obj.get("first") orelse return error.Malformed),
.second = try parseNode(alloc, obj.get("second") orelse return error.Malformed),
} };
return node;
}
@@ -294,9 +301,9 @@ fn parseNode(self: *Layouts, raw: std.json.Value) ParseError!*Node {
.string => |s| std.meta.stringToEnum(Kind, s) orelse return error.Malformed,
else => return error.Malformed,
} else .terminal,
.cwd = try self.optionalString(obj.get("cwd")),
.command = try self.optionalString(obj.get("command")),
.url = try self.optionalString(obj.get("url")),
.cwd = try optionalString(alloc, obj.get("cwd")),
.command = try optionalString(alloc, obj.get("command")),
.url = try optionalString(alloc, obj.get("url")),
} };
return node;
}
@@ -331,16 +338,18 @@ fn numberOr(raw: ?std.json.Value, fallback: f64) f64 {
};
}
fn dupeString(self: *Layouts, raw: std.json.Value) ParseError![]const u8 {
fn dupeString(alloc: std.mem.Allocator, raw: std.json.Value) ParseError![]const u8 {
return switch (raw) {
.string => |s| try self.allocator().dupe(u8, s),
.string => |s| try alloc.dupe(u8, s),
else => error.Malformed,
};
}
fn optionalString(self: *Layouts, raw: ?std.json.Value) ParseError![]const u8 {
/// A string that need not be there. Absent and null both read as empty, which
/// is what every optional field in these files means by saying nothing.
pub fn optionalString(alloc: std.mem.Allocator, raw: ?std.json.Value) ParseError![]const u8 {
return switch (raw orelse return "") {
.string => |s| try self.allocator().dupe(u8, s),
.string => |s| try alloc.dupe(u8, s),
.null => "",
else => error.Malformed,
};
@@ -429,7 +438,9 @@ fn writeLayout(json: *std.json.Stringify, layout: *const Layout) SaveError!void
try json.endObject();
}
fn writeNode(json: *std.json.Stringify, node: *const Node) SaveError!void {
/// Write one node of a split tree. The reading half of `parseNode`, public for
/// the same reason it is.
pub fn writeNode(json: *std.json.Stringify, node: *const Node) SaveError!void {
try json.beginObject();
switch (node.*) {
.split => |s| {
@@ -470,10 +481,13 @@ fn writeNode(json: *std.json.Stringify, node: *const Node) SaveError!void {
// into the arena. Callers hand over borrowed strings and forget about them.
pub const Builder = struct {
layouts: *Layouts,
/// Where the built tree lives. An allocator rather than a `*Layouts`
/// because "save tab as layout" and the session snapshot capture the same
/// tree into different arenas, and neither wants the other's.
alloc: std.mem.Allocator,
pub fn node(self: Builder, value: Node) !*Node {
const n = try self.layouts.allocator().create(Node);
const n = try self.alloc.create(Node);
n.* = value;
return n;
}
@@ -503,12 +517,12 @@ pub const Builder = struct {
}
pub fn dupe(self: Builder, text: []const u8) ![]const u8 {
return self.layouts.allocator().dupe(u8, text);
return self.alloc.dupe(u8, text);
}
};
pub fn builder(self: *Layouts) Builder {
return .{ .layouts = self };
return .{ .alloc = self.allocator() };
}
/// Add a layout, replacing any existing one with the same name.
+515
View File
@@ -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());
}
+324 -11
View File
@@ -22,6 +22,7 @@ const Review = @import("Review.zig");
const SaveLayoutDialog = @import("SaveLayoutDialog.zig");
const Settings = @import("Settings.zig");
const SettingsDialog = @import("SettingsDialog.zig");
const Snapshot = @import("Snapshot.zig");
const TabSettingsDialog = @import("TabSettingsDialog.zig");
const Terminal = @import("Terminal.zig");
const View = @import("View.zig");
@@ -133,6 +134,24 @@ layout_popover: *gtk.Popover,
/// they belong to are on screen.
layout_rows: std.ArrayListUnmanaged(*LayoutRow) = .empty,
/// The arrangement the last session exited with, read once at launch. Empty
/// on a first run, and after any launch that ended with every tab closed.
snapshot: Snapshot,
/// The banner at the foot of the sidebar offering that snapshot back, and the
/// line under its title saying how much there is to put back.
///
/// Built with the sidebar and hidden until there is something to offer, rather
/// than created when the offer arrives: a launch is already busy opening tabs,
/// and this has no state worth building twice.
restore_banner: *gtk.Box,
restore_detail: *gtk.Label,
/// Whether the offer is currently standing. Separate from the banner's own
/// visibility because a collapsed sidebar hides the banner without answering
/// it — see `applyRestoreVisible`.
restore_offered: bool = false,
/// What a sidebar row is signaling. Defined with the dots themselves, since
/// a row and a pane header show the same five states for the same reasons.
const Attention = Pane.Attention;
@@ -274,6 +293,9 @@ pub fn create(alloc: std.mem.Allocator, app: *adw.Application) !*Window {
.layout_button = gtk.MenuButton.new(),
.footer = gtk.Box.new(.horizontal, 0),
.collapse_button = gtk.Button.newFromIconName("go-previous-symbolic"),
.snapshot = .init(alloc),
.restore_banner = gtk.Box.new(.horizontal, 4),
.restore_detail = gtk.Label.new(null),
};
self.layouts.load();
if (self.layouts.load_error) |message| std.log.warn("{s}", .{message});
@@ -352,6 +374,12 @@ pub fn create(alloc: std.mem.Allocator, app: *adw.Application) !*Window {
scroller.setChild(self.list.as(gtk.Widget));
sidebar.append(scroller.as(gtk.Widget));
// Under the tab list and above the footer. It is an offer about the tabs, so
// it belongs against them rather than up by the new-tab button — and at the
// foot it is out of the way of the list on every launch that has nothing to
// offer, which is most of them.
sidebar.append(self.buildRestoreBanner());
// Settings sit at the foot of the sidebar rather than in its header. The
// header holds the two things you reach for constantly — a new tab and a
// saved layout — and a preferences button is the opposite of that: opened
@@ -462,6 +490,11 @@ pub fn create(alloc: std.mem.Allocator, app: *adw.Application) !*Window {
try self.openStartupTabs();
// After the startup tabs, not before: the offer is withdrawn by anything
// that changes the tab set, and the window opening its own tabs would
// otherwise withdraw it before it was ever made.
self.offerRestore();
return self;
}
@@ -479,6 +512,8 @@ pub fn present(self: *Window) void {
/// Open a new tab holding a single terminal — the plain case, unchanged by
/// layouts existing.
pub fn newTab(self: *Window) !void {
self.withdrawRestoreOffer();
const tab = try self.newTabEmpty();
// Only once the tab is in `self.tabs` is it complete enough for the
@@ -992,6 +1027,8 @@ fn onLayoutParameters(
/// Open a layout in a new tab and go to it.
fn openLayout(self: *Window, layout: *Layouts.Layout, bindings: []const Layouts.Binding) void {
self.withdrawRestoreOffer();
const tab = self.buildLayoutTab(layout, bindings) catch |err| {
std.log.err("failed to open layout \"{s}\": {s}", .{ layout.name, @errorName(err) });
return;
@@ -1160,7 +1197,7 @@ fn openStartupTab(self: *Window, entry: Settings.StartupTab) void {
return;
} orelse return;
self.applyStartupChrome(tab, entry);
self.applyTabChrome(tab, entry.name, entry.emoji);
self.refreshLabel(tab);
}
@@ -1228,23 +1265,27 @@ fn namesValue(values: []const Settings.Value, name: []const u8) bool {
return false;
}
/// The name and emoji an entry pins on its tab, both behaving exactly as though
/// they had been set by hand once it was open.
fn applyStartupChrome(self: *Window, tab: *Tab, entry: Settings.StartupTab) void {
if (entry.name.len > 0) {
tab.custom_name = self.alloc.dupe(u8, entry.name) catch |err| blk: {
std.log.warn("could not name startup tab: {s}", .{@errorName(err)});
/// The name and emoji a saved entry pins on its tab, both behaving exactly as
/// though they had been set by hand once it was open.
///
/// Shared by the startup list and the session snapshot, which record the same
/// two strings for the same reason: a tab you named "deploy" should still say
/// so when it comes back, whichever of the two files brought it back.
fn applyTabChrome(self: *Window, tab: *Tab, name: []const u8, glyph: []const u8) void {
if (name.len > 0) {
tab.custom_name = self.alloc.dupe(u8, name) catch |err| blk: {
std.log.warn("could not name a reopened tab: {s}", .{@errorName(err)});
break :blk null;
};
}
// Resolved against the emoji table rather than copied, because a row holds
// a pointer into that table and nothing else. A glyph that isn't in it is
// a hand-edited file naming something this build can't draw.
if (entry.emoji.len > 0) {
tab.emoji = emoji.lookup(entry.emoji);
// a file naming something this build can't draw.
if (glyph.len > 0) {
tab.emoji = emoji.lookup(glyph);
if (tab.emoji == null) {
std.log.warn("startup: \"{s}\" is not an emoji this build knows", .{entry.emoji});
std.log.warn("\"{s}\" is not an emoji this build knows", .{glyph});
}
}
}
@@ -1339,6 +1380,269 @@ fn captureStartupTabs(ctx: ?*anyopaque) void {
};
}
// -------------------------------------------------------------------------
// The session snapshot
//
// Every exit photographs the window — see `Snapshot` for what is in the
// photograph and what deliberately is not — and every launch that finds one
// offers it back from a banner at the foot of the sidebar. The offer is
// optional in both directions: dismissing it costs nothing, and taking it is a
// single click rather than a dialog.
//
// The offer stands only until the tab set changes. That is the price of
// restoring *over* the startup tabs rather than beside them, and it is worth
// paying: a window holding both your startup tabs and the session they were
// standing in for is two of everything, and nobody wants to close half a window
// by hand. But "the tabs the window opened for itself" is a set that only
// exists for as long as nobody has touched it — the moment you open or close one
// yourself, closing that set would be closing your work. So `newTab`,
// `openLayout` and `closeTab` all take the offer down, and the banner is only
// ever the launch-time gesture it looks like.
/// The banner: a row that puts the last session back, and a ✕ that says no.
fn buildRestoreBanner(self: *Window) *gtk.Widget {
const banner = self.restore_banner;
banner.as(gtk.Widget).addCssClass("playpen-restore");
banner.as(gtk.Widget).setVisible(0);
// The whole row is the button rather than a "Restore" beside a description
// of what it would do. There is one action here, and a banner whose text is
// inert invites a click on the half of itself that does nothing.
const action = gtk.Button.new();
action.as(gtk.Widget).addCssClass("flat");
action.as(gtk.Widget).addCssClass("playpen-restore-action");
action.as(gtk.Widget).setHexpand(1);
action.setHasFrame(0);
action.as(gtk.Widget).setTooltipText("Reopen the tabs this window had when it last closed");
const content = gtk.Box.new(.horizontal, 8);
const icon = gtk.Image.newFromIconName("view-refresh-symbolic");
icon.as(gtk.Widget).addCssClass("playpen-restore-icon");
content.append(icon.as(gtk.Widget));
const text = gtk.Box.new(.vertical, 0);
text.as(gtk.Widget).setHexpand(1);
const title = gtk.Label.new("Restore session");
title.setXalign(0);
title.as(gtk.Widget).addCssClass("playpen-restore-title");
text.append(title.as(gtk.Widget));
// Filled in by `offerRestore`, which is the only moment the count is known.
self.restore_detail.setXalign(0);
self.restore_detail.setEllipsize(.end);
self.restore_detail.as(gtk.Widget).addCssClass("playpen-restore-detail");
text.append(self.restore_detail.as(gtk.Widget));
content.append(text.as(gtk.Widget));
action.setChild(content.as(gtk.Widget));
_ = gtk.Button.signals.clicked.connect(
action,
*Window,
&onRestoreClicked,
self,
.{},
);
banner.append(action.as(gtk.Widget));
const dismiss = gtk.Button.newFromIconName("window-close-symbolic");
dismiss.as(gtk.Widget).addCssClass("flat");
dismiss.as(gtk.Widget).addCssClass("playpen-restore-dismiss");
dismiss.as(gtk.Widget).setValign(.center);
dismiss.as(gtk.Widget).setTooltipText("Dismiss");
_ = gtk.Button.signals.clicked.connect(
dismiss,
*Window,
&onRestoreDismissed,
self,
.{},
);
banner.append(dismiss.as(gtk.Widget));
return banner.as(gtk.Widget);
}
/// Read the snapshot and, if there is anything in it, put the offer up.
fn offerRestore(self: *Window) void {
self.snapshot.load();
if (!self.snapshot.any()) return;
// The count is the whole of what the banner can honestly promise, and it is
// also what tells you whether this is the session you meant. The buffer is
// sized so the format cannot fail; the fallback still says what the offer
// is, just without the number.
const count = self.snapshot.tabs.items.len;
var buf: [64]u8 = undefined;
const detail: [:0]const u8 = std.fmt.bufPrintZ(&buf, "{d} tab{s} from last time", .{
count,
if (count == 1) "" else "s",
}) catch "from last time";
self.restore_detail.setText(detail.ptr);
self.restore_offered = true;
self.applyRestoreVisible();
}
/// Take the offer down for the rest of this launch.
///
/// The snapshot file itself is left alone. Nothing needs to delete it — the next
/// exit overwrites it — and leaving it means a dismissal followed by a crash
/// still has last session's tabs to offer, which is the direction to err in.
fn withdrawRestoreOffer(self: *Window) void {
if (!self.restore_offered) return;
self.restore_offered = false;
self.applyRestoreVisible();
}
/// The banner shows while the offer stands *and* the sidebar is wide enough to
/// read it. At the emoji column there is no version of this that is smaller
/// rather than clipped, and it is not urgent enough to be the one thing that
/// forces the column open.
fn applyRestoreVisible(self: *Window) void {
self.restore_banner.as(gtk.Widget).setVisible(
@intFromBool(self.restore_offered and !self.sidebar_collapsed),
);
}
fn onRestoreClicked(_: *gtk.Button, self: *Window) callconv(.c) void {
self.restoreSession();
}
fn onRestoreDismissed(_: *gtk.Button, self: *Window) callconv(.c) void {
self.withdrawRestoreOffer();
}
/// Put the last session back, in place of the tabs the window opened itself.
///
/// The new tabs are built *before* the old ones are closed. That order is what
/// makes a failed restore harmless: a snapshot whose every tab refuses to build
/// leaves the window exactly as it was, rather than empty and with the offer
/// spent. It also means both sets are briefly in the sidebar at once, which is
/// why the selection moves to the first restored tab before anything is
/// discarded — the stack should never be showing a page that is about to go.
fn restoreSession(self: *Window) void {
self.withdrawRestoreOffer();
// Taken before the list grows: `tabs` is about to hold both sets, and these
// are the ones on their way out.
const previous = self.alloc.dupe(*Tab, self.tabs.items) catch |err| {
std.log.err("could not restore the session: {s}", .{@errorName(err)});
return;
};
defer self.alloc.free(previous);
var first: ?*Tab = null;
for (self.snapshot.tabs.items) |entry| {
const tab = self.buildSnapshotTab(entry) catch |err| {
// One tab short is a much better outcome than none: the other five
// are still the session you asked for. Note that a directory that
// has since been deleted does *not* land here — the child's `chdir`
// fails and the shell simply starts where the app did, exactly as it
// does for a layout that has gone stale.
std.log.warn("could not restore a tab: {s}", .{@errorName(err)});
continue;
};
if (first == null) first = tab;
}
const restored = first orelse {
// Nothing was built, so nothing is closed and the window is exactly as
// it was — only the banner has gone. Reaching here takes an allocation
// failure per tab, at which point there is nothing better to offer.
std.log.err("nothing in the session snapshot could be reopened", .{});
return;
};
self.select(restored);
for (previous) |tab| self.discardTab(tab);
}
/// One tab out of the snapshot.
///
/// The tree is applied with no bindings, and that is not an omission: a snapshot
/// is taken *after* substitution, so every directory in it is the one a shell
/// was actually sitting in. There is nothing left to expand, and a path that
/// really did contain a `{{` would be a path rather than a parameter.
fn buildSnapshotTab(self: *Window, entry: Snapshot.Tab) !*Tab {
const tab = try self.newTabEmpty();
// Before the panes, for the reason `buildLayoutTab` gives: a review pane
// starts fetching its page the moment it exists, so the repository has to be
// attached first.
if (layoutReviewDir(entry.root)) |dir| self.bindLayoutReview(tab, dir, &.{});
tab.view.applyLayout(entry.root, &.{}) catch |err| {
// As in `buildLayoutTab`: a view with no panes has nothing to work in,
// so it goes rather than sitting there empty. Discarded rather than
// closed, since closing the only tab would take the window with it.
if (tab.view.panes.items.len == 0) {
self.discardTab(tab);
return err;
}
std.log.err("a restored tab is only partly built: {s}", .{@errorName(err)});
};
// The recipe the tab was originally opened from, carried through the
// snapshot so that a restored window can still be captured as a startup
// list. It plays no part in the restore itself — the tree above did that.
if (entry.layout.len > 0) self.recordSource(tab, entry.layout, entry.values);
self.applyTabChrome(tab, entry.name, entry.emoji);
self.refreshLabel(tab);
return tab;
}
/// Photograph the window for the next launch.
///
/// Called from `onDestroy`, which is the one funnel every route out of the
/// window passes through — the quit confirmation being accepted, the last tab
/// closing, the compositor closing the window — and which runs while the tabs
/// and their shells are all still alive. That last part is the reason it is
/// there and not in `quit`: a terminal's directory is read out of its live
/// child process, and after teardown there is nothing left to ask.
///
/// Every failure below is a warning and a carry-on. This runs while the app is
/// already leaving, there is nowhere to report anything, and the worst case is
/// one launch that has nothing to offer.
fn saveSnapshot(self: *Window) void {
var snapshot: Snapshot = .init(self.alloc);
defer snapshot.deinit();
// Reused across tabs rather than allocated per tab: `add` copies what it is
// given, so this only ever has to hold one tab's worth.
var values: std.ArrayListUnmanaged(Layouts.Binding) = .empty;
defer values.deinit(self.alloc);
for (self.tabs.items) |tab| {
const root = tab.view.capture(snapshot.builder()) catch |err| {
std.log.warn("could not capture a tab for the snapshot: {s}", .{@errorName(err)});
continue;
} orelse continue;
values.clearRetainingCapacity();
if (tab.source) |source| {
for (source.values) |v| {
values.append(self.alloc, .{ .name = v.name, .value = v.value }) catch break;
}
}
snapshot.add(.{
.root = root,
.name = if (tab.custom_name) |name| name else "",
.emoji = if (tab.emoji) |glyph| glyph else "",
.layout = if (tab.source) |source| source.layout else "",
.values = values.items,
}) catch |err| {
std.log.warn("could not record a tab in the snapshot: {s}", .{@errorName(err)});
};
}
snapshot.save() catch |err| {
std.log.warn("could not write the session snapshot: {s}", .{@errorName(err)});
};
}
/// Make `tab` the visible one.
fn select(self: *Window, tab: *Tab) void {
self.updating = true;
@@ -1602,6 +1906,8 @@ fn closeTab(self: *Window, tab: *Tab) void {
if (self.closing) return;
const index = self.indexOf(tab) orelse return;
self.withdrawRestoreOffer();
self.discardTab(tab);
if (self.tabs.items.len == 0) {
@@ -1687,6 +1993,8 @@ fn applySidebarCollapsed(self: *Window, collapsed: bool) void {
else
"Collapse the sidebar (Ctrl+Shift+S)");
self.applyRestoreVisible();
for (self.tabs.items) |tab| self.applyRowCollapsed(tab);
}
@@ -2040,6 +2348,10 @@ fn onDestroy(_: *adw.ApplicationWindow, self: *Window) callconv(.c) void {
if (self.closing) return;
self.closing = true;
// First, while every tab is still whole and every shell still running: the
// directories in the snapshot are read out of live processes.
self.saveSnapshot();
// Before anything is freed: a scheme change arriving mid-teardown would
// otherwise walk a tab list we are about to destroy. The sort function reads
// that same list, and holds this window as its user data, so it goes now for
@@ -2067,6 +2379,7 @@ fn onDestroy(_: *adw.ApplicationWindow, self: *Window) callconv(.c) void {
self.freeLayoutRows();
self.layout_rows.deinit(self.alloc);
self.layouts.deinit();
self.snapshot.deinit();
self.alloc.destroy(self);
}
+58
View File
@@ -79,6 +79,64 @@ button.playpen-header-button:hover,
border-top: 1px solid @pp_border;
}
/* The restore-session offer, between the tab list and the footer. Rules of its
own rather than a reused row style: it is not a tab, and styling it as one
would put a fifth thing in a column of four that all select something.
Bordered top and bottom so it reads as a band across the sidebar rather than
a card floating in it — the footer below already draws the same hairline, and
the pair of them frame the offer without adding a third weight. The accent
tint is what makes it the one thing in the column asking to be clicked, kept
low enough that it does not compete with the selected row above it. */
.playpen-restore {
padding: 6px 8px;
border-top: 1px solid @pp_border;
background-color: alpha(@pp_accent_strong, 0.08);
}
.playpen-restore-action {
padding: 5px 6px;
border-radius: 8px;
color: @pp_text_dim;
}
.playpen-restore-action:hover {
background-color: alpha(@pp_accent_strong, 0.16);
color: @pp_text;
}
/* The one accent-colored thing in the banner, so that what draws the eye is the
symbol for the action rather than the sentence describing it. */
.playpen-restore-icon {
color: @pp_accent_strong;
}
.playpen-restore-title {
font-weight: bold;
font-size: 0.92em;
}
/* How many tabs are on offer, sized and dimmed like the sublabels in the
settings dialog: a caption on the action above it, not a second action. */
.playpen-restore-detail {
color: @pp_text_faint;
font-size: 0.82em;
}
/* Quieter than the action beside it, and quieter still than a tab's close
button, which at least closes something you were looking at. Saying no to
this should be easy to find and hard to hit by accident. */
.playpen-restore-dismiss {
min-width: 22px;
min-height: 22px;
padding: 0;
color: @pp_text_faint;
}
.playpen-restore-dismiss:hover {
color: @pp_text;
}
.playpen-settings-button {
min-width: 26px;
min-height: 26px;