diff --git a/README.md b/README.md index ffe3fa5..a8d15a7 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/build.zig b/build.zig index a580e22..4185698 100644 --- a/build.zig +++ b/build.zig @@ -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 diff --git a/src/Layouts.zig b/src/Layouts.zig index 34cdb09..98bf8ef 100644 --- a/src/Layouts.zig +++ b/src/Layouts.zig @@ -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. diff --git a/src/Snapshot.zig b/src/Snapshot.zig new file mode 100644 index 0000000..8e802fa --- /dev/null +++ b/src/Snapshot.zig @@ -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()); +} diff --git a/src/Window.zig b/src/Window.zig index ebd0b69..17b2de0 100644 --- a/src/Window.zig +++ b/src/Window.zig @@ -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); } diff --git a/src/style.css b/src/style.css index bee3b89..7005cf7 100644 --- a/src/style.css +++ b/src/style.css @@ -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;