Add ability to restore previous layout.
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user