295 lines
9.6 KiB
Zig
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);
|
|
}
|