//! 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:/t/`. /// /// 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); }