Build in the review tool.
This commit is contained in:
+217
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user