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