Files
playpen/src/Review.zig
T

295 lines
9.6 KiB
Zig

//! The third thing a pane can hold: the review UI for the tab it lives in.
//!
//! Underneath it is a web view, the same as `Browser` — the UI is a web page,
//! served by the review server in this same process (see `src/review/`). What
//! makes it its own kind of pane rather than a web pane pointed at a URL is
//! everything around that:
//!
//! - **It is bound to the tab.** The URL is the tab's own review endpoint, and
//! it is not navigable. There is no address bar, because there is nowhere
//! else to go: a review pane showing another tab's review would be a way to
//! leave comments on the wrong branch.
//! - **One per tab.** Enforced by `View.addPane`, since two panes on one
//! review would each be publishing a different diff selection to the server
//! and an agent would follow whichever wrote last.
//! - **It can fail to load and say so.** A web pane that cannot reach a host
//! is showing you a page; this one failing means the server did not come up,
//! which is a playpen problem and worth an explanation and a retry button
//! rather than WebKit's network error.
//!
//! The callback shape matches `Terminal`'s and `Browser`'s exactly, so a pane
//! drives any of the three through the same handlers.
const std = @import("std");
const glib = @import("glib");
const gobject = @import("gobject");
const gtk = @import("gtk");
const Pane = @import("Pane.zig");
const webkit = @import("webkit.zig");
const Review = @This();
/// Everything needed to open one.
pub const Options = struct {
/// The tab's review endpoint — `http://127.0.0.1:<port>/t/<tabId>`.
///
/// Empty means the review server never started, which the pane reports
/// rather than leaving a blank web view.
url: []const u8 = "",
/// The work tree being reviewed, for the pane header. Empty until the tab
/// has resolved one.
repo: []const u8 = "",
};
/// How long to wait before retrying a load that failed.
///
/// A review pane opened in the same gesture that starts the server can lose the
/// race with it, and that is by far the most likely reason for the first load to
/// fail — so one quiet retry turns the common failure into a flicker rather than
/// an error the user has to answer.
const retry_delay_ms = 400;
alloc: std.mem.Allocator,
/// Vertical box: the error bar (hidden in the normal case) above the page.
box: *gtk.Box,
view: *webkit.WebView,
/// Shown only when a load fails, so the normal case is the page and nothing else.
error_bar: *gtk.Box,
error_label: *gtk.Label,
/// The endpoint this pane is bound to, NUL-terminated for WebKit. Owned.
url: [:0]u8,
/// Title for the pane header: the repository's name, or a placeholder.
label: [64:0]u8 = @splat(0),
/// Set once the automatic retry has been spent, so a genuinely unreachable
/// server produces one error rather than a reload loop.
retried: bool = false,
/// The pending retry's GLib source id, so tearing the pane down cancels it
/// instead of letting it fire into freed memory.
retry_source: c_uint = 0,
on_title: *const fn (ctx: ?*anyopaque, title: []const u8) void,
on_exit: *const fn (ctx: ?*anyopaque) void,
on_focus: *const fn (ctx: ?*anyopaque) void,
ctx: ?*anyopaque = null,
pub fn create(
alloc: std.mem.Allocator,
opts: Options,
cbs: Pane.Callbacks,
) !*Review {
const self = try alloc.create(Review);
errdefer alloc.destroy(self);
const url = try alloc.dupeZ(u8, opts.url);
errdefer alloc.free(url);
self.* = .{
.alloc = alloc,
.box = gtk.Box.new(.vertical, 0),
.view = .new(),
.error_bar = gtk.Box.new(.horizontal, 8),
.error_label = gtk.Label.new(""),
.url = url,
.on_title = cbs.on_title,
.on_exit = cbs.on_exit,
.on_focus = cbs.on_focus,
.ctx = cbs.ctx,
};
self.setLabel(opts.repo);
self.box.append(self.buildErrorBar());
const view_widget = self.view.as(gtk.Widget);
view_widget.setHexpand(1);
view_widget.setVexpand(1);
self.box.append(view_widget);
// The page sets its own title — the repository and branch — which is better
// than anything this side could compose, so the header follows it.
_ = gobject.Object.signals.notify.connect(
self.view.as(gobject.Object),
*Review,
&onNotifyTitle,
self,
.{ .detail = "title" },
);
self.view.connectSignal("load-failed", *Review, &onLoadFailed, self);
self.view.connectSignal("load-changed", *Review, &onLoadChanged, self);
// Fires for focus landing anywhere inside, so clicking the retry button
// marks the pane active just as clicking the page does.
const focus = gtk.EventControllerFocus.new();
_ = gtk.EventControllerFocus.signals.enter.connect(
focus,
*Review,
&onFocusEnter,
self,
.{},
);
self.box.as(gtk.Widget).addController(focus.as(gtk.EventController));
if (url.len > 0) {
self.view.loadUri(url);
} else {
self.showError("The review server isn't running — check the log for why.");
}
return self;
}
pub fn destroy(self: *Review) void {
if (self.retry_source != 0) {
_ = glib.Source.remove(self.retry_source);
self.retry_source = 0;
}
// The widget tree outlives this struct by a moment: the pane frees its
// content first and drops the widgets' last reference afterwards. Tearing
// down a page makes WebKit emit signals on the way out, so every handler
// bound to `self` has to go before `self` does — same hazard as `Browser`.
_ = gobject.signalHandlersDisconnectMatched(
self.view.as(gobject.Object),
.{ .data = true },
0,
0,
null,
null,
self,
);
self.alloc.free(self.url);
self.alloc.destroy(self);
}
pub fn widget(self: *Review) *gtk.Widget {
return self.box.as(gtk.Widget);
}
pub fn grabFocus(self: *Review) void {
_ = self.view.as(gtk.Widget).grabFocus();
}
/// Title for the pane header and the tab row.
pub fn title(self: *const Review) []const u8 {
return std.mem.sliceTo(&self.label, 0);
}
/// Reload the page. Bound to the pane's own reload, and to the error bar's
/// button, so a server that came up late can be picked up without reopening.
pub fn reload(self: *Review) void {
self.retried = false;
self.hideError();
if (self.url.len == 0) return;
// `loadUri` rather than `reload`, because a failed load leaves the view with
// no URI to reload — WebKit would do nothing at all.
self.view.loadUri(self.url);
}
fn setLabel(self: *Review, repo: []const u8) void {
const name = if (repo.len == 0)
"review"
else
std.fs.path.basename(repo);
const n = @min(name.len, self.label.len - 1);
@memcpy(self.label[0..n], name[0..n]);
@memset(self.label[n..], 0);
}
// -------------------------------------------------------------------------
// The error bar
fn buildErrorBar(self: *Review) *gtk.Widget {
const bar = self.error_bar.as(gtk.Widget);
bar.addCssClass("playpen-review-error");
bar.setVisible(0);
const icon = gtk.Image.newFromIconName("process-stop-symbolic");
self.error_bar.append(icon.as(gtk.Widget));
self.error_label.setXalign(0);
self.error_label.setWrap(1);
self.error_label.as(gtk.Widget).setHexpand(1);
self.error_bar.append(self.error_label.as(gtk.Widget));
const retry = gtk.Button.newWithLabel("Reload");
retry.as(gtk.Widget).addCssClass("playpen-review-retry");
_ = gtk.Button.signals.clicked.connect(retry, *Review, &onRetryClicked, self, .{});
self.error_bar.append(retry.as(gtk.Widget));
return bar;
}
fn showError(self: *Review, message: [:0]const u8) void {
self.error_label.setText(message);
self.error_bar.as(gtk.Widget).setVisible(1);
}
fn hideError(self: *Review) void {
self.error_bar.as(gtk.Widget).setVisible(0);
}
// -------------------------------------------------------------------------
// Callbacks
fn onNotifyTitle(_: *gobject.Object, _: *gobject.ParamSpec, self: *Review) callconv(.c) void {
const raw = self.view.getTitle() orelse return;
const span = std.mem.span(raw);
if (span.len == 0) return;
const n = @min(span.len, self.label.len - 1);
@memcpy(self.label[0..n], span[0..n]);
@memset(self.label[n..], 0);
self.on_title(self.ctx, self.title());
}
/// WebKit's `load-changed`. Only the `finished` phase matters here: a load that
/// got through is what clears an error left over from the attempt before it.
fn onLoadChanged(_: *webkit.WebView, event: c_uint, self: *Review) callconv(.c) void {
const load_finished = 3; // WEBKIT_LOAD_FINISHED
if (event == load_finished) self.hideError();
}
/// WebKit's `load-failed`. Returning true says the failure is handled, which
/// suppresses the browser error page — this pane has its own bar for it, and a
/// "server not found" page inside a tab is more confusing than helpful.
fn onLoadFailed(
_: *webkit.WebView,
_: c_uint,
_: [*:0]const u8,
_: ?*anyopaque,
self: *Review,
) callconv(.c) c_int {
if (!self.retried) {
self.retried = true;
self.retry_source = glib.timeoutAddOnce(retry_delay_ms, &onRetryTimeout, self);
return 1;
}
self.showError("Couldn't reach the review server in this playpen.");
return 1;
}
fn onRetryTimeout(data: ?*anyopaque) callconv(.c) void {
const self: *Review = @ptrCast(@alignCast(data.?));
self.retry_source = 0;
if (self.url.len > 0) self.view.loadUri(self.url);
}
fn onRetryClicked(_: *gtk.Button, self: *Review) callconv(.c) void {
self.reload();
}
fn onFocusEnter(_: *gtk.EventControllerFocus, self: *Review) callconv(.c) void {
self.on_focus(self.ctx);
}