Build in the review tool.

This commit is contained in:
Greyson Parrelli
2026-08-24 09:24:01 -04:00
parent 4eef6a28d3
commit 407dd81512
55 changed files with 12932 additions and 40 deletions
+217
View File
@@ -27,6 +27,7 @@ const View = @import("View.zig");
const appearance = @import("appearance.zig");
const emoji = @import("emoji.zig");
const key = @import("key.zig");
const review = @import("review.zig");
const shortcuts = @import("shortcuts.zig");
const Window = @This();
@@ -186,6 +187,15 @@ const Tab = struct {
/// The layout this tab was opened from, if any. Null for a plain shell.
source: ?Source = null,
/// This tab's review endpoint — `http://127.0.0.1:<port>/t/<name>` — owned
/// by the window's allocator, or empty when the review server never started.
///
/// Every tab has one from the moment it exists, whether or not it has a
/// review pane, because it is what its terminals are handed as
/// `PLAYPEN_REVIEW_URL`. An agent started in a tab should not have to be
/// restarted because a review pane opened after it did.
review_url: []u8 = &.{},
/// The row's right-click menu, parented to this tab's row.
menu_popover: *gtk.Popover,
@@ -458,6 +468,7 @@ fn newTabEmpty(self: *Window) !*Tab {
.on_title = &onViewTitle,
.on_status = &onViewStatus,
.on_finished = &onViewFinished,
.on_review = &onViewReview,
.ctx = tab,
});
errdefer view.destroy();
@@ -524,9 +535,38 @@ fn newTabEmpty(self: *Window) !*Tab {
try self.tabs.append(self.alloc, tab);
// The tab is announced to the review server as soon as it exists, so its
// endpoint is real before the first shell in it starts. Which repository the
// endpoint reviews is decided later: from the directory the tab is working
// in when a review pane is opened by hand (`openReview`), or from the
// directory a layout named (`bindLayoutReview`).
self.bindReview(tab);
return tab;
}
/// Give a tab its review endpoint and tell the server the tab exists.
///
/// Best-effort throughout: a tab with no endpoint is a tab whose terminals get
/// no `PLAYPEN_REVIEW_URL` and whose review pane explains itself, which is a
/// smaller problem than refusing to open the tab.
fn bindReview(self: *Window, tab: *Tab) void {
const server = review.get() orelse return;
server.registerTab(tab.pageName()) catch |err| {
std.log.warn("review: could not register {s}: {s}", .{
tab.pageName(),
@errorName(err),
});
return;
};
var buf: [256]u8 = undefined;
const url = server.tabUrl(&buf, tab.pageName()) catch return;
tab.review_url = self.alloc.dupe(u8, url) catch return;
tab.view.review_spec = .{ .url = tab.review_url };
}
// -------------------------------------------------------------------------
// The row menu
//
@@ -887,6 +927,10 @@ fn buildLayoutTab(
) !*Tab {
const tab = try self.newTabEmpty();
// Before the panes, not after: a review pane starts loading its page as it
// is built, so the repository has to be attached first.
if (layoutReviewDir(layout.root)) |dir| self.bindLayoutReview(tab, dir, bindings);
tab.view.applyLayout(layout.root, bindings) catch |err| {
// A half-built view has no panes to work in and no shell to close, so
// drop the tab rather than leave an empty one behind. Discarded rather
@@ -1452,6 +1496,13 @@ fn discardTab(self: *Window, tab: *Tab) void {
/// by whoever took the tab out of the window, which on the teardown path is not
/// the same code.
fn releaseTab(self: *Window, tab: *Tab) void {
// Before the URL is freed: the server is holding this tab's id, and its
// store, until told the tab has gone. The comments themselves are on disk
// and stay there, so a tab reopened on the same repository picks the review
// back up where it left off.
if (review.get()) |server| server.unregisterTab(tab.pageName());
if (tab.review_url.len > 0) self.alloc.free(tab.review_url);
if (tab.custom_name) |name| self.alloc.free(name);
self.freeSource(tab);
self.alloc.destroy(tab);
@@ -1737,6 +1788,12 @@ fn onViewFinished(ctx: ?*anyopaque) void {
}
/// The view lost its last pane, so the tab goes with it.
/// A pane in this tab asked for the tab's review.
fn onViewReview(ctx: ?*anyopaque) void {
const tab: *Tab = @ptrCast(@alignCast(ctx.?));
tab.window.openReview(tab);
}
fn onViewEmpty(ctx: ?*anyopaque) void {
const tab: *Tab = @ptrCast(@alignCast(ctx.?));
tab.window.closeTab(tab);
@@ -1811,6 +1868,162 @@ fn addPane(self: *Window, kind: View.Kind) void {
};
}
// -------------------------------------------------------------------------
// The review pane
//
// A tab has at most one, and it is bound to a repository: the one the tab is
// working in, or the one its layout named. Both halves of that are decided here
// rather than in the pane or the view: the pane is a web view, the view is a
// split tree, and "which repository is this tab about" is a question only the
// window — which can see the tab's terminals and what it was opened from — is in
// a position to answer.
/// Open the visible tab's review, or go to the one it already has.
///
/// The repository is resolved once, here, from the directory the tab is working
/// in, and then stays put for as long as the review is open. Re-resolving on
/// every fetch was the alternative, and it means a `cd` in a terminal can swap
/// the diff out from under someone mid-read; a review you have to reopen is the
/// better failure.
fn openReview(self: *Window, tab: *Tab) void {
// Already open: take them to it rather than reporting a refusal. Asking for
// the review twice is a reasonable way to say "where is my review".
if (tab.view.reviewPane()) |pane| {
self.select(tab);
tab.view.setFocused(pane);
pane.grabFocus();
return;
}
const server = review.get() orelse {
// The pane still opens, and says this. Better than a shortcut that looks
// broken because nothing happened.
self.addReviewPane(tab);
return;
};
var buf: [std.fs.max_path_bytes]u8 = undefined;
const dir = self.tabDirectory(tab, &buf);
server.openReview(tab.pageName(), dir) catch |err| {
// Most often `dir` is simply not inside a repository, which is not a
// failure of playpen's and not worth a dialog: the pane's own empty
// state explains it, and the log line is here for the rest.
std.log.info("review: no repository for {s} at {s}: {s}", .{
tab.pageName(),
dir,
@errorName(err),
});
self.addReviewPane(tab);
return;
};
// The pane header shows the repository's name, which is only knowable once
// the server has resolved the work-tree root.
tab.view.review_spec.repo = server.repoPath(tab.pageName()) orelse dir;
self.addReviewPane(tab);
}
/// The directory a layout points its review at, or null if it has no review
/// pane or leaves the directory to the tab.
///
/// The first review leaf decides it. A layout holding two is refused as the
/// second pane is built — one review per tab — so there is never a second
/// directory to disagree with this one.
fn layoutReviewDir(node: *const Layouts.Node) ?[]const u8 {
switch (node.*) {
.pane => |p| {
if (p.kind != .review or p.cwd.len == 0) return null;
return p.cwd;
},
.split => |s| return layoutReviewDir(s.first) orelse layoutReviewDir(s.second),
}
}
/// Bind a tab's review to the directory its layout named, while the view is
/// still empty.
///
/// This is `openReview` without the pane: the layout has already said the tab
/// has a review in it, and all that is missing is which repository. Resolving it
/// here rather than after the panes are built is what makes the result
/// deterministic — the review pane's page is fetched from the server on another
/// thread the moment the pane exists, and a repository attached afterwards would
/// sometimes arrive first and sometimes second.
///
/// The directory goes through the same expansion a terminal's does, so a layout
/// can review `{{a parameter}}` or `$(whatever a script prints)`.
fn bindLayoutReview(
self: *Window,
tab: *Tab,
template: []const u8,
bindings: []const Layouts.Binding,
) void {
const server = review.get() orelse return;
const dir = Layouts.expandPath(self.alloc, template, bindings) catch |err| {
std.log.warn("review: could not resolve \"{s}\": {s}", .{ template, @errorName(err) });
return;
};
defer self.alloc.free(dir);
server.openReview(tab.pageName(), dir) catch |err| {
// Same as opening a review by hand: a directory that isn't in a
// repository is the pane's own empty state to explain, not a reason to
// refuse the rest of the tab.
std.log.info("review: no repository for {s} at {s}: {s}", .{
tab.pageName(),
dir,
@errorName(err),
});
return;
};
// Borrowed from the server, which keeps it for as long as the tab's review
// lives — longer than any pane in the tab.
if (server.repoPath(tab.pageName())) |repo| tab.view.review_spec.repo = repo;
}
fn addReviewPane(self: *Window, tab: *Tab) void {
tab.view.addPane(.plain(.review)) catch |err| {
std.log.err("failed to open the review pane: {s}", .{@errorName(err)});
return;
};
self.select(tab);
}
/// The directory a tab is working in, copied into `buf`.
///
/// Read from a terminal's own process rather than from anything recorded when
/// the tab opened, because the directory that matters is the one you are working
/// in now: a tab opened in a monorepo root and `cd`-ed into a worktree is a tab
/// about that worktree. The focused pane is asked first, so a split holding two
/// repositories reviews the one you are looking at.
///
/// Falls back to playpen's own working directory, which at least gives the
/// server something to fail on that the user can recognise in the message.
fn tabDirectory(self: *Window, tab: *Tab, buf: []u8) []const u8 {
_ = self;
if (tab.view.focusedPane()) |focused| {
if (focused.terminal()) |terminal| {
if (terminal.session.pty.cwd(buf)) |dir| return dir;
}
}
for (tab.view.panes.items) |pane| {
const terminal = pane.terminal() orelse continue;
if (terminal.session.pty.cwd(buf)) |dir| return dir;
}
// Playpen's own directory, which at least gives the server something to
// fail on that the user can recognise in the message. `std.c` rather than
// `std.posix`, matching `Pty.zig`: the latter has been churning across Zig
// releases and this is one call.
if (std.c.getcwd(buf.ptr, buf.len) != null) {
return std.mem.sliceTo(buf, 0);
}
return ".";
}
fn selectIndex(self: *Window, index: usize) void {
if (index >= self.tabs.items.len) return;
self.select(self.tabs.items[index]);
@@ -1887,6 +2100,10 @@ fn perform(self: *Window, action: shortcuts.Action) bool {
.new_terminal => self.addPane(.terminal),
.new_web => self.addPane(.web),
// Not `addPane`: opening a review is more than adding a pane, and asking
// for one you already have takes you to it instead of refusing.
.new_review => if (self.activeTab()) |tab| self.openReview(tab),
.rename_tab => if (self.activeTab()) |tab| self.beginRename(tab),
.toggle_zoom => if (self.activeTab()) |tab| tab.view.toggleZoomFocused(),
.toggle_sidebar => self.toggleSidebar(),