Add ability to restore previous layout.

This commit is contained in:
Greyson Parrelli
2026-08-27 10:55:04 -04:00
parent 5cb9bb117a
commit 0bf85b3b2a
6 changed files with 1029 additions and 33 deletions
+324 -11
View File
@@ -22,6 +22,7 @@ const Review = @import("Review.zig");
const SaveLayoutDialog = @import("SaveLayoutDialog.zig");
const Settings = @import("Settings.zig");
const SettingsDialog = @import("SettingsDialog.zig");
const Snapshot = @import("Snapshot.zig");
const TabSettingsDialog = @import("TabSettingsDialog.zig");
const Terminal = @import("Terminal.zig");
const View = @import("View.zig");
@@ -133,6 +134,24 @@ layout_popover: *gtk.Popover,
/// they belong to are on screen.
layout_rows: std.ArrayListUnmanaged(*LayoutRow) = .empty,
/// The arrangement the last session exited with, read once at launch. Empty
/// on a first run, and after any launch that ended with every tab closed.
snapshot: Snapshot,
/// The banner at the foot of the sidebar offering that snapshot back, and the
/// line under its title saying how much there is to put back.
///
/// Built with the sidebar and hidden until there is something to offer, rather
/// than created when the offer arrives: a launch is already busy opening tabs,
/// and this has no state worth building twice.
restore_banner: *gtk.Box,
restore_detail: *gtk.Label,
/// Whether the offer is currently standing. Separate from the banner's own
/// visibility because a collapsed sidebar hides the banner without answering
/// it — see `applyRestoreVisible`.
restore_offered: bool = false,
/// What a sidebar row is signaling. Defined with the dots themselves, since
/// a row and a pane header show the same five states for the same reasons.
const Attention = Pane.Attention;
@@ -274,6 +293,9 @@ pub fn create(alloc: std.mem.Allocator, app: *adw.Application) !*Window {
.layout_button = gtk.MenuButton.new(),
.footer = gtk.Box.new(.horizontal, 0),
.collapse_button = gtk.Button.newFromIconName("go-previous-symbolic"),
.snapshot = .init(alloc),
.restore_banner = gtk.Box.new(.horizontal, 4),
.restore_detail = gtk.Label.new(null),
};
self.layouts.load();
if (self.layouts.load_error) |message| std.log.warn("{s}", .{message});
@@ -352,6 +374,12 @@ pub fn create(alloc: std.mem.Allocator, app: *adw.Application) !*Window {
scroller.setChild(self.list.as(gtk.Widget));
sidebar.append(scroller.as(gtk.Widget));
// Under the tab list and above the footer. It is an offer about the tabs, so
// it belongs against them rather than up by the new-tab button — and at the
// foot it is out of the way of the list on every launch that has nothing to
// offer, which is most of them.
sidebar.append(self.buildRestoreBanner());
// Settings sit at the foot of the sidebar rather than in its header. The
// header holds the two things you reach for constantly — a new tab and a
// saved layout — and a preferences button is the opposite of that: opened
@@ -462,6 +490,11 @@ pub fn create(alloc: std.mem.Allocator, app: *adw.Application) !*Window {
try self.openStartupTabs();
// After the startup tabs, not before: the offer is withdrawn by anything
// that changes the tab set, and the window opening its own tabs would
// otherwise withdraw it before it was ever made.
self.offerRestore();
return self;
}
@@ -479,6 +512,8 @@ pub fn present(self: *Window) void {
/// Open a new tab holding a single terminal — the plain case, unchanged by
/// layouts existing.
pub fn newTab(self: *Window) !void {
self.withdrawRestoreOffer();
const tab = try self.newTabEmpty();
// Only once the tab is in `self.tabs` is it complete enough for the
@@ -992,6 +1027,8 @@ fn onLayoutParameters(
/// Open a layout in a new tab and go to it.
fn openLayout(self: *Window, layout: *Layouts.Layout, bindings: []const Layouts.Binding) void {
self.withdrawRestoreOffer();
const tab = self.buildLayoutTab(layout, bindings) catch |err| {
std.log.err("failed to open layout \"{s}\": {s}", .{ layout.name, @errorName(err) });
return;
@@ -1160,7 +1197,7 @@ fn openStartupTab(self: *Window, entry: Settings.StartupTab) void {
return;
} orelse return;
self.applyStartupChrome(tab, entry);
self.applyTabChrome(tab, entry.name, entry.emoji);
self.refreshLabel(tab);
}
@@ -1228,23 +1265,27 @@ fn namesValue(values: []const Settings.Value, name: []const u8) bool {
return false;
}
/// The name and emoji an entry pins on its tab, both behaving exactly as though
/// they had been set by hand once it was open.
fn applyStartupChrome(self: *Window, tab: *Tab, entry: Settings.StartupTab) void {
if (entry.name.len > 0) {
tab.custom_name = self.alloc.dupe(u8, entry.name) catch |err| blk: {
std.log.warn("could not name startup tab: {s}", .{@errorName(err)});
/// The name and emoji a saved entry pins on its tab, both behaving exactly as
/// though they had been set by hand once it was open.
///
/// Shared by the startup list and the session snapshot, which record the same
/// two strings for the same reason: a tab you named "deploy" should still say
/// so when it comes back, whichever of the two files brought it back.
fn applyTabChrome(self: *Window, tab: *Tab, name: []const u8, glyph: []const u8) void {
if (name.len > 0) {
tab.custom_name = self.alloc.dupe(u8, name) catch |err| blk: {
std.log.warn("could not name a reopened tab: {s}", .{@errorName(err)});
break :blk null;
};
}
// Resolved against the emoji table rather than copied, because a row holds
// a pointer into that table and nothing else. A glyph that isn't in it is
// a hand-edited file naming something this build can't draw.
if (entry.emoji.len > 0) {
tab.emoji = emoji.lookup(entry.emoji);
// a file naming something this build can't draw.
if (glyph.len > 0) {
tab.emoji = emoji.lookup(glyph);
if (tab.emoji == null) {
std.log.warn("startup: \"{s}\" is not an emoji this build knows", .{entry.emoji});
std.log.warn("\"{s}\" is not an emoji this build knows", .{glyph});
}
}
}
@@ -1339,6 +1380,269 @@ fn captureStartupTabs(ctx: ?*anyopaque) void {
};
}
// -------------------------------------------------------------------------
// The session snapshot
//
// Every exit photographs the window — see `Snapshot` for what is in the
// photograph and what deliberately is not — and every launch that finds one
// offers it back from a banner at the foot of the sidebar. The offer is
// optional in both directions: dismissing it costs nothing, and taking it is a
// single click rather than a dialog.
//
// The offer stands only until the tab set changes. That is the price of
// restoring *over* the startup tabs rather than beside them, and it is worth
// paying: a window holding both your startup tabs and the session they were
// standing in for is two of everything, and nobody wants to close half a window
// by hand. But "the tabs the window opened for itself" is a set that only
// exists for as long as nobody has touched it — the moment you open or close one
// yourself, closing that set would be closing your work. So `newTab`,
// `openLayout` and `closeTab` all take the offer down, and the banner is only
// ever the launch-time gesture it looks like.
/// The banner: a row that puts the last session back, and a ✕ that says no.
fn buildRestoreBanner(self: *Window) *gtk.Widget {
const banner = self.restore_banner;
banner.as(gtk.Widget).addCssClass("playpen-restore");
banner.as(gtk.Widget).setVisible(0);
// The whole row is the button rather than a "Restore" beside a description
// of what it would do. There is one action here, and a banner whose text is
// inert invites a click on the half of itself that does nothing.
const action = gtk.Button.new();
action.as(gtk.Widget).addCssClass("flat");
action.as(gtk.Widget).addCssClass("playpen-restore-action");
action.as(gtk.Widget).setHexpand(1);
action.setHasFrame(0);
action.as(gtk.Widget).setTooltipText("Reopen the tabs this window had when it last closed");
const content = gtk.Box.new(.horizontal, 8);
const icon = gtk.Image.newFromIconName("view-refresh-symbolic");
icon.as(gtk.Widget).addCssClass("playpen-restore-icon");
content.append(icon.as(gtk.Widget));
const text = gtk.Box.new(.vertical, 0);
text.as(gtk.Widget).setHexpand(1);
const title = gtk.Label.new("Restore session");
title.setXalign(0);
title.as(gtk.Widget).addCssClass("playpen-restore-title");
text.append(title.as(gtk.Widget));
// Filled in by `offerRestore`, which is the only moment the count is known.
self.restore_detail.setXalign(0);
self.restore_detail.setEllipsize(.end);
self.restore_detail.as(gtk.Widget).addCssClass("playpen-restore-detail");
text.append(self.restore_detail.as(gtk.Widget));
content.append(text.as(gtk.Widget));
action.setChild(content.as(gtk.Widget));
_ = gtk.Button.signals.clicked.connect(
action,
*Window,
&onRestoreClicked,
self,
.{},
);
banner.append(action.as(gtk.Widget));
const dismiss = gtk.Button.newFromIconName("window-close-symbolic");
dismiss.as(gtk.Widget).addCssClass("flat");
dismiss.as(gtk.Widget).addCssClass("playpen-restore-dismiss");
dismiss.as(gtk.Widget).setValign(.center);
dismiss.as(gtk.Widget).setTooltipText("Dismiss");
_ = gtk.Button.signals.clicked.connect(
dismiss,
*Window,
&onRestoreDismissed,
self,
.{},
);
banner.append(dismiss.as(gtk.Widget));
return banner.as(gtk.Widget);
}
/// Read the snapshot and, if there is anything in it, put the offer up.
fn offerRestore(self: *Window) void {
self.snapshot.load();
if (!self.snapshot.any()) return;
// The count is the whole of what the banner can honestly promise, and it is
// also what tells you whether this is the session you meant. The buffer is
// sized so the format cannot fail; the fallback still says what the offer
// is, just without the number.
const count = self.snapshot.tabs.items.len;
var buf: [64]u8 = undefined;
const detail: [:0]const u8 = std.fmt.bufPrintZ(&buf, "{d} tab{s} from last time", .{
count,
if (count == 1) "" else "s",
}) catch "from last time";
self.restore_detail.setText(detail.ptr);
self.restore_offered = true;
self.applyRestoreVisible();
}
/// Take the offer down for the rest of this launch.
///
/// The snapshot file itself is left alone. Nothing needs to delete it — the next
/// exit overwrites it — and leaving it means a dismissal followed by a crash
/// still has last session's tabs to offer, which is the direction to err in.
fn withdrawRestoreOffer(self: *Window) void {
if (!self.restore_offered) return;
self.restore_offered = false;
self.applyRestoreVisible();
}
/// The banner shows while the offer stands *and* the sidebar is wide enough to
/// read it. At the emoji column there is no version of this that is smaller
/// rather than clipped, and it is not urgent enough to be the one thing that
/// forces the column open.
fn applyRestoreVisible(self: *Window) void {
self.restore_banner.as(gtk.Widget).setVisible(
@intFromBool(self.restore_offered and !self.sidebar_collapsed),
);
}
fn onRestoreClicked(_: *gtk.Button, self: *Window) callconv(.c) void {
self.restoreSession();
}
fn onRestoreDismissed(_: *gtk.Button, self: *Window) callconv(.c) void {
self.withdrawRestoreOffer();
}
/// Put the last session back, in place of the tabs the window opened itself.
///
/// The new tabs are built *before* the old ones are closed. That order is what
/// makes a failed restore harmless: a snapshot whose every tab refuses to build
/// leaves the window exactly as it was, rather than empty and with the offer
/// spent. It also means both sets are briefly in the sidebar at once, which is
/// why the selection moves to the first restored tab before anything is
/// discarded — the stack should never be showing a page that is about to go.
fn restoreSession(self: *Window) void {
self.withdrawRestoreOffer();
// Taken before the list grows: `tabs` is about to hold both sets, and these
// are the ones on their way out.
const previous = self.alloc.dupe(*Tab, self.tabs.items) catch |err| {
std.log.err("could not restore the session: {s}", .{@errorName(err)});
return;
};
defer self.alloc.free(previous);
var first: ?*Tab = null;
for (self.snapshot.tabs.items) |entry| {
const tab = self.buildSnapshotTab(entry) catch |err| {
// One tab short is a much better outcome than none: the other five
// are still the session you asked for. Note that a directory that
// has since been deleted does *not* land here — the child's `chdir`
// fails and the shell simply starts where the app did, exactly as it
// does for a layout that has gone stale.
std.log.warn("could not restore a tab: {s}", .{@errorName(err)});
continue;
};
if (first == null) first = tab;
}
const restored = first orelse {
// Nothing was built, so nothing is closed and the window is exactly as
// it was — only the banner has gone. Reaching here takes an allocation
// failure per tab, at which point there is nothing better to offer.
std.log.err("nothing in the session snapshot could be reopened", .{});
return;
};
self.select(restored);
for (previous) |tab| self.discardTab(tab);
}
/// One tab out of the snapshot.
///
/// The tree is applied with no bindings, and that is not an omission: a snapshot
/// is taken *after* substitution, so every directory in it is the one a shell
/// was actually sitting in. There is nothing left to expand, and a path that
/// really did contain a `{{` would be a path rather than a parameter.
fn buildSnapshotTab(self: *Window, entry: Snapshot.Tab) !*Tab {
const tab = try self.newTabEmpty();
// Before the panes, for the reason `buildLayoutTab` gives: a review pane
// starts fetching its page the moment it exists, so the repository has to be
// attached first.
if (layoutReviewDir(entry.root)) |dir| self.bindLayoutReview(tab, dir, &.{});
tab.view.applyLayout(entry.root, &.{}) catch |err| {
// As in `buildLayoutTab`: a view with no panes has nothing to work in,
// so it goes rather than sitting there empty. Discarded rather than
// closed, since closing the only tab would take the window with it.
if (tab.view.panes.items.len == 0) {
self.discardTab(tab);
return err;
}
std.log.err("a restored tab is only partly built: {s}", .{@errorName(err)});
};
// The recipe the tab was originally opened from, carried through the
// snapshot so that a restored window can still be captured as a startup
// list. It plays no part in the restore itself — the tree above did that.
if (entry.layout.len > 0) self.recordSource(tab, entry.layout, entry.values);
self.applyTabChrome(tab, entry.name, entry.emoji);
self.refreshLabel(tab);
return tab;
}
/// Photograph the window for the next launch.
///
/// Called from `onDestroy`, which is the one funnel every route out of the
/// window passes through — the quit confirmation being accepted, the last tab
/// closing, the compositor closing the window — and which runs while the tabs
/// and their shells are all still alive. That last part is the reason it is
/// there and not in `quit`: a terminal's directory is read out of its live
/// child process, and after teardown there is nothing left to ask.
///
/// Every failure below is a warning and a carry-on. This runs while the app is
/// already leaving, there is nowhere to report anything, and the worst case is
/// one launch that has nothing to offer.
fn saveSnapshot(self: *Window) void {
var snapshot: Snapshot = .init(self.alloc);
defer snapshot.deinit();
// Reused across tabs rather than allocated per tab: `add` copies what it is
// given, so this only ever has to hold one tab's worth.
var values: std.ArrayListUnmanaged(Layouts.Binding) = .empty;
defer values.deinit(self.alloc);
for (self.tabs.items) |tab| {
const root = tab.view.capture(snapshot.builder()) catch |err| {
std.log.warn("could not capture a tab for the snapshot: {s}", .{@errorName(err)});
continue;
} orelse continue;
values.clearRetainingCapacity();
if (tab.source) |source| {
for (source.values) |v| {
values.append(self.alloc, .{ .name = v.name, .value = v.value }) catch break;
}
}
snapshot.add(.{
.root = root,
.name = if (tab.custom_name) |name| name else "",
.emoji = if (tab.emoji) |glyph| glyph else "",
.layout = if (tab.source) |source| source.layout else "",
.values = values.items,
}) catch |err| {
std.log.warn("could not record a tab in the snapshot: {s}", .{@errorName(err)});
};
}
snapshot.save() catch |err| {
std.log.warn("could not write the session snapshot: {s}", .{@errorName(err)});
};
}
/// Make `tab` the visible one.
fn select(self: *Window, tab: *Tab) void {
self.updating = true;
@@ -1602,6 +1906,8 @@ fn closeTab(self: *Window, tab: *Tab) void {
if (self.closing) return;
const index = self.indexOf(tab) orelse return;
self.withdrawRestoreOffer();
self.discardTab(tab);
if (self.tabs.items.len == 0) {
@@ -1687,6 +1993,8 @@ fn applySidebarCollapsed(self: *Window, collapsed: bool) void {
else
"Collapse the sidebar (Ctrl+Shift+S)");
self.applyRestoreVisible();
for (self.tabs.items) |tab| self.applyRowCollapsed(tab);
}
@@ -2040,6 +2348,10 @@ fn onDestroy(_: *adw.ApplicationWindow, self: *Window) callconv(.c) void {
if (self.closing) return;
self.closing = true;
// First, while every tab is still whole and every shell still running: the
// directories in the snapshot are read out of live processes.
self.saveSnapshot();
// Before anything is freed: a scheme change arriving mid-teardown would
// otherwise walk a tab list we are about to destroy. The sort function reads
// that same list, and holds this window as its user data, so it goes now for
@@ -2067,6 +2379,7 @@ fn onDestroy(_: *adw.ApplicationWindow, self: *Window) callconv(.c) void {
self.freeLayoutRows();
self.layout_rows.deinit(self.alloc);
self.layouts.deinit();
self.snapshot.deinit();
self.alloc.destroy(self);
}