Build in the review tool.
This commit is contained in:
+73
-1
@@ -47,7 +47,11 @@ pub const Orientation = enum { horizontal, vertical };
|
||||
|
||||
/// What a pane can hold, mirroring `Pane.Kind`. Duplicated rather than
|
||||
/// imported so this module stays free of GTK and of the widget tree.
|
||||
pub const Kind = enum { terminal, web };
|
||||
///
|
||||
/// `review` uses `cwd` and nothing else: a review has no shell to start and no
|
||||
/// page to load, so the only thing a layout has to say about one is which
|
||||
/// repository to point it at.
|
||||
pub const Kind = enum { terminal, web, review };
|
||||
|
||||
/// A value the user supplies when opening a layout.
|
||||
pub const Parameter = struct {
|
||||
@@ -63,6 +67,16 @@ pub const Pane = struct {
|
||||
kind: Kind = .terminal,
|
||||
|
||||
/// Directory to start in. A leading `~` is expanded at open time.
|
||||
///
|
||||
/// For a `review` pane this is the directory whose repository the tab
|
||||
/// reviews, resolved as the tab opens rather than read off a terminal —
|
||||
/// which is the only way a layout can say it, since the panes it would be
|
||||
/// read off have not started yet.
|
||||
///
|
||||
/// Left empty — which is what a layout saved before this field existed says
|
||||
/// — the pane opens with no review bound and says so, since the gesture
|
||||
/// that would bind one (asking for the tab's review) finds a review pane
|
||||
/// already there and takes you to it instead.
|
||||
cwd: []const u8 = "",
|
||||
|
||||
/// Script to run once the shell is up. Empty means "just a shell".
|
||||
@@ -791,3 +805,61 @@ test "closingParen counts nesting" {
|
||||
test "an unknown parameter is left visible rather than blanked" {
|
||||
try expectPath("/tmp/{{nope}}", "/tmp/{{nope}}", &.{});
|
||||
}
|
||||
|
||||
test "a review leaf carries a directory through the layout file" {
|
||||
const gpa = std.testing.allocator;
|
||||
|
||||
var layouts: Layouts = .init(gpa);
|
||||
defer layouts.deinit();
|
||||
|
||||
try layouts.parse(
|
||||
\\{"version":1,"layouts":[{"name":"Work","root":{
|
||||
\\ "split":"horizontal","ratio":0.5,
|
||||
\\ "first":{"kind":"terminal","cwd":"{{path}}"},
|
||||
\\ "second":{"kind":"review","cwd":"{{path}}"}}}]}
|
||||
);
|
||||
try std.testing.expectEqual(@as(usize, 1), layouts.items.items.len);
|
||||
|
||||
// The directory is the one field a review leaf has, and it is a template
|
||||
// like any other — a layout aimed at a project reviews that project.
|
||||
const second = layouts.items.items[0].root.split.second;
|
||||
try std.testing.expectEqual(Kind.review, second.pane.kind);
|
||||
try std.testing.expectEqualStrings("{{path}}", second.pane.cwd);
|
||||
try std.testing.expectEqualStrings("", second.pane.url);
|
||||
try expectPath("/tmp/x", second.pane.cwd, &.{.{ .name = "path", .value = "/tmp/x" }});
|
||||
|
||||
// And it survives being written back out, which is what "save tab as
|
||||
// layout" does to a tab that has its review open.
|
||||
var out: std.Io.Writer.Allocating = .init(gpa);
|
||||
defer out.deinit();
|
||||
var json: std.json.Stringify = .{ .writer = &out.writer, .options = .{} };
|
||||
try writeNode(&json, second);
|
||||
try std.testing.expectEqualStrings(
|
||||
"{\"kind\":\"review\",\"cwd\":\"{{path}}\"}",
|
||||
out.written(),
|
||||
);
|
||||
}
|
||||
|
||||
// A layout saved before the directory existed says nothing about one, and still
|
||||
// has to open — its review pane comes up unbound rather than the whole layout
|
||||
// refusing to parse.
|
||||
test "a review leaf without a directory still round-trips" {
|
||||
const gpa = std.testing.allocator;
|
||||
|
||||
var layouts: Layouts = .init(gpa);
|
||||
defer layouts.deinit();
|
||||
|
||||
try layouts.parse(
|
||||
\\{"version":1,"layouts":[{"name":"Work","root":{"kind":"review"}}]}
|
||||
);
|
||||
|
||||
const root = layouts.items.items[0].root;
|
||||
try std.testing.expectEqual(Kind.review, root.pane.kind);
|
||||
try std.testing.expectEqualStrings("", root.pane.cwd);
|
||||
|
||||
var out: std.Io.Writer.Allocating = .init(gpa);
|
||||
defer out.deinit();
|
||||
var json: std.json.Stringify = .{ .writer = &out.writer, .options = .{} };
|
||||
try writeNode(&json, root);
|
||||
try std.testing.expectEqualStrings("{\"kind\":\"review\"}", out.written());
|
||||
}
|
||||
|
||||
+55
-18
@@ -1,7 +1,7 @@
|
||||
//! One pane inside a view: either a terminal or a web view, plus the chrome
|
||||
//! needed to tell panes apart and rearrange them — a header strip showing the
|
||||
//! content's title, which doubles as the drag handle, and a drop target
|
||||
//! covering the whole pane.
|
||||
//! One pane inside a view: a terminal, a web view, or the tab's code review,
|
||||
//! plus the chrome needed to tell panes apart and rearrange them — a header
|
||||
//! strip showing the content's title, which doubles as the drag handle, and a
|
||||
//! drop target covering the whole pane.
|
||||
//!
|
||||
//! The header exists mainly so dragging a pane never competes with the
|
||||
//! content's own mouse handling. Grabbing anywhere in the terminal body would
|
||||
@@ -9,8 +9,8 @@
|
||||
//! page would collide with the page itself.
|
||||
//!
|
||||
//! Everything below the header is behind `Content`, so the layout, drag and
|
||||
//! drop, and focus tracking are all written once and neither kind of content
|
||||
//! is special-cased.
|
||||
//! drop, and focus tracking are all written once and no kind of content is
|
||||
//! special-cased.
|
||||
|
||||
const std = @import("std");
|
||||
const gdk = @import("gdk");
|
||||
@@ -19,6 +19,7 @@ const gtk = @import("gtk");
|
||||
|
||||
const Browser = @import("Browser.zig");
|
||||
const Layout = @import("Layout.zig");
|
||||
const Review = @import("Review.zig");
|
||||
const Terminal = @import("Terminal.zig");
|
||||
const View = @import("View.zig");
|
||||
|
||||
@@ -32,11 +33,17 @@ pub const Kind = enum {
|
||||
terminal,
|
||||
web,
|
||||
|
||||
/// The tab's code review. Unlike the other two there can be only one in a
|
||||
/// view — see `View.addPane` — because it is bound to the tab rather than
|
||||
/// being a thing you can have several of.
|
||||
review,
|
||||
|
||||
/// Icon standing in for this kind in the pane header and the tab row.
|
||||
pub fn iconName(self: Kind) [:0]const u8 {
|
||||
return switch (self) {
|
||||
.terminal => "utilities-terminal-symbolic",
|
||||
.web => "web-browser-symbolic",
|
||||
.review => "document-edit-symbolic",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -45,6 +52,7 @@ pub const Kind = enum {
|
||||
return switch (self) {
|
||||
.terminal => "shell",
|
||||
.web => "web",
|
||||
.review => "review",
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -55,12 +63,18 @@ pub const Kind = enum {
|
||||
pub const Spec = union(Kind) {
|
||||
terminal: Terminal.Options,
|
||||
web: Browser.Options,
|
||||
review: Review.Options,
|
||||
|
||||
/// A plain pane of the given kind, with nothing preloaded.
|
||||
///
|
||||
/// A review pane with nothing preloaded has no endpoint to talk to, so it
|
||||
/// opens explaining itself. Everything that opens one for real fills the
|
||||
/// options in — see `Window.addReview`.
|
||||
pub fn plain(of: Kind) Spec {
|
||||
return switch (of) {
|
||||
.terminal => .{ .terminal = .{} },
|
||||
.web => .{ .web = .{} },
|
||||
.review => .{ .review = .{} },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -70,16 +84,16 @@ pub const Spec = union(Kind) {
|
||||
};
|
||||
|
||||
/// What the content of a pane is currently doing. Only a terminal ever
|
||||
/// reports this — a web pane is always `.idle` — but it lives here rather
|
||||
/// than on Terminal so the view can aggregate across panes without caring
|
||||
/// which kind each one is.
|
||||
/// reports this — a web or review pane is always `.idle` — but it lives here
|
||||
/// rather than on Terminal so the view can aggregate across panes without
|
||||
/// caring which kind each one is.
|
||||
pub const Status = Terminal.Status;
|
||||
|
||||
/// What a content kind reports back to its pane. Shared by both kinds so the
|
||||
/// pane can wire either one up with the same handlers.
|
||||
/// What a content kind reports back to its pane. Shared by all three kinds so
|
||||
/// the pane can wire any of them up with the same handlers.
|
||||
///
|
||||
/// A web pane simply never calls `on_status` or `on_input`; it has no
|
||||
/// equivalent of a long-running job to report.
|
||||
/// A web or review pane simply never calls `on_status` or `on_input`; neither
|
||||
/// has an equivalent of a long-running job to report.
|
||||
pub const Callbacks = struct {
|
||||
on_title: *const fn (ctx: ?*anyopaque, title: []const u8) void,
|
||||
on_exit: *const fn (ctx: ?*anyopaque) void,
|
||||
@@ -89,12 +103,13 @@ pub const Callbacks = struct {
|
||||
ctx: ?*anyopaque,
|
||||
};
|
||||
|
||||
/// The two things a pane can hold. Both expose the same three operations and
|
||||
/// report back through the same three callbacks, which is the whole reason a
|
||||
/// pane can stay ignorant of which one it has.
|
||||
/// The three things a pane can hold. All of them expose the same three
|
||||
/// operations and report back through the same callbacks, which is the whole
|
||||
/// reason a pane can stay ignorant of which one it has.
|
||||
pub const Content = union(Kind) {
|
||||
terminal: *Terminal,
|
||||
web: *Browser,
|
||||
review: *Review,
|
||||
|
||||
pub fn widget(self: Content) *gtk.Widget {
|
||||
return switch (self) {
|
||||
@@ -336,6 +351,7 @@ pub fn create(alloc: std.mem.Allocator, view: *View, spec: Spec) !*Pane {
|
||||
self.content = switch (spec) {
|
||||
.terminal => |opts| .{ .terminal = try .create(alloc, opts, callbacks) },
|
||||
.web => |opts| .{ .web = try .create(alloc, opts, callbacks) },
|
||||
.review => |opts| .{ .review = try .create(alloc, opts, callbacks) },
|
||||
};
|
||||
errdefer self.content.destroy();
|
||||
|
||||
@@ -385,7 +401,7 @@ pub fn grabFocus(self: *Pane) void {
|
||||
pub fn terminal(self: *Pane) ?*Terminal {
|
||||
return switch (self.content) {
|
||||
.terminal => |t| t,
|
||||
.web => null,
|
||||
else => null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -395,7 +411,17 @@ pub fn terminal(self: *Pane) ?*Terminal {
|
||||
pub fn browser(self: *Pane) ?*Browser {
|
||||
return switch (self.content) {
|
||||
.web => |b| b,
|
||||
.terminal => null,
|
||||
else => null,
|
||||
};
|
||||
}
|
||||
|
||||
/// The review this pane holds, or null if it holds something else. Used by the
|
||||
/// view to enforce one review per tab, and by the window to reload the one
|
||||
/// that is open.
|
||||
pub fn review(self: *Pane) ?*Review {
|
||||
return switch (self.content) {
|
||||
.review => |r| r,
|
||||
else => null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -467,6 +493,13 @@ fn buildHeader(self: *Pane) void {
|
||||
_ = gtk.Button.signals.clicked.connect(web, *Pane, &onWebClicked, self, .{});
|
||||
header.append(web.as(gtk.Widget));
|
||||
|
||||
const review_button = gtk.Button.newFromIconName(Kind.review.iconName());
|
||||
review_button.as(gtk.Widget).addCssClass("flat");
|
||||
review_button.as(gtk.Widget).addCssClass("playpen-pane-button");
|
||||
review_button.as(gtk.Widget).setTooltipText("Review this tab's changes (Ctrl+Shift+D)");
|
||||
_ = gtk.Button.signals.clicked.connect(review_button, *Pane, &onReviewClicked, self, .{});
|
||||
header.append(review_button.as(gtk.Widget));
|
||||
|
||||
// Last before close, so the destructive button stays on the end where it
|
||||
// is expected and the zoom toggle sits with the other view controls.
|
||||
self.zoom.as(gtk.Widget).addCssClass("flat");
|
||||
@@ -711,6 +744,10 @@ fn onWebClicked(_: *gtk.Button, self: *Pane) callconv(.c) void {
|
||||
};
|
||||
}
|
||||
|
||||
fn onReviewClicked(_: *gtk.Button, self: *Pane) callconv(.c) void {
|
||||
self.view.requestReview();
|
||||
}
|
||||
|
||||
fn onZoomClicked(_: *gtk.Button, self: *Pane) callconv(.c) void {
|
||||
self.view.toggleZoom(self);
|
||||
}
|
||||
|
||||
+33
-4
@@ -92,11 +92,16 @@ pub const Error = error{
|
||||
/// `dir` is the directory the child starts in. A null, or a directory that
|
||||
/// cannot be entered, leaves it wherever the app was started — a layout
|
||||
/// naming a path that no longer exists should still give you a usable shell.
|
||||
///
|
||||
/// `env_extra` is added to the child's environment as `NAME=value` strings,
|
||||
/// replacing any the parent already had under the same name. This is how a
|
||||
/// shell learns about the tab it is running in.
|
||||
pub fn create(
|
||||
alloc: std.mem.Allocator,
|
||||
path: [:0]const u8,
|
||||
argv: []const [:0]const u8,
|
||||
dir: ?[:0]const u8,
|
||||
env_extra: []const []const u8,
|
||||
size: Winsize,
|
||||
) !Pty {
|
||||
const master = c.posix_openpt(O_RDWR | O_NOCTTY);
|
||||
@@ -118,7 +123,7 @@ pub fn create(
|
||||
defer alloc.free(argv_z);
|
||||
for (argv, 0..) |arg, i| argv_z[i] = arg.ptr;
|
||||
|
||||
const envp_z = try buildEnv(alloc);
|
||||
const envp_z = try buildEnv(alloc, env_extra);
|
||||
defer freeEnv(alloc, envp_z);
|
||||
|
||||
const slave_path_z = try alloc.dupeZ(u8, slave_path);
|
||||
@@ -198,9 +203,18 @@ pub fn loginShell(buf: []u8) ?[]const u8 {
|
||||
}
|
||||
|
||||
/// Copy the current environment, forcing the variables that describe what
|
||||
/// kind of terminal we are. We advertise xterm-256color rather than
|
||||
/// ghostty's own terminfo because we don't install a terminfo entry.
|
||||
fn buildEnv(alloc: std.mem.Allocator) ![:null]?[*:0]const u8 {
|
||||
/// kind of terminal we are, and adding whatever the caller supplied. We
|
||||
/// advertise xterm-256color rather than ghostty's own terminfo because we
|
||||
/// don't install a terminfo entry.
|
||||
///
|
||||
/// Anything we are about to define is dropped from the inherited copy first,
|
||||
/// so a variable set in playpen's own environment cannot shadow the value this
|
||||
/// pane is supposed to see — which matters most for the ones that describe the
|
||||
/// pane itself, since inheriting a stale one is worse than having none.
|
||||
fn buildEnv(
|
||||
alloc: std.mem.Allocator,
|
||||
extra: []const []const u8,
|
||||
) ![:null]?[*:0]const u8 {
|
||||
var list: std.ArrayListUnmanaged([*:0]const u8) = .empty;
|
||||
defer list.deinit(alloc);
|
||||
errdefer for (list.items) |item| alloc.free(std.mem.span(item));
|
||||
@@ -211,16 +225,31 @@ fn buildEnv(alloc: std.mem.Allocator) ![:null]?[*:0]const u8 {
|
||||
// Drop the variables we're about to define ourselves.
|
||||
if (std.mem.startsWith(u8, span, "TERM=")) continue;
|
||||
if (std.mem.startsWith(u8, span, "COLORTERM=")) continue;
|
||||
if (shadowedBy(span, extra)) continue;
|
||||
try list.append(alloc, (try alloc.dupeZ(u8, span)).ptr);
|
||||
}
|
||||
try list.append(alloc, (try alloc.dupeZ(u8, "TERM=xterm-256color")).ptr);
|
||||
try list.append(alloc, (try alloc.dupeZ(u8, "COLORTERM=truecolor")).ptr);
|
||||
for (extra) |entry| {
|
||||
try list.append(alloc, (try alloc.dupeZ(u8, entry)).ptr);
|
||||
}
|
||||
|
||||
const result = try alloc.allocSentinel(?[*:0]const u8, list.items.len, null);
|
||||
for (list.items, 0..) |item, idx| result[idx] = item;
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Whether an inherited `NAME=value` entry names a variable the caller is
|
||||
/// about to define.
|
||||
fn shadowedBy(entry: []const u8, extra: []const []const u8) bool {
|
||||
const eq = std.mem.indexOfScalar(u8, entry, '=') orelse return false;
|
||||
for (extra) |candidate| {
|
||||
const candidate_eq = std.mem.indexOfScalar(u8, candidate, '=') orelse continue;
|
||||
if (std.mem.eql(u8, entry[0 .. eq + 1], candidate[0 .. candidate_eq + 1])) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
fn freeEnv(alloc: std.mem.Allocator, envp: [:null]?[*:0]const u8) void {
|
||||
for (envp) |entry| if (entry) |e| alloc.free(std.mem.span(e));
|
||||
alloc.free(envp);
|
||||
|
||||
+294
@@ -0,0 +1,294 @@
|
||||
//! 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);
|
||||
}
|
||||
@@ -36,8 +36,12 @@ const ParamRow = struct {
|
||||
const PaneRow = struct {
|
||||
node: *Layouts.Node,
|
||||
|
||||
/// Directory and script, for a terminal pane.
|
||||
/// Directory, for a terminal pane or a review pane. The two mean slightly
|
||||
/// different things by it — where the shell starts, versus which repository
|
||||
/// is reviewed — but they are the same field, written back the same way.
|
||||
cwd: ?*gtk.Entry = null,
|
||||
|
||||
/// Script to run, for a terminal pane.
|
||||
command: ?*gtk.Entry = null,
|
||||
|
||||
/// Page to open, for a web pane.
|
||||
@@ -245,6 +249,13 @@ fn buildPaneSection(self: *SaveLayoutDialog, index: usize, leaf: *Layouts.Node)
|
||||
const url = field(grid, 0, "Address", spec.url, "https://example.com");
|
||||
self.panes[index].url = url;
|
||||
},
|
||||
// A review is a repository, so the directory is the whole of it. It is
|
||||
// the same field a terminal's is, down to the `$(...)` expansion, and
|
||||
// the same one a review captured off a live tab is prefilled with.
|
||||
.review => {
|
||||
const cwd = field(grid, 0, "Directory", spec.cwd, "~/projects/{{path}}");
|
||||
self.panes[index].cwd = cwd;
|
||||
},
|
||||
}
|
||||
|
||||
box.append(grid.as(gtk.Widget));
|
||||
|
||||
+24
-1
@@ -106,6 +106,16 @@ pub const Options = struct {
|
||||
|
||||
/// Script fed to the shell once it is up.
|
||||
command: []const u8 = "",
|
||||
|
||||
/// The review endpoint of the tab this pane lives in, exported to the shell
|
||||
/// as `PLAYPEN_REVIEW_URL`.
|
||||
///
|
||||
/// It is the whole reason the review server has a stable address: an agent
|
||||
/// running in this pane reads it out of its own environment and can then
|
||||
/// fetch the comments the person next to it left, with no discovery step and
|
||||
/// no chance of picking up another tab's review. Empty leaves the variable
|
||||
/// unset, which is what an agent sees when the server never started.
|
||||
review_url: []const u8 = "",
|
||||
};
|
||||
|
||||
pub fn create(
|
||||
@@ -179,7 +189,20 @@ pub fn create(
|
||||
null;
|
||||
defer if (cwd_z) |z| alloc.free(z);
|
||||
|
||||
self.pty = try .create(alloc, shell, &.{argv0}, cwd_z, .{
|
||||
// One entry, and only when there is a review to point at: an empty
|
||||
// `PLAYPEN_REVIEW_URL` would read as "there is a review server, and it is at
|
||||
// the empty string", which is worse than the variable being absent.
|
||||
var env_buf: [1][]const u8 = undefined;
|
||||
var env_extra: []const []const u8 = &.{};
|
||||
var review_env: []u8 = &.{};
|
||||
defer alloc.free(review_env);
|
||||
if (opts.review_url.len > 0) {
|
||||
review_env = try std.fmt.allocPrint(alloc, "PLAYPEN_REVIEW_URL={s}", .{opts.review_url});
|
||||
env_buf[0] = review_env;
|
||||
env_extra = &env_buf;
|
||||
}
|
||||
|
||||
self.pty = try .create(alloc, shell, &.{argv0}, cwd_z, env_extra, .{
|
||||
.ws_row = rows,
|
||||
.ws_col = cols,
|
||||
});
|
||||
|
||||
+88
-3
@@ -1,5 +1,5 @@
|
||||
//! A view: the content of one tab, holding one or more panes — terminals, web
|
||||
//! views, or a mix — arranged in a split tree.
|
||||
//! views, the tab's code review, or a mix — arranged in a split tree.
|
||||
//!
|
||||
//! Dragging a pane rearranges the view live rather than on release. Each time
|
||||
//! the drop target changes, the move is applied for real, so what you see
|
||||
@@ -15,6 +15,7 @@ const Browser = @import("Browser.zig");
|
||||
const Layout = @import("Layout.zig");
|
||||
const Layouts = @import("Layouts.zig");
|
||||
const Pane = @import("Pane.zig");
|
||||
const Review = @import("Review.zig");
|
||||
const Terminal = @import("Terminal.zig");
|
||||
|
||||
const View = @This();
|
||||
@@ -75,6 +76,18 @@ zoomed: ?*Pane = null,
|
||||
|
||||
drag: ?Drag = null,
|
||||
|
||||
/// What a review pane in this view should be opened with.
|
||||
///
|
||||
/// Set by the window when the tab is created, because the endpoint is a property
|
||||
/// of the tab and not of any pane. Held here so `applyLayout` can build a review
|
||||
/// pane out of a saved layout without the layout having to carry a URL that
|
||||
/// would be wrong the moment the tab changed.
|
||||
///
|
||||
/// It is also what every *terminal* in this view is handed as
|
||||
/// `PLAYPEN_REVIEW_URL` — see `stamp` — so an agent started in any pane of the
|
||||
/// tab can reach the tab's review without being told where it is.
|
||||
review_spec: Review.Options = .{},
|
||||
|
||||
/// Set while the view is being torn down, so a pane's child exiting doesn't
|
||||
/// try to remove it from a list we're already draining.
|
||||
closing: bool = false,
|
||||
@@ -89,6 +102,13 @@ on_status: *const fn (ctx: ?*anyopaque) void,
|
||||
/// one that finished before your last visit looks identical to one that
|
||||
/// finished after it.
|
||||
on_finished: *const fn (ctx: ?*anyopaque) void,
|
||||
|
||||
/// Something in here asked for a review pane.
|
||||
///
|
||||
/// It goes up to the window rather than being handled here because opening a
|
||||
/// review means resolving the directory the tab is working in and registering it
|
||||
/// with the server, and neither of those is a property of the split tree.
|
||||
on_review: *const fn (ctx: ?*anyopaque) void,
|
||||
ctx: ?*anyopaque = null,
|
||||
|
||||
pub const Callbacks = struct {
|
||||
@@ -96,6 +116,7 @@ pub const Callbacks = struct {
|
||||
on_title: *const fn (ctx: ?*anyopaque) void,
|
||||
on_status: *const fn (ctx: ?*anyopaque) void,
|
||||
on_finished: *const fn (ctx: ?*anyopaque) void,
|
||||
on_review: *const fn (ctx: ?*anyopaque) void,
|
||||
ctx: ?*anyopaque,
|
||||
};
|
||||
|
||||
@@ -111,6 +132,7 @@ pub fn create(alloc: std.mem.Allocator, cbs: Callbacks) !*View {
|
||||
.on_title = cbs.on_title,
|
||||
.on_status = cbs.on_status,
|
||||
.on_finished = cbs.on_finished,
|
||||
.on_review = cbs.on_review,
|
||||
.ctx = cbs.ctx,
|
||||
};
|
||||
|
||||
@@ -243,9 +265,31 @@ pub fn focus(self: *View) void {
|
||||
if (self.focusedPane()) |pane| pane.grabFocus();
|
||||
}
|
||||
|
||||
/// The review pane in this view, if it has one.
|
||||
///
|
||||
/// There is at most one by construction — see `addPane` — which is what lets
|
||||
/// callers treat this as "the review" rather than "a review".
|
||||
pub fn reviewPane(self: *View) ?*Pane {
|
||||
for (self.panes.items) |pane| {
|
||||
if (pane.kind == .review) return pane;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Add a pane, splitting the focused one so the new pane appears beside
|
||||
/// whatever you were working in.
|
||||
pub fn addPane(self: *View, spec: Pane.Spec) !void {
|
||||
///
|
||||
/// A second review pane is refused rather than opened. Two of them would each
|
||||
/// publish their own diff selection to the server, and an agent asked to review
|
||||
/// "the diff I'm looking at" would follow whichever wrote last — so the second
|
||||
/// one would quietly break the first. `error.ReviewAlreadyOpen` is what the
|
||||
/// window turns into "go to the one you have".
|
||||
pub fn addPane(self: *View, raw: Pane.Spec) !void {
|
||||
if (raw.kind() == .review and self.reviewPane() != null) {
|
||||
return error.ReviewAlreadyOpen;
|
||||
}
|
||||
const spec = self.stamp(raw);
|
||||
|
||||
const pane = try Pane.create(self.alloc, self, spec);
|
||||
errdefer pane.destroy();
|
||||
|
||||
@@ -272,6 +316,31 @@ pub fn addPane(self: *View, spec: Pane.Spec) !void {
|
||||
self.on_title(self.ctx);
|
||||
}
|
||||
|
||||
/// Fill in the parts of a spec that come from the tab rather than the caller.
|
||||
///
|
||||
/// Both arms are the tab's review endpoint: a review pane *is* that endpoint,
|
||||
/// and a terminal is handed it so anything started in the pane can find it. Done
|
||||
/// here, in the one place every pane is built, rather than at each of the four
|
||||
/// call sites that construct a spec.
|
||||
fn stamp(self: *View, spec: Pane.Spec) Pane.Spec {
|
||||
if (self.review_spec.url.len == 0) return spec;
|
||||
return switch (spec) {
|
||||
.terminal => |opts| .{ .terminal = blk: {
|
||||
var stamped = opts;
|
||||
stamped.review_url = self.review_spec.url;
|
||||
break :blk stamped;
|
||||
} },
|
||||
.review => .{ .review = self.review_spec },
|
||||
.web => spec,
|
||||
};
|
||||
}
|
||||
|
||||
/// Ask the window to open a review pane in this tab. The pane header's button
|
||||
/// and the keyboard shortcut both land here.
|
||||
pub fn requestReview(self: *View) void {
|
||||
self.on_review(self.ctx);
|
||||
}
|
||||
|
||||
pub fn closePane(self: *View, pane: *Pane) void {
|
||||
if (self.closing) return;
|
||||
|
||||
@@ -443,7 +512,7 @@ fn buildNode(
|
||||
const pane_spec = try self.paneSpec(p, bindings);
|
||||
defer freePaneSpec(self.alloc, pane_spec);
|
||||
|
||||
const pane = try Pane.create(self.alloc, self, pane_spec);
|
||||
const pane = try Pane.create(self.alloc, self, self.stamp(pane_spec));
|
||||
errdefer pane.destroy();
|
||||
|
||||
const node = try self.layout.newLeaf(pane);
|
||||
@@ -485,6 +554,11 @@ fn paneSpec(
|
||||
.web => .{ .web = .{
|
||||
.url = try Layouts.expand(self.alloc, p.url, bindings),
|
||||
} },
|
||||
// The layout's own `cwd` is not read here. A review pane is handed an
|
||||
// endpoint, not a directory: which repository that endpoint serves is
|
||||
// settled by the window before the view is built, precisely so the
|
||||
// page's first load already finds one. See `Window.bindLayoutReview`.
|
||||
.review => .{ .review = self.review_spec },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -495,6 +569,9 @@ fn freePaneSpec(alloc: std.mem.Allocator, spec: Pane.Spec) void {
|
||||
alloc.free(o.command);
|
||||
},
|
||||
.web => |o| alloc.free(o.url),
|
||||
// Borrowed from the window, which owns the strings for as long as the
|
||||
// tab does.
|
||||
.review => {},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -523,6 +600,14 @@ fn captureNode(self: *View, node: *Layout.Node, builder: Layouts.Builder) !*Layo
|
||||
.kind = .web,
|
||||
.url = b.currentUrl(),
|
||||
}),
|
||||
// The repository this review is bound to, so a captured tab
|
||||
// reopens on the same one. Prefilled rather than fixed: the dialog
|
||||
// is where a literal path becomes `{{a parameter}}`, exactly as it
|
||||
// is for a terminal's directory.
|
||||
.review => try builder.pane(.{
|
||||
.kind = .review,
|
||||
.cwd = self.review_spec.repo,
|
||||
}),
|
||||
},
|
||||
.split => |s| try builder.split(
|
||||
switch (s.paned.as(gtk.Orientable).getOrientation()) {
|
||||
|
||||
+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(),
|
||||
|
||||
@@ -13,6 +13,7 @@ const Settings = @import("Settings.zig");
|
||||
const Window = @import("Window.zig");
|
||||
const appearance = @import("appearance.zig");
|
||||
const icons = @import("icons.zig");
|
||||
const review = @import("review.zig");
|
||||
|
||||
/// libghostty-vt logs unimplemented sequences at debug level, which is very
|
||||
/// chatty against a real shell. Keep the app's own warnings and errors.
|
||||
@@ -29,6 +30,11 @@ pub fn main() u8 {
|
||||
// of it. A no-op if the app never got as far as activating.
|
||||
defer Settings.deinit();
|
||||
|
||||
// Also before the allocator, and after the window: the review server owns
|
||||
// threads, and stopping it waits for the ones still inside a request rather
|
||||
// than freeing the memory they are reading. A no-op if it never started.
|
||||
defer review.deinit();
|
||||
|
||||
// Non-unique so every launch is its own process. The default GApplication
|
||||
// behavior hands off to an already-running instance over D-Bus, which for
|
||||
// a terminal means a second launch silently does nothing visible here and
|
||||
@@ -55,6 +61,12 @@ fn onActivate(app: *adw.Application, _: ?*anyopaque) callconv(.c) void {
|
||||
// for one by name.
|
||||
icons.init();
|
||||
|
||||
// Before the window, because every tab is announced to the server as it is
|
||||
// created and every terminal is handed the endpoint of the tab it opens in.
|
||||
// Started unconditionally rather than on the first review pane, so an agent
|
||||
// running in a tab has a `PLAYPEN_REVIEW_URL` from the moment it starts.
|
||||
review.init(gpa.allocator());
|
||||
|
||||
const window = Window.create(gpa.allocator(), app) catch |err| {
|
||||
std.log.err("failed to create window: {s}", .{@errorName(err)});
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
//! The review server, as the rest of the app sees it: one process-wide instance,
|
||||
//! started at activation and stopped on the way out.
|
||||
//!
|
||||
//! A singleton for the same reason `Settings` is one — there is exactly one of
|
||||
//! it, everything wants at it, and threading a pointer down through the window,
|
||||
//! the tab, the view and the pane would be four parameters carried for one
|
||||
//! consumer. What lives here is only the lifecycle; the server itself is
|
||||
//! `review/Server.zig`, and nothing in this file or under it touches a widget.
|
||||
//!
|
||||
//! This is also the one place in playpen with threads in it. `std.Io.Threaded`
|
||||
//! is created here and handed to the server, which runs its listener and each
|
||||
//! connection on a thread of its own. That is not gold-plating: a request spends
|
||||
//! most of its life inside `git diff`, and doing that on the GTK main loop would
|
||||
//! freeze the window for the length of every fetch.
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
pub const Server = @import("review/Server.zig");
|
||||
|
||||
/// The port the server tries first, and the range it walks if that is taken.
|
||||
///
|
||||
/// A predictable number matters more here than it looks. Every terminal pane is
|
||||
/// handed a `PLAYPEN_REVIEW_URL` so an agent never has to guess — but a person
|
||||
/// poking at the API with `curl`, or running the UI's dev server against a live
|
||||
/// review, is much better off with a number they can remember than with whatever
|
||||
/// the kernel handed out. The walk is for a second playpen window: the first one
|
||||
/// keeps 8420, the second quietly takes 8421.
|
||||
const preferred_port: u16 = 8420;
|
||||
const port_range: u16 = 20;
|
||||
|
||||
/// Override for the port to try first, for developing the UI against a specific
|
||||
/// window. Read once at startup.
|
||||
const port_env = "PLAYPEN_REVIEW_PORT";
|
||||
|
||||
var threaded: std.Io.Threaded = undefined;
|
||||
var server: ?*Server = null;
|
||||
|
||||
/// Start the server. Called once at activation, before the first window.
|
||||
///
|
||||
/// Failure is logged and swallowed. A playpen with no review server is a
|
||||
/// playpen with no review panes, which is a smaller loss than refusing to open a
|
||||
/// window at all — and a review pane opened in that state says so rather than
|
||||
/// showing a blank page.
|
||||
pub fn init(gpa: std.mem.Allocator) void {
|
||||
threaded = .init(gpa, .{});
|
||||
|
||||
const instance = Server.create(gpa, threaded.io()) catch |err| {
|
||||
std.log.warn("review: could not create the server: {s}", .{@errorName(err)});
|
||||
threaded.deinit();
|
||||
return;
|
||||
};
|
||||
|
||||
const first = firstPort();
|
||||
var port = first;
|
||||
while (port < first + port_range) : (port += 1) {
|
||||
instance.start(port) catch continue;
|
||||
server = instance;
|
||||
return;
|
||||
}
|
||||
|
||||
// Every port in the range was taken — more playpen windows than the range
|
||||
// allows, or something else living there. An ephemeral port still gives a
|
||||
// working review; it is only less guessable.
|
||||
instance.start(0) catch |err| {
|
||||
std.log.warn("review: could not listen: {s}", .{@errorName(err)});
|
||||
instance.destroy();
|
||||
threaded.deinit();
|
||||
return;
|
||||
};
|
||||
server = instance;
|
||||
}
|
||||
|
||||
pub fn deinit() void {
|
||||
const instance = server orelse return;
|
||||
server = null;
|
||||
instance.destroy();
|
||||
threaded.deinit();
|
||||
}
|
||||
|
||||
/// The running server, or null if it never started.
|
||||
pub fn get() ?*Server {
|
||||
return server;
|
||||
}
|
||||
|
||||
fn firstPort() u16 {
|
||||
const raw = std.mem.span(std.c.getenv(port_env) orelse return preferred_port);
|
||||
return std.fmt.parseInt(u16, std.mem.trim(u8, raw, " \t"), 10) catch {
|
||||
std.log.warn("review: ignoring {s}={s}, which is not a port", .{ port_env, raw });
|
||||
return preferred_port;
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,632 @@
|
||||
//! The comment store for one review: every thread, persisted as JSON.
|
||||
//!
|
||||
//! The file lives inside the repository's git directory, so it never shows up
|
||||
//! in the diff being reviewed and is naturally per-worktree. It is written
|
||||
//! whole on every mutation, through an unnamed temporary that is renamed into
|
||||
//! place — a half-written review file is a lost review, and this is a few
|
||||
//! kilobytes, so there is nothing to gain by being cleverer.
|
||||
//!
|
||||
//! **Everything here is called from the server's connection threads**, which is
|
||||
//! why the mutex is on the store rather than around its call sites: a reply
|
||||
//! arriving over HTTP and the UI reloading the list are genuinely concurrent.
|
||||
//!
|
||||
//! Each comment owns an arena. Freeing a thread is then dropping one allocator
|
||||
//! rather than walking a struct-shaped graph of strings, and editing a body can
|
||||
//! leave the old one behind without leaking anything that outlives the comment.
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
const model = @import("model.zig");
|
||||
|
||||
const Store = @This();
|
||||
|
||||
const Comment = model.Comment;
|
||||
const Reply = model.Reply;
|
||||
|
||||
/// One thread and the arena its strings live in.
|
||||
const Entry = struct {
|
||||
arena: std.heap.ArenaAllocator,
|
||||
comment: Comment,
|
||||
replies: std.ArrayListUnmanaged(Reply) = .empty,
|
||||
|
||||
fn deinit(self: *Entry, gpa: std.mem.Allocator) void {
|
||||
self.arena.deinit();
|
||||
gpa.destroy(self);
|
||||
}
|
||||
};
|
||||
|
||||
pub const Error = error{
|
||||
NotFound,
|
||||
OutOfMemory,
|
||||
/// The review could not be written to disk. The in-memory change is rolled
|
||||
/// back before this is returned, so a failed save never leaves the store
|
||||
/// claiming something the file does not say.
|
||||
SaveFailed,
|
||||
};
|
||||
|
||||
gpa: std.mem.Allocator,
|
||||
io: std.Io,
|
||||
mutex: std.Io.Mutex = .init,
|
||||
|
||||
/// Absolute path to the JSON file. Owned.
|
||||
path: []u8,
|
||||
|
||||
/// Threads in creation order, which is the order everything is served in.
|
||||
entries: std.ArrayListUnmanaged(*Entry) = .empty,
|
||||
|
||||
/// Cap on the review file. A review is comments a person typed; anything past
|
||||
/// this is a corrupt or hand-edited file, and refusing it is better than
|
||||
/// spending the memory to find out.
|
||||
const max_file_bytes = 32 * 1024 * 1024;
|
||||
|
||||
/// Open the store backing `path`, loading whatever is already there.
|
||||
///
|
||||
/// A missing file is the normal first-run case and loads as an empty review. A
|
||||
/// file that exists but cannot be parsed is *not* silently discarded: it is
|
||||
/// reported, so the caller can refuse to open the review rather than overwrite
|
||||
/// someone's comments on the next save.
|
||||
pub fn open(gpa: std.mem.Allocator, io: std.Io, path: []const u8) !*Store {
|
||||
const self = try gpa.create(Store);
|
||||
errdefer gpa.destroy(self);
|
||||
|
||||
self.* = .{
|
||||
.gpa = gpa,
|
||||
.io = io,
|
||||
.path = try gpa.dupe(u8, path),
|
||||
};
|
||||
errdefer gpa.free(self.path);
|
||||
|
||||
try self.load();
|
||||
return self;
|
||||
}
|
||||
|
||||
pub fn close(self: *Store) void {
|
||||
for (self.entries.items) |entry| entry.deinit(self.gpa);
|
||||
self.entries.deinit(self.gpa);
|
||||
self.gpa.free(self.path);
|
||||
self.gpa.destroy(self);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Persistence
|
||||
|
||||
/// The on-disk shape. Deliberately the same object review-tool wrote, so a
|
||||
/// repository that was reviewed there opens here with its comments intact.
|
||||
const FileData = struct {
|
||||
comments: []const Comment = &.{},
|
||||
};
|
||||
|
||||
fn load(self: *Store) !void {
|
||||
const bytes = std.Io.Dir.cwd().readFileAlloc(
|
||||
self.io,
|
||||
self.path,
|
||||
self.gpa,
|
||||
.limited(max_file_bytes),
|
||||
) catch |err| switch (err) {
|
||||
error.FileNotFound => return,
|
||||
else => return err,
|
||||
};
|
||||
defer self.gpa.free(bytes);
|
||||
|
||||
if (std.mem.trim(u8, bytes, " \t\r\n").len == 0) return;
|
||||
|
||||
const parsed = try std.json.parseFromSlice(
|
||||
FileData,
|
||||
self.gpa,
|
||||
bytes,
|
||||
.{ .ignore_unknown_fields = true },
|
||||
);
|
||||
defer parsed.deinit();
|
||||
|
||||
for (parsed.value.comments) |c| {
|
||||
const entry = try self.adopt(c);
|
||||
errdefer entry.deinit(self.gpa);
|
||||
try self.entries.append(self.gpa, entry);
|
||||
}
|
||||
|
||||
// Creation order is the order everything is served in, and a file written
|
||||
// by an older version — or edited by hand — need not already be in it.
|
||||
std.mem.sort(*Entry, self.entries.items, {}, lessByCreated);
|
||||
}
|
||||
|
||||
fn lessByCreated(_: void, a: *Entry, b: *Entry) bool {
|
||||
// RFC 3339 in UTC sorts lexicographically, which is most of why the
|
||||
// timestamps are stored as text.
|
||||
return std.mem.order(u8, a.comment.createdAt, b.comment.createdAt) == .lt;
|
||||
}
|
||||
|
||||
/// Copy a parsed comment into an entry that owns every string in it.
|
||||
fn adopt(self: *Store, c: Comment) !*Entry {
|
||||
const entry = try self.gpa.create(Entry);
|
||||
errdefer self.gpa.destroy(entry);
|
||||
|
||||
entry.* = .{ .arena = .init(self.gpa), .comment = undefined };
|
||||
errdefer entry.arena.deinit();
|
||||
|
||||
const a = entry.arena.allocator();
|
||||
entry.comment = .{
|
||||
.id = try a.dupe(u8, c.id),
|
||||
.level = c.level,
|
||||
.file = try a.dupe(u8, c.file),
|
||||
.side = try a.dupe(u8, c.side),
|
||||
.line = c.line,
|
||||
.endLine = c.endLine,
|
||||
.body = try a.dupe(u8, c.body),
|
||||
.author = c.author,
|
||||
.status = c.status,
|
||||
.replies = &.{},
|
||||
.context = .{
|
||||
.base = try a.dupe(u8, c.context.base),
|
||||
.uncommitted = c.context.uncommitted,
|
||||
.commit = try a.dupe(u8, c.context.commit),
|
||||
},
|
||||
.createdAt = try a.dupe(u8, c.createdAt),
|
||||
.updatedAt = try a.dupe(u8, c.updatedAt),
|
||||
};
|
||||
|
||||
for (c.replies) |r| {
|
||||
try entry.replies.append(a, .{
|
||||
.id = try a.dupe(u8, r.id),
|
||||
.author = r.author,
|
||||
.body = try a.dupe(u8, r.body),
|
||||
.createdAt = try a.dupe(u8, r.createdAt),
|
||||
});
|
||||
}
|
||||
entry.comment.replies = entry.replies.items;
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
/// Write the whole review out. Callers must hold the mutex.
|
||||
fn save(self: *Store) Error!void {
|
||||
var flat: std.ArrayListUnmanaged(Comment) = .empty;
|
||||
defer flat.deinit(self.gpa);
|
||||
flat.ensureTotalCapacity(self.gpa, self.entries.items.len) catch return error.OutOfMemory;
|
||||
for (self.entries.items) |entry| flat.appendAssumeCapacity(entry.comment);
|
||||
|
||||
const json = std.json.Stringify.valueAlloc(
|
||||
self.gpa,
|
||||
FileData{ .comments = flat.items },
|
||||
.{ .whitespace = .indent_2 },
|
||||
) catch return error.OutOfMemory;
|
||||
defer self.gpa.free(json);
|
||||
|
||||
var atomic = std.Io.Dir.cwd().createFileAtomic(self.io, self.path, .{
|
||||
.make_path = true,
|
||||
.replace = true,
|
||||
}) catch return error.SaveFailed;
|
||||
defer atomic.deinit(self.io);
|
||||
|
||||
var buf: [4096]u8 = undefined;
|
||||
var writer = atomic.file.writer(self.io, &buf);
|
||||
writer.interface.writeAll(json) catch return error.SaveFailed;
|
||||
writer.interface.flush() catch return error.SaveFailed;
|
||||
atomic.replace(self.io) catch return error.SaveFailed;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Reading
|
||||
//
|
||||
// Every read returns a snapshot allocated with the caller's allocator rather
|
||||
// than lending out the store's own strings: the caller is a connection thread
|
||||
// about to serialize and write to a socket, and holding the store's lock for
|
||||
// the length of a socket write would let a stalled client block every other
|
||||
// request.
|
||||
|
||||
/// Every thread in the review, oldest first.
|
||||
///
|
||||
/// Deliberately not filtered by diff context. A comment is content someone
|
||||
/// typed: it has to survive the base ref moving, the working tree being
|
||||
/// committed, or the page being reloaded onto a different selection. Whether a
|
||||
/// comment still lines up with the diff on screen is the frontend's judgement —
|
||||
/// it has the parsed diff, and it marks the ones it cannot place as outdated.
|
||||
pub fn list(self: *Store, gpa: std.mem.Allocator) ![]Comment {
|
||||
self.mutex.lockUncancelable(self.io);
|
||||
defer self.mutex.unlock(self.io);
|
||||
return self.snapshot(gpa, null);
|
||||
}
|
||||
|
||||
/// The submitted, unresolved threads — the actionable queue an agent works.
|
||||
pub fn pending(self: *Store, gpa: std.mem.Allocator) ![]Comment {
|
||||
self.mutex.lockUncancelable(self.io);
|
||||
defer self.mutex.unlock(self.io);
|
||||
return self.snapshot(gpa, .submitted);
|
||||
}
|
||||
|
||||
fn snapshot(self: *Store, gpa: std.mem.Allocator, only: ?model.Status) ![]Comment {
|
||||
var out: std.ArrayListUnmanaged(Comment) = .empty;
|
||||
errdefer out.deinit(gpa);
|
||||
|
||||
for (self.entries.items) |entry| {
|
||||
if (only) |status| if (entry.comment.status != status) continue;
|
||||
try out.append(gpa, entry.comment);
|
||||
}
|
||||
return out.toOwnedSlice(gpa);
|
||||
}
|
||||
|
||||
/// How many threads are in a status. Both counts the UI badges with.
|
||||
pub fn countByStatus(self: *Store, status: model.Status) u32 {
|
||||
self.mutex.lockUncancelable(self.io);
|
||||
defer self.mutex.unlock(self.io);
|
||||
var n: u32 = 0;
|
||||
for (self.entries.items) |entry| {
|
||||
if (entry.comment.status == status) n += 1;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Writing
|
||||
|
||||
/// What a caller supplies to open a thread. Identity, status and timestamps are
|
||||
/// assigned here.
|
||||
pub const New = struct {
|
||||
level: model.Level = .line,
|
||||
file: []const u8 = "",
|
||||
side: []const u8 = "",
|
||||
line: u32 = 0,
|
||||
endLine: u32 = 0,
|
||||
body: []const u8,
|
||||
author: model.Author = .user,
|
||||
context: model.DiffContext = .{},
|
||||
};
|
||||
|
||||
/// Open a thread.
|
||||
///
|
||||
/// The status follows from the author, and that is the whole rule. A person
|
||||
/// composes drafts and decides when to submit them, so their comment starts as
|
||||
/// a draft. An agent has no drafting step — it posts a review it has already
|
||||
/// decided on — so its comments are born submitted: open threads, with no
|
||||
/// "Submit review" click standing between them and being read.
|
||||
pub fn add(self: *Store, in: New) Error!Comment {
|
||||
self.mutex.lockUncancelable(self.io);
|
||||
defer self.mutex.unlock(self.io);
|
||||
|
||||
const entry = self.gpa.create(Entry) catch return error.OutOfMemory;
|
||||
entry.* = .{ .arena = .init(self.gpa), .comment = undefined };
|
||||
errdefer entry.deinit(self.gpa);
|
||||
|
||||
const a = entry.arena.allocator();
|
||||
const now = try self.stampAlloc(a);
|
||||
|
||||
var id_buf: [16]u8 = undefined;
|
||||
entry.comment = .{
|
||||
.id = a.dupe(u8, self.newId(&id_buf)) catch return error.OutOfMemory,
|
||||
.level = in.level,
|
||||
.file = a.dupe(u8, in.file) catch return error.OutOfMemory,
|
||||
.side = a.dupe(u8, in.side) catch return error.OutOfMemory,
|
||||
.line = in.line,
|
||||
.endLine = if (in.level == .line and in.endLine < in.line) in.line else in.endLine,
|
||||
.body = a.dupe(u8, in.body) catch return error.OutOfMemory,
|
||||
.author = in.author,
|
||||
.status = if (in.author == .claude) .submitted else .draft,
|
||||
.replies = &.{},
|
||||
.context = .{
|
||||
.base = a.dupe(u8, in.context.base) catch return error.OutOfMemory,
|
||||
.uncommitted = in.context.uncommitted,
|
||||
.commit = a.dupe(u8, in.context.commit) catch return error.OutOfMemory,
|
||||
},
|
||||
.createdAt = now,
|
||||
.updatedAt = now,
|
||||
};
|
||||
|
||||
self.entries.append(self.gpa, entry) catch return error.OutOfMemory;
|
||||
errdefer _ = self.entries.pop();
|
||||
|
||||
try self.save();
|
||||
return entry.comment;
|
||||
}
|
||||
|
||||
pub fn updateBody(self: *Store, id: []const u8, body: []const u8) Error!Comment {
|
||||
self.mutex.lockUncancelable(self.io);
|
||||
defer self.mutex.unlock(self.io);
|
||||
|
||||
const entry = self.find(id) orelse return error.NotFound;
|
||||
const a = entry.arena.allocator();
|
||||
const previous = entry.comment.body;
|
||||
|
||||
entry.comment.body = a.dupe(u8, body) catch return error.OutOfMemory;
|
||||
errdefer entry.comment.body = previous;
|
||||
|
||||
try self.touch(entry);
|
||||
return entry.comment;
|
||||
}
|
||||
|
||||
pub fn delete(self: *Store, id: []const u8) Error!void {
|
||||
self.mutex.lockUncancelable(self.io);
|
||||
defer self.mutex.unlock(self.io);
|
||||
|
||||
const index = self.indexOf(id) orelse return error.NotFound;
|
||||
const entry = self.entries.orderedRemove(index);
|
||||
|
||||
self.save() catch |err| {
|
||||
// Put it back rather than leave the store disagreeing with the file.
|
||||
self.entries.insert(self.gpa, index, entry) catch entry.deinit(self.gpa);
|
||||
return err;
|
||||
};
|
||||
entry.deinit(self.gpa);
|
||||
}
|
||||
|
||||
pub fn addReply(
|
||||
self: *Store,
|
||||
id: []const u8,
|
||||
author: model.Author,
|
||||
body: []const u8,
|
||||
) Error!Comment {
|
||||
self.mutex.lockUncancelable(self.io);
|
||||
defer self.mutex.unlock(self.io);
|
||||
|
||||
const entry = self.find(id) orelse return error.NotFound;
|
||||
const a = entry.arena.allocator();
|
||||
|
||||
var id_buf: [16]u8 = undefined;
|
||||
entry.replies.append(a, .{
|
||||
.id = a.dupe(u8, self.newId(&id_buf)) catch return error.OutOfMemory,
|
||||
.author = author,
|
||||
.body = a.dupe(u8, body) catch return error.OutOfMemory,
|
||||
.createdAt = try self.stampAlloc(a),
|
||||
}) catch return error.OutOfMemory;
|
||||
errdefer _ = entry.replies.pop();
|
||||
entry.comment.replies = entry.replies.items;
|
||||
|
||||
try self.touch(entry);
|
||||
return entry.comment;
|
||||
}
|
||||
|
||||
/// Edit one reply. Reply ids are only unique inside their thread, so both are
|
||||
/// required.
|
||||
pub fn updateReplyBody(
|
||||
self: *Store,
|
||||
id: []const u8,
|
||||
reply_id: []const u8,
|
||||
body: []const u8,
|
||||
) Error!Comment {
|
||||
self.mutex.lockUncancelable(self.io);
|
||||
defer self.mutex.unlock(self.io);
|
||||
|
||||
const entry = self.find(id) orelse return error.NotFound;
|
||||
const a = entry.arena.allocator();
|
||||
|
||||
for (entry.replies.items) |*reply| {
|
||||
if (!std.mem.eql(u8, reply.id, reply_id)) continue;
|
||||
|
||||
const previous = reply.body;
|
||||
reply.body = a.dupe(u8, body) catch return error.OutOfMemory;
|
||||
errdefer reply.body = previous;
|
||||
|
||||
entry.comment.replies = entry.replies.items;
|
||||
try self.touch(entry);
|
||||
return entry.comment;
|
||||
}
|
||||
return error.NotFound;
|
||||
}
|
||||
|
||||
pub fn setStatus(self: *Store, id: []const u8, status: model.Status) Error!Comment {
|
||||
self.mutex.lockUncancelable(self.io);
|
||||
defer self.mutex.unlock(self.io);
|
||||
|
||||
const entry = self.find(id) orelse return error.NotFound;
|
||||
const previous = entry.comment.status;
|
||||
entry.comment.status = status;
|
||||
errdefer entry.comment.status = previous;
|
||||
|
||||
try self.touch(entry);
|
||||
return entry.comment;
|
||||
}
|
||||
|
||||
/// Flip every draft to submitted, and report how many moved.
|
||||
///
|
||||
/// Review-wide rather than per diff context, to match `list`: a draft visible in
|
||||
/// the rail has to be submittable, or changing the base ref after writing one
|
||||
/// would strand it as a draft no agent ever sees.
|
||||
pub fn submitDrafts(self: *Store) Error!u32 {
|
||||
self.mutex.lockUncancelable(self.io);
|
||||
defer self.mutex.unlock(self.io);
|
||||
|
||||
var n: u32 = 0;
|
||||
for (self.entries.items) |entry| {
|
||||
if (entry.comment.status != .draft) continue;
|
||||
entry.comment.status = .submitted;
|
||||
entry.comment.updatedAt = self.stampAlloc(entry.arena.allocator()) catch
|
||||
entry.comment.updatedAt;
|
||||
n += 1;
|
||||
}
|
||||
if (n == 0) return 0;
|
||||
|
||||
try self.save();
|
||||
return n;
|
||||
}
|
||||
|
||||
/// Delete every thread — drafts, submitted, and resolved alike.
|
||||
///
|
||||
/// This backs the UI's "reset review" button, and a review that kept its
|
||||
/// resolved threads would not be the fresh start that asks for.
|
||||
pub fn reset(self: *Store) Error!u32 {
|
||||
return self.deleteWhere(null);
|
||||
}
|
||||
|
||||
/// Delete the resolved threads and leave everything else alone, for tidying
|
||||
/// finished work out of the rail without throwing the review out.
|
||||
pub fn deleteResolved(self: *Store) Error!u32 {
|
||||
return self.deleteWhere(.resolved);
|
||||
}
|
||||
|
||||
fn deleteWhere(self: *Store, status: ?model.Status) Error!u32 {
|
||||
self.mutex.lockUncancelable(self.io);
|
||||
defer self.mutex.unlock(self.io);
|
||||
|
||||
var doomed: std.ArrayListUnmanaged(*Entry) = .empty;
|
||||
defer doomed.deinit(self.gpa);
|
||||
|
||||
var kept: std.ArrayListUnmanaged(*Entry) = .empty;
|
||||
errdefer kept.deinit(self.gpa);
|
||||
|
||||
for (self.entries.items) |entry| {
|
||||
const matches = if (status) |s| entry.comment.status == s else true;
|
||||
if (matches) {
|
||||
doomed.append(self.gpa, entry) catch return error.OutOfMemory;
|
||||
} else {
|
||||
kept.append(self.gpa, entry) catch return error.OutOfMemory;
|
||||
}
|
||||
}
|
||||
if (doomed.items.len == 0) {
|
||||
kept.deinit(self.gpa);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Swap the surviving list in before saving, and swap the old one back if
|
||||
// the write fails, so a failed reset is a no-op rather than a half-reset.
|
||||
const previous = self.entries;
|
||||
self.entries = kept;
|
||||
self.save() catch |err| {
|
||||
self.entries.deinit(self.gpa);
|
||||
self.entries = previous;
|
||||
return err;
|
||||
};
|
||||
var old = previous;
|
||||
old.deinit(self.gpa);
|
||||
|
||||
const n: u32 = @intCast(doomed.items.len);
|
||||
for (doomed.items) |entry| entry.deinit(self.gpa);
|
||||
return n;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Internals. All of these assume the mutex is held.
|
||||
|
||||
fn find(self: *Store, id: []const u8) ?*Entry {
|
||||
const index = self.indexOf(id) orelse return null;
|
||||
return self.entries.items[index];
|
||||
}
|
||||
|
||||
fn indexOf(self: *Store, id: []const u8) ?usize {
|
||||
for (self.entries.items, 0..) |entry, i| {
|
||||
if (std.mem.eql(u8, entry.comment.id, id)) return i;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Stamp a thread as just-edited and persist. A failed save leaves the stamp
|
||||
/// moved, which is the one inconsistency worth accepting: the caller has
|
||||
/// already rolled back the change that mattered.
|
||||
fn touch(self: *Store, entry: *Entry) Error!void {
|
||||
entry.comment.updatedAt = self.stampAlloc(entry.arena.allocator()) catch
|
||||
entry.comment.updatedAt;
|
||||
try self.save();
|
||||
}
|
||||
|
||||
fn stampAlloc(self: *Store, gpa: std.mem.Allocator) error{OutOfMemory}![]const u8 {
|
||||
var buf: [32]u8 = undefined;
|
||||
return gpa.dupe(u8, stamp(self.io, &buf));
|
||||
}
|
||||
|
||||
/// Now, as RFC 3339 in UTC.
|
||||
///
|
||||
/// Text rather than a number because it is what the wire format and the on-disk
|
||||
/// file both carry, and because it sorts: the store's ordering is a
|
||||
/// lexicographic compare on this, with no parsing step in between.
|
||||
pub fn stamp(io: std.Io, buf: *[32]u8) []const u8 {
|
||||
const now = std.Io.Timestamp.now(io, .real);
|
||||
const secs: i64 = @intCast(@divFloor(now.nanoseconds, std.time.ns_per_s));
|
||||
const millis: u64 = @intCast(@divFloor(@mod(now.nanoseconds, std.time.ns_per_s), std.time.ns_per_ms));
|
||||
|
||||
const epoch: std.time.epoch.EpochSeconds = .{ .secs = @intCast(@max(secs, 0)) };
|
||||
const day = epoch.getEpochDay();
|
||||
const year_day = day.calculateYearDay();
|
||||
const month_day = year_day.calculateMonthDay();
|
||||
const time = epoch.getDaySeconds();
|
||||
|
||||
return std.fmt.bufPrint(buf, "{d:0>4}-{d:0>2}-{d:0>2}T{d:0>2}:{d:0>2}:{d:0>2}.{d:0>3}Z", .{
|
||||
year_day.year,
|
||||
month_day.month.numeric(),
|
||||
month_day.day_index + 1,
|
||||
time.getHoursIntoDay(),
|
||||
time.getMinutesIntoHour(),
|
||||
time.getSecondsIntoMinute(),
|
||||
millis,
|
||||
}) catch unreachable;
|
||||
}
|
||||
|
||||
/// Eight random bytes, hex. Ids only have to be unique inside one review, so
|
||||
/// there is nothing to gain from a UUID's shape.
|
||||
fn newId(self: *Store, buf: *[16]u8) []const u8 {
|
||||
var raw: [8]u8 = undefined;
|
||||
self.io.random(&raw);
|
||||
return std.fmt.bufPrint(buf, "{x}", .{&raw}) catch unreachable;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Tests
|
||||
//
|
||||
// The store is the one piece of the review server with state that has to survive
|
||||
// the process, so what these cover is the round trip: what a review looks like
|
||||
// after being written, closed, and opened again.
|
||||
|
||||
test "store round-trips a review through the file" {
|
||||
const gpa = std.testing.allocator;
|
||||
var threaded: std.Io.Threaded = .init(gpa, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var tmp = std.testing.tmpDir(.{});
|
||||
defer tmp.cleanup();
|
||||
var dir_buf: [std.fs.max_path_bytes]u8 = undefined;
|
||||
const dir_len = try tmp.dir.realPath(io, &dir_buf);
|
||||
const path = try std.fs.path.join(gpa, &.{ dir_buf[0..dir_len], "nested", "reviews.json" });
|
||||
defer gpa.free(path);
|
||||
|
||||
var arena: std.heap.ArenaAllocator = .init(gpa);
|
||||
defer arena.deinit();
|
||||
const a = arena.allocator();
|
||||
|
||||
{
|
||||
const store = try open(gpa, io, path);
|
||||
defer store.close();
|
||||
|
||||
const c = try store.add(.{
|
||||
.level = .line,
|
||||
.file = "src/main.zig",
|
||||
.side = model.side_new,
|
||||
.line = 12,
|
||||
.body = "Should fix: this leaks.",
|
||||
.context = .{ .base = "main", .uncommitted = true },
|
||||
});
|
||||
try std.testing.expectEqual(model.Status.draft, c.status);
|
||||
try std.testing.expectEqual(@as(u32, 12), c.endLine);
|
||||
|
||||
const agent = try store.add(.{ .body = "Nit: naming.", .author = .claude, .level = .review });
|
||||
try std.testing.expectEqual(model.Status.submitted, agent.status);
|
||||
|
||||
_ = try store.addReply(c.id, .claude, "Fixed.");
|
||||
try std.testing.expectEqual(@as(u32, 1), store.countByStatus(.draft));
|
||||
try std.testing.expectEqual(@as(u32, 1), try store.submitDrafts());
|
||||
try std.testing.expectEqual(@as(u32, 2), store.countByStatus(.submitted));
|
||||
|
||||
_ = try store.setStatus(agent.id, .resolved);
|
||||
try std.testing.expectEqual(@as(u32, 1), try store.deleteResolved());
|
||||
}
|
||||
|
||||
// Reopen: what survived the process is what the file said.
|
||||
const store = try open(gpa, io, path);
|
||||
defer store.close();
|
||||
const all = try store.list(a);
|
||||
try std.testing.expectEqual(@as(usize, 1), all.len);
|
||||
try std.testing.expectEqualStrings("src/main.zig", all[0].file);
|
||||
try std.testing.expectEqual(@as(usize, 1), all[0].replies.len);
|
||||
try std.testing.expectEqualStrings("Fixed.", all[0].replies[0].body);
|
||||
try std.testing.expectEqual(model.Status.submitted, all[0].status);
|
||||
|
||||
try std.testing.expectError(error.NotFound, store.setStatus("nope", .resolved));
|
||||
try std.testing.expectEqual(@as(u32, 1), try store.reset());
|
||||
try std.testing.expectEqual(@as(usize, 0), (try store.list(a)).len);
|
||||
}
|
||||
|
||||
test "stamp is RFC 3339 and sorts" {
|
||||
var threaded: std.Io.Threaded = .init(std.testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
var buf: [32]u8 = undefined;
|
||||
const s = stamp(threaded.io(), &buf);
|
||||
try std.testing.expectEqual(@as(usize, 24), s.len);
|
||||
try std.testing.expectEqual(@as(u8, 'T'), s[10]);
|
||||
try std.testing.expectEqual(@as(u8, 'Z'), s[23]);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
//! The review UI, carried inside the binary.
|
||||
//!
|
||||
//! The page is a React app built by Vite (see `web/`), and `build.zig` runs that
|
||||
//! build and hands the four files it produces to `@embedFile`. Bundling them the
|
||||
//! way the icons are bundled keeps playpen a single binary: the review pane is a
|
||||
//! web view pointed at this process, not at a directory someone has to have
|
||||
//! installed alongside it.
|
||||
//!
|
||||
//! The bundle's filenames are pinned in `web/vite.config.ts` rather than left as
|
||||
//! Vite's content hashes, precisely so this list can be written down. Cache
|
||||
//! busting is not needed for a bundle that only changes when the binary does,
|
||||
//! and the server sends `cache-control: no-cache` for these anyway.
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
pub const File = struct {
|
||||
bytes: []const u8,
|
||||
mime: []const u8,
|
||||
};
|
||||
|
||||
const index_html = @embedFile("review-index.html");
|
||||
const app_js = @embedFile("review-app.js");
|
||||
const app_css = @embedFile("review-app.css");
|
||||
const favicon_svg = @embedFile("review-favicon.svg");
|
||||
|
||||
/// Whether the UI was built into this binary at all.
|
||||
///
|
||||
/// False when `build.zig` could not run the Vite build — no node, or the
|
||||
/// dependencies were never installed — in which case a placeholder page saying
|
||||
/// so is embedded instead of the app. The review pane still opens; it just
|
||||
/// explains itself rather than rendering a blank web view.
|
||||
pub const present = index_html.len > 0 and app_js.len > 0;
|
||||
|
||||
/// Look up one file by the path the browser asked for, relative to the tab root.
|
||||
pub fn find(name: []const u8) ?File {
|
||||
const path = std.mem.trimStart(u8, name, "/");
|
||||
|
||||
if (path.len == 0 or std.mem.eql(u8, path, "index.html")) return .{
|
||||
.bytes = index_html,
|
||||
.mime = "text/html; charset=utf-8",
|
||||
};
|
||||
if (std.mem.eql(u8, path, "assets/app.js")) return .{
|
||||
.bytes = app_js,
|
||||
.mime = "text/javascript; charset=utf-8",
|
||||
};
|
||||
if (std.mem.eql(u8, path, "assets/app.css")) return .{
|
||||
.bytes = app_css,
|
||||
.mime = "text/css; charset=utf-8",
|
||||
};
|
||||
if (std.mem.eql(u8, path, "favicon.svg")) return .{
|
||||
.bytes = favicon_svg,
|
||||
.mime = "image/svg+xml",
|
||||
};
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,815 @@
|
||||
//! Everything the review server asks git for: the repository's identity, the
|
||||
//! refs the base picker offers, the commits a range spans, and the patch itself.
|
||||
//!
|
||||
//! It shells out to the `git` binary rather than linking a library, for the same
|
||||
//! reason the tool this was ported from did: the output of `git diff` is the
|
||||
//! thing the UI renders, so producing it any other way would mean rendering a
|
||||
//! patch git did not write, and every line number in every comment is anchored
|
||||
//! to those exact bytes.
|
||||
//!
|
||||
//! Every function takes an allocator and returns memory owned by it. The server
|
||||
//! hands each request an arena, so nothing here frees anything: the whole
|
||||
//! request's worth of git output goes away in one drop when the response has
|
||||
//! been written.
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
const model = @import("model.zig");
|
||||
|
||||
/// How long a single git invocation may run before it is killed.
|
||||
///
|
||||
/// Generous, because a cold-cache `git diff` against a release branch on a large
|
||||
/// repository genuinely takes seconds. It exists so a repository in a strange
|
||||
/// state — an interrupted rebase holding a lock, a network filesystem gone
|
||||
/// away — costs one failed request rather than a connection thread parked
|
||||
/// forever.
|
||||
const timeout_s = 60;
|
||||
|
||||
/// Cap on what one git invocation may print. A patch is the big one: the
|
||||
/// oversize guard below is what normally keeps it in hand, and this is the
|
||||
/// backstop for the cases the guard cannot see coming.
|
||||
const max_output = 256 * 1024 * 1024;
|
||||
|
||||
pub const Error = error{
|
||||
NotARepository,
|
||||
GitFailed,
|
||||
BadCommit,
|
||||
OutOfMemory,
|
||||
};
|
||||
|
||||
/// A handle on one work tree.
|
||||
pub const Repo = struct {
|
||||
/// Absolute path to the work-tree root.
|
||||
path: []const u8,
|
||||
|
||||
/// Absolute path to the git directory, which for a worktree is the
|
||||
/// per-worktree one — so a review's comments live with the worktree they
|
||||
/// were written about rather than in the shared repository.
|
||||
git_dir: []const u8,
|
||||
};
|
||||
|
||||
/// Resolve `path` to the work tree containing it.
|
||||
///
|
||||
/// Any directory inside the repository works, which is what lets an agent pass
|
||||
/// its `$PWD` and the review pane pass a terminal's current directory without
|
||||
/// either having to know where the root is.
|
||||
///
|
||||
/// Both paths point into `gpa` allocations that are larger than the slices
|
||||
/// themselves — they are git's output with the trailing newline trimmed — so, as
|
||||
/// everywhere else here, they belong to an arena and must not be freed
|
||||
/// individually. `Server.openReview` copies them out of one.
|
||||
pub fn open(gpa: std.mem.Allocator, io: std.Io, path: []const u8) Error!Repo {
|
||||
const top = run(gpa, io, path, &.{ "rev-parse", "--show-toplevel" }) catch
|
||||
return error.NotARepository;
|
||||
const root = trim(top);
|
||||
if (root.len == 0) return error.NotARepository;
|
||||
|
||||
const dir = run(gpa, io, root, &.{ "rev-parse", "--absolute-git-dir" }) catch
|
||||
return error.NotARepository;
|
||||
|
||||
return .{ .path = root, .git_dir = trim(dir) };
|
||||
}
|
||||
|
||||
/// The short name of the checked-out branch, or "HEAD" when detached.
|
||||
pub fn currentBranch(repo: Repo, gpa: std.mem.Allocator, io: std.Io) []const u8 {
|
||||
const out = run(gpa, io, repo.path, &.{ "rev-parse", "--abbrev-ref", "HEAD" }) catch
|
||||
return "HEAD";
|
||||
const branch = trim(out);
|
||||
return if (branch.len == 0) "HEAD" else branch;
|
||||
}
|
||||
|
||||
/// Candidate base refs for the picker: local and remote branches, plus tags.
|
||||
pub fn refs(repo: Repo, gpa: std.mem.Allocator, io: std.Io) []const []const u8 {
|
||||
return lines(gpa, run(gpa, io, repo.path, &.{
|
||||
"for-each-ref",
|
||||
"--format=%(refname:short)",
|
||||
"refs/heads",
|
||||
"refs/remotes",
|
||||
"refs/tags",
|
||||
}) catch return &.{}, "origin/HEAD");
|
||||
}
|
||||
|
||||
/// Local branch names only.
|
||||
pub fn branches(repo: Repo, gpa: std.mem.Allocator, io: std.Io) []const []const u8 {
|
||||
return lines(gpa, run(gpa, io, repo.path, &.{
|
||||
"for-each-ref",
|
||||
"--format=%(refname:short)",
|
||||
"refs/heads",
|
||||
}) catch return &.{}, null);
|
||||
}
|
||||
|
||||
/// Split output into non-empty trimmed lines, dropping `skip` if given.
|
||||
fn lines(gpa: std.mem.Allocator, out: []const u8, skip: ?[]const u8) []const []const u8 {
|
||||
var list: std.ArrayListUnmanaged([]const u8) = .empty;
|
||||
var it = std.mem.splitScalar(u8, out, '\n');
|
||||
while (it.next()) |raw| {
|
||||
const line = trim(raw);
|
||||
if (line.len == 0) continue;
|
||||
if (skip) |s| if (std.mem.eql(u8, line, s)) continue;
|
||||
list.append(gpa, line) catch return list.items;
|
||||
}
|
||||
return list.items;
|
||||
}
|
||||
|
||||
pub fn info(repo: Repo, gpa: std.mem.Allocator, io: std.Io) model.RepoInfo {
|
||||
const branch = currentBranch(repo, gpa, io);
|
||||
const all_refs = refs(repo, gpa, io);
|
||||
return .{
|
||||
.path = repo.path,
|
||||
.branch = branch,
|
||||
.branches = branches(repo, gpa, io),
|
||||
.refs = all_refs,
|
||||
.suggestedBase = suggestedBase(repo.path, branch, all_refs),
|
||||
};
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Picking a base ref
|
||||
|
||||
const Version = struct { major: u32, minor: u32 };
|
||||
|
||||
/// Parse a bare release-branch name like "8.21" or "9.5".
|
||||
fn versionOf(name: []const u8) ?Version {
|
||||
const dot = std.mem.indexOfScalar(u8, name, '.') orelse return null;
|
||||
if (dot == 0 or dot == name.len - 1) return null;
|
||||
const major = std.fmt.parseInt(u32, name[0..dot], 10) catch return null;
|
||||
const minor = std.fmt.parseInt(u32, name[dot + 1 ..], 10) catch return null;
|
||||
return .{ .major = major, .minor = minor };
|
||||
}
|
||||
|
||||
/// The ref with the greatest `x.x` version, comparing major then minor
|
||||
/// numerically.
|
||||
///
|
||||
/// A plain local branch (`8.21`) beats a remote-prefixed one (`origin/8.21`),
|
||||
/// which is why the whole ref name is tried before its last path segment:
|
||||
/// `origin/8.21` only ever wins when there is no local `8.21`. Returns empty
|
||||
/// when no ref looks like a version at all.
|
||||
pub fn highestVersionBranch(all: []const []const u8) []const u8 {
|
||||
var best_plain: []const u8 = "";
|
||||
var best_plain_v: Version = .{ .major = 0, .minor = 0 };
|
||||
var best_remote: []const u8 = "";
|
||||
var best_remote_v: Version = .{ .major = 0, .minor = 0 };
|
||||
|
||||
for (all) |ref| {
|
||||
if (versionOf(ref)) |v| {
|
||||
if (best_plain.len == 0 or v.major > best_plain_v.major or
|
||||
(v.major == best_plain_v.major and v.minor > best_plain_v.minor))
|
||||
{
|
||||
best_plain = ref;
|
||||
best_plain_v = v;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const tail = if (std.mem.lastIndexOfScalar(u8, ref, '/')) |i| ref[i + 1 ..] else ref;
|
||||
if (versionOf(tail)) |v| {
|
||||
if (best_remote.len == 0 or v.major > best_remote_v.major or
|
||||
(v.major == best_remote_v.major and v.minor > best_remote_v.minor))
|
||||
{
|
||||
best_remote = ref;
|
||||
best_remote_v = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
return if (best_plain.len > 0) best_plain else best_remote;
|
||||
}
|
||||
|
||||
/// The ref the picker offers directly under HEAD — the one you most likely want
|
||||
/// when the uncommitted-only diff HEAD gives is not it:
|
||||
///
|
||||
/// - repositories whose path names android: the highest `x.x` release branch,
|
||||
/// matching a release-branch development flow;
|
||||
/// - otherwise `main`.
|
||||
///
|
||||
/// It is a suggestion rather than the default on purpose. A base whose history
|
||||
/// has moved on — a release branch rebased since the work was cut from it — puts
|
||||
/// every commit in that gap into the diff, producing a change set far larger
|
||||
/// than what is actually under review. That is a bad thing to open on unasked,
|
||||
/// so the picker offers it and the user takes it.
|
||||
///
|
||||
/// Empty when there is nothing useful to suggest: no candidate exists, or the
|
||||
/// only one is the branch you are already on, and diffing a ref against itself
|
||||
/// shows nothing.
|
||||
pub fn suggestedBase(path: []const u8, branch: []const u8, all: []const []const u8) []const u8 {
|
||||
if (isAndroidPath(path)) {
|
||||
const v = highestVersionBranch(all);
|
||||
if (v.len > 0 and !std.mem.eql(u8, v, branch)) return v;
|
||||
}
|
||||
if (!std.mem.eql(u8, branch, "main")) {
|
||||
for (all) |ref| if (std.mem.eql(u8, ref, "main")) return "main";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/// Whether a work-tree path names android, the heuristic that selects the
|
||||
/// release-branch flow. A path check because it has to work before anything has
|
||||
/// been read out of the repository.
|
||||
fn isAndroidPath(path: []const u8) bool {
|
||||
return std.ascii.indexOfIgnoreCase(path, "android") != null;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Diffs
|
||||
|
||||
/// The per-request preferences that shape a diff without changing which commits
|
||||
/// it spans.
|
||||
///
|
||||
/// Deliberately separate from `model.DiffContext`: that type is also the tag
|
||||
/// stored on every comment, and a comment written with one of these set is a
|
||||
/// comment about the same diff, not another one.
|
||||
pub const Options = struct {
|
||||
/// Pass `-w`, dropping changes that are only whitespace — including files
|
||||
/// whose every change is whitespace, which leave the change set entirely.
|
||||
/// git omits them from `--numstat` and `--name-status` too, so the file list
|
||||
/// agrees with the patch.
|
||||
ignore_whitespace: bool = false,
|
||||
|
||||
/// Skip the oversize guard, for a caller that has been told how big the
|
||||
/// change set is and wants the patch regardless.
|
||||
force: bool = false,
|
||||
};
|
||||
|
||||
/// What one diff can be before the UI cannot be expected to render it.
|
||||
///
|
||||
/// The browser parses the whole patch, tokenizes every line for highlighting,
|
||||
/// and mounts every hunk at once, so a change set past this locks the page up
|
||||
/// long enough to look like a crash. The usual cause is not a genuinely huge
|
||||
/// review but a base ref whose history has moved on, which pads the diff with
|
||||
/// commits nobody is reviewing — see `suggestedBase`.
|
||||
const max_diff_lines = 20000;
|
||||
const max_diff_files = 400;
|
||||
|
||||
/// Cap on the commit list a diff reports.
|
||||
///
|
||||
/// Listing commits is cheap; summarizing each one's stats is a diff apiece, so
|
||||
/// an unbounded range — a base ref hundreds of releases back — would pay for
|
||||
/// thousands of them on every fetch. The newest are the ones kept, since those
|
||||
/// are the work under review, and the caller is told the list was cut rather
|
||||
/// than left to assume it is whole.
|
||||
const max_commits = 500;
|
||||
|
||||
/// The argument list for a context, with `extra` spliced in before the revisions:
|
||||
///
|
||||
/// - a single commit selected: that commit against its parent, whatever the
|
||||
/// other fields say;
|
||||
/// - uncommitted included: base against the working tree;
|
||||
/// - uncommitted excluded: base against HEAD, so only committed work.
|
||||
fn diffArgs(
|
||||
gpa: std.mem.Allocator,
|
||||
ctx: model.DiffContext,
|
||||
opts: Options,
|
||||
extra: []const []const u8,
|
||||
) Error![]const []const u8 {
|
||||
const base = if (ctx.base.len == 0) "HEAD" else ctx.base;
|
||||
|
||||
var args: std.ArrayListUnmanaged([]const u8) = .empty;
|
||||
if (ctx.commit.len > 0) {
|
||||
// One commit on its own is `show`, not `diff`, because it covers the two
|
||||
// shapes `diff <sha>^ <sha>` cannot be told to handle: a root commit,
|
||||
// which has no parent to name, and a merge, which `-m --first-parent`
|
||||
// renders as the change it brought onto the branch rather than as
|
||||
// nothing at all. `--format=` drops the commit header, leaving the patch.
|
||||
try args.appendSlice(gpa, &.{ "show", "--format=", "-m", "--first-parent" });
|
||||
} else {
|
||||
try args.append(gpa, "diff");
|
||||
}
|
||||
if (opts.ignore_whitespace) try args.append(gpa, "-w");
|
||||
try args.appendSlice(gpa, extra);
|
||||
|
||||
if (ctx.commit.len > 0) {
|
||||
try args.append(gpa, ctx.commit);
|
||||
} else if (ctx.uncommitted) {
|
||||
try args.append(gpa, base);
|
||||
} else {
|
||||
try args.appendSlice(gpa, &.{ base, "HEAD" });
|
||||
}
|
||||
return args.items;
|
||||
}
|
||||
|
||||
/// Object names a client may select: a hex sha, abbreviated or full.
|
||||
///
|
||||
/// Anything else is refused rather than handed to git, where a value beginning
|
||||
/// with `-` would be read as a flag.
|
||||
fn validateCommit(sha: []const u8) Error!void {
|
||||
if (sha.len == 0) return;
|
||||
if (sha.len < 4 or sha.len > 64) return error.BadCommit;
|
||||
for (sha) |c| if (!std.ascii.isHex(c)) return error.BadCommit;
|
||||
}
|
||||
|
||||
/// The patch plus the per-file summary and the commit list, for one selection.
|
||||
///
|
||||
/// The summary is gathered first, and when it says the change set is past what
|
||||
/// the UI can render the patch is left out and `oversized` is set, so the caller
|
||||
/// can say how big the thing is and ask before loading it. `opts.force` skips
|
||||
/// the check. Either way the summary comes back, which is what the size question
|
||||
/// gets answered from.
|
||||
pub fn diff(
|
||||
repo: Repo,
|
||||
gpa: std.mem.Allocator,
|
||||
io: std.Io,
|
||||
ctx: model.DiffContext,
|
||||
opts: Options,
|
||||
) Error!model.DiffPayload {
|
||||
try validateCommit(ctx.commit);
|
||||
|
||||
const files = try diffFiles(repo, gpa, io, ctx, opts);
|
||||
|
||||
// Best-effort: the commit list is a way to navigate the diff, not part of
|
||||
// it, and a base ref `git diff` accepts but `git log` will not walk — a bare
|
||||
// tree, say — should not cost the user the patch they asked for.
|
||||
const listed = commits(repo, gpa, io, ctx) catch
|
||||
Commits{ .items = &.{}, .more = false };
|
||||
|
||||
if (!opts.force and oversized(files)) return .{
|
||||
.context = ctx,
|
||||
.patch = "",
|
||||
.files = files,
|
||||
.commits = listed.items,
|
||||
.moreCommits = listed.more,
|
||||
.oversized = true,
|
||||
};
|
||||
|
||||
const patch = try run(gpa, io, repo.path, try diffArgs(gpa, ctx, opts, &.{
|
||||
"--no-color",
|
||||
"--find-renames",
|
||||
}));
|
||||
|
||||
return .{
|
||||
.context = ctx,
|
||||
.patch = patch,
|
||||
.files = files,
|
||||
.commits = listed.items,
|
||||
.moreCommits = listed.more,
|
||||
};
|
||||
}
|
||||
|
||||
/// Whether a change set is past what the UI can render at once. Binary files
|
||||
/// count for no lines, hence the file cap alongside the line one.
|
||||
fn oversized(files: []const model.DiffFile) bool {
|
||||
if (files.len > max_diff_files) return true;
|
||||
var total: u64 = 0;
|
||||
for (files) |f| total += f.additions + f.deletions;
|
||||
return total > max_diff_lines;
|
||||
}
|
||||
|
||||
/// The full contents of a file at a ref, for expanding collapsed context between
|
||||
/// hunks. An empty ref means HEAD.
|
||||
pub fn fileAt(
|
||||
repo: Repo,
|
||||
gpa: std.mem.Allocator,
|
||||
io: std.Io,
|
||||
ref: []const u8,
|
||||
path: []const u8,
|
||||
) Error![]const u8 {
|
||||
const spec = try std.fmt.allocPrint(gpa, "{s}:{s}", .{
|
||||
if (ref.len == 0) "HEAD" else ref,
|
||||
path,
|
||||
});
|
||||
return run(gpa, io, repo.path, &.{ "show", spec });
|
||||
}
|
||||
|
||||
/// Per-file status and add/delete counts, from `--numstat` keyed by new path
|
||||
/// with `--name-status` supplying the status word.
|
||||
fn diffFiles(
|
||||
repo: Repo,
|
||||
gpa: std.mem.Allocator,
|
||||
io: std.Io,
|
||||
ctx: model.DiffContext,
|
||||
opts: Options,
|
||||
) Error![]const model.DiffFile {
|
||||
const numstat = try run(gpa, io, repo.path, try diffArgs(gpa, ctx, opts, &.{
|
||||
"--numstat",
|
||||
"--find-renames",
|
||||
}));
|
||||
const name_status = try run(gpa, io, repo.path, try diffArgs(gpa, ctx, opts, &.{
|
||||
"--name-status",
|
||||
"--find-renames",
|
||||
}));
|
||||
|
||||
var status_by_path: std.StringHashMapUnmanaged([]const u8) = .empty;
|
||||
try parseNameStatus(gpa, name_status, &status_by_path);
|
||||
|
||||
var out: std.ArrayListUnmanaged(model.DiffFile) = .empty;
|
||||
var it = std.mem.splitScalar(u8, numstat, '\n');
|
||||
while (it.next()) |line| {
|
||||
if (line.len == 0) continue;
|
||||
const first = std.mem.indexOfScalar(u8, line, '\t') orelse continue;
|
||||
const second = std.mem.indexOfScalarPos(u8, line, first + 1, '\t') orelse continue;
|
||||
|
||||
// "-" for a binary file, which parses as zero — the file cap above is
|
||||
// what keeps those from slipping past the oversize guard.
|
||||
const adds = std.fmt.parseInt(u32, line[0..first], 10) catch 0;
|
||||
const dels = std.fmt.parseInt(u32, line[first + 1 .. second], 10) catch 0;
|
||||
|
||||
const paths = try parsePathField(gpa, line[second + 1 ..]);
|
||||
try out.append(gpa, .{
|
||||
.oldPath = paths.old,
|
||||
.newPath = paths.new,
|
||||
.status = status_by_path.get(paths.new) orelse "modified",
|
||||
.additions = adds,
|
||||
.deletions = dels,
|
||||
});
|
||||
}
|
||||
return out.items;
|
||||
}
|
||||
|
||||
const Paths = struct { old: []const u8, new: []const u8 };
|
||||
|
||||
/// numstat's path field, which spells a rename either as `old => new` or in the
|
||||
/// brace form `dir/{a => b}/file`. For a plain path both halves are the same.
|
||||
fn parsePathField(gpa: std.mem.Allocator, raw: []const u8) Error!Paths {
|
||||
const field = trim(raw);
|
||||
if (std.mem.indexOf(u8, field, "=>") == null) return .{ .old = field, .new = field };
|
||||
|
||||
if (std.mem.indexOfScalar(u8, field, '{')) |open_brace| {
|
||||
const rest = field[open_brace + 1 ..];
|
||||
if (std.mem.indexOfScalar(u8, rest, '}')) |close_brace| {
|
||||
const inner = rest[0..close_brace];
|
||||
const suffix = rest[close_brace + 1 ..];
|
||||
if (std.mem.indexOf(u8, inner, "=>")) |arrow| {
|
||||
const prefix = field[0..open_brace];
|
||||
return .{
|
||||
.old = try join(gpa, prefix, trim(inner[0..arrow]), suffix),
|
||||
.new = try join(gpa, prefix, trim(inner[arrow + 2 ..]), suffix),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
if (std.mem.indexOf(u8, field, "=>")) |arrow| return .{
|
||||
.old = trim(field[0..arrow]),
|
||||
.new = trim(field[arrow + 2 ..]),
|
||||
};
|
||||
return .{ .old = field, .new = field };
|
||||
}
|
||||
|
||||
/// Splice the three pieces of a brace rename together, collapsing the doubled
|
||||
/// separators an empty middle leaves behind (`dir/{ => sub}/f` gives `dir//f`).
|
||||
fn join(gpa: std.mem.Allocator, prefix: []const u8, middle: []const u8, suffix: []const u8) Error![]const u8 {
|
||||
const raw = try std.fmt.allocPrint(gpa, "{s}{s}{s}", .{ prefix, middle, suffix });
|
||||
if (std.mem.indexOf(u8, raw, "//") == null) return raw;
|
||||
|
||||
var out: std.ArrayListUnmanaged(u8) = .empty;
|
||||
try out.ensureTotalCapacity(gpa, raw.len);
|
||||
for (raw, 0..) |c, i| {
|
||||
if (c == '/' and i + 1 < raw.len and raw[i + 1] == '/') continue;
|
||||
out.appendAssumeCapacity(c);
|
||||
}
|
||||
return out.items;
|
||||
}
|
||||
|
||||
/// Map each path to a human status word.
|
||||
fn parseNameStatus(
|
||||
gpa: std.mem.Allocator,
|
||||
out: []const u8,
|
||||
into: *std.StringHashMapUnmanaged([]const u8),
|
||||
) Error!void {
|
||||
var it = std.mem.splitScalar(u8, out, '\n');
|
||||
while (it.next()) |line| {
|
||||
if (line.len == 0) continue;
|
||||
var fields = std.mem.splitScalar(u8, line, '\t');
|
||||
const code = fields.next() orelse continue;
|
||||
if (code.len == 0) continue;
|
||||
|
||||
// A rename or copy names both paths; the new one is what the file list
|
||||
// is keyed by, so skip past the old.
|
||||
const first = fields.next() orelse continue;
|
||||
const path = if (code[0] == 'R' or code[0] == 'C')
|
||||
(fields.next() orelse continue)
|
||||
else
|
||||
first;
|
||||
|
||||
try into.put(gpa, path, switch (code[0]) {
|
||||
'A' => "added",
|
||||
'D' => "deleted",
|
||||
'R' => "renamed",
|
||||
'C' => "copied",
|
||||
else => "modified",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Commits
|
||||
|
||||
pub const Commits = struct {
|
||||
items: []const model.Commit,
|
||||
/// The range holds more than `max_commits`; the newest were kept.
|
||||
more: bool,
|
||||
};
|
||||
|
||||
/// One record per commit: a NUL to split records on, then unit-separated
|
||||
/// fields. Both are bytes git will not put in a subject or an author name, so no
|
||||
/// field can spell the end of its own record.
|
||||
const commit_format = "--format=%x00%H%x1f%h%x1f%an%x1f%aI%x1f%s";
|
||||
|
||||
/// The commits a context spans — reachable from HEAD but not from the base ref —
|
||||
/// oldest first, the order they were written in.
|
||||
///
|
||||
/// `ctx.commit` is ignored on purpose: narrowing the view to one commit should
|
||||
/// not shrink the list it was picked out of, or there would be no way back to a
|
||||
/// sibling. A base of HEAD, which a review opens on, spans no commits at all —
|
||||
/// that diff is the uncommitted work — and comes back empty.
|
||||
pub fn commits(
|
||||
repo: Repo,
|
||||
gpa: std.mem.Allocator,
|
||||
io: std.Io,
|
||||
ctx: model.DiffContext,
|
||||
) Error!Commits {
|
||||
if (ctx.base.len == 0 or std.mem.eql(u8, ctx.base, "HEAD")) {
|
||||
return .{ .items = &.{}, .more = false };
|
||||
}
|
||||
|
||||
// One past the cap, so hitting it is distinguishable from filling it
|
||||
// exactly.
|
||||
const limit = try std.fmt.allocPrint(gpa, "--max-count={d}", .{max_commits + 1});
|
||||
const range = try std.fmt.allocPrint(gpa, "{s}..HEAD", .{ctx.base});
|
||||
const out = try run(gpa, io, repo.path, &.{
|
||||
"log", limit, commit_format, "--shortstat", range,
|
||||
});
|
||||
|
||||
// git walks newest first; reversed at the end so the cap drops the oldest
|
||||
// commits rather than the ones the branch is being judged on.
|
||||
var list: std.ArrayListUnmanaged(model.Commit) = .empty;
|
||||
var more = false;
|
||||
var records = std.mem.splitScalar(u8, out, 0);
|
||||
while (records.next()) |record| {
|
||||
const commit = parseCommit(record) orelse continue;
|
||||
if (list.items.len == max_commits) {
|
||||
more = true;
|
||||
break;
|
||||
}
|
||||
try list.append(gpa, commit);
|
||||
}
|
||||
std.mem.reverse(model.Commit, list.items);
|
||||
return .{ .items = list.items, .more = more };
|
||||
}
|
||||
|
||||
/// One `commit_format` record: the field line, then the shortstat summary git
|
||||
/// appends under it — absent for a merge, whose diff it does not summarize.
|
||||
fn parseCommit(record: []const u8) ?model.Commit {
|
||||
const newline = std.mem.indexOfScalar(u8, record, '\n') orelse record.len;
|
||||
var fields = std.mem.splitScalar(u8, record[0..newline], '\x1f');
|
||||
|
||||
const sha = fields.next() orelse return null;
|
||||
const short = fields.next() orelse return null;
|
||||
const author = fields.next() orelse return null;
|
||||
const date = fields.next() orelse return null;
|
||||
const subject = fields.next() orelse return null;
|
||||
if (sha.len == 0) return null;
|
||||
|
||||
var commit: model.Commit = .{
|
||||
.sha = sha,
|
||||
.shortSha = short,
|
||||
.author = author,
|
||||
.date = date,
|
||||
.subject = subject,
|
||||
};
|
||||
if (newline < record.len) parseShortstat(record[newline..], &commit);
|
||||
return commit;
|
||||
}
|
||||
|
||||
/// Pull the counts out of `git log --shortstat`'s summary line:
|
||||
///
|
||||
/// 3 files changed, 12 insertions(+), 4 deletions(-)
|
||||
///
|
||||
/// Each clause is absent when its count is zero, so this reads by keyword rather
|
||||
/// than by position.
|
||||
fn parseShortstat(tail: []const u8, into: *model.Commit) void {
|
||||
var it = std.mem.tokenizeAny(u8, tail, " ,\n\t");
|
||||
var previous: ?[]const u8 = null;
|
||||
while (it.next()) |word| {
|
||||
defer previous = word;
|
||||
const number = previous orelse continue;
|
||||
const count = std.fmt.parseInt(u32, number, 10) catch continue;
|
||||
|
||||
if (std.mem.startsWith(u8, word, "file")) {
|
||||
into.files = count;
|
||||
} else if (std.mem.startsWith(u8, word, "insertion")) {
|
||||
into.additions = count;
|
||||
} else if (std.mem.startsWith(u8, word, "deletion")) {
|
||||
into.deletions = count;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Running git
|
||||
|
||||
fn trim(s: []const u8) []const u8 {
|
||||
return std.mem.trim(u8, s, " \t\r\n");
|
||||
}
|
||||
|
||||
/// Run git in `dir` and return its stdout, owned by `gpa`.
|
||||
///
|
||||
/// A non-zero exit is a failure even when something was printed: git writes
|
||||
/// partial output before giving up on a bad revision, and treating that as a
|
||||
/// diff would render a patch that is not the one asked for.
|
||||
fn run(
|
||||
gpa: std.mem.Allocator,
|
||||
io: std.Io,
|
||||
dir: []const u8,
|
||||
args: []const []const u8,
|
||||
) Error![]const u8 {
|
||||
var argv: std.ArrayListUnmanaged([]const u8) = .empty;
|
||||
try argv.ensureTotalCapacity(gpa, args.len + 1);
|
||||
argv.appendAssumeCapacity("git");
|
||||
argv.appendSliceAssumeCapacity(args);
|
||||
|
||||
const result = std.process.run(gpa, io, .{
|
||||
.argv = argv.items,
|
||||
.cwd = .{ .path = dir },
|
||||
.stdout_limit = .limited(max_output),
|
||||
.stderr_limit = .limited(64 * 1024),
|
||||
.timeout = .{ .duration = .{ .raw = .fromSeconds(timeout_s), .clock = .awake } },
|
||||
}) catch |err| {
|
||||
std.log.warn("review: git {s}: {s}", .{ args[0], @errorName(err) });
|
||||
return error.GitFailed;
|
||||
};
|
||||
|
||||
switch (result.term) {
|
||||
.exited => |code| if (code != 0) {
|
||||
std.log.warn("review: git {s} exited {d}: {s}", .{
|
||||
args[0], code, trim(result.stderr),
|
||||
});
|
||||
return error.GitFailed;
|
||||
},
|
||||
else => {
|
||||
std.log.warn("review: git {s} died: {s}", .{ args[0], trim(result.stderr) });
|
||||
return error.GitFailed;
|
||||
},
|
||||
}
|
||||
return result.stdout;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Tests
|
||||
//
|
||||
// The pure half of this file — picking a base ref, and reading git's own output
|
||||
// formats — is what these cover. Anything that runs git wants a repository in a
|
||||
// known state, which is a different kind of test than this project has.
|
||||
|
||||
test "highestVersionBranch compares numerically, not lexically" {
|
||||
try std.testing.expectEqualStrings("8.21", highestVersionBranch(&.{ "8.9", "8.21", "8.2" }));
|
||||
try std.testing.expectEqualStrings("9.5", highestVersionBranch(&.{ "8.99", "9.5" }));
|
||||
try std.testing.expectEqualStrings("", highestVersionBranch(&.{ "main", "feature/x" }));
|
||||
}
|
||||
|
||||
test "highestVersionBranch prefers a local branch over a remote one" {
|
||||
try std.testing.expectEqualStrings(
|
||||
"8.21",
|
||||
highestVersionBranch(&.{ "origin/8.21", "8.21" }),
|
||||
);
|
||||
try std.testing.expectEqualStrings(
|
||||
"origin/8.21",
|
||||
highestVersionBranch(&.{ "origin/8.21", "main" }),
|
||||
);
|
||||
}
|
||||
|
||||
test "suggestedBase declines the branch you are already on" {
|
||||
try std.testing.expectEqualStrings("", suggestedBase("/w/proj", "main", &.{"main"}));
|
||||
try std.testing.expectEqualStrings("main", suggestedBase("/w/proj", "feature", &.{"main"}));
|
||||
// Sitting on the release branch: the version candidate is declined for
|
||||
// being the branch itself, and `main` is what is left to offer.
|
||||
try std.testing.expectEqualStrings(
|
||||
"main",
|
||||
suggestedBase("/w/Signal-Android", "9.5", &.{ "9.5", "main" }),
|
||||
);
|
||||
try std.testing.expectEqualStrings(
|
||||
"",
|
||||
suggestedBase("/w/Signal-Android", "9.5", &.{"9.5"}),
|
||||
);
|
||||
try std.testing.expectEqualStrings(
|
||||
"9.5",
|
||||
suggestedBase("/w/Signal-Android", "feature", &.{ "9.5", "main" }),
|
||||
);
|
||||
}
|
||||
|
||||
test "parsePathField reads both rename spellings" {
|
||||
const gpa = std.testing.allocator;
|
||||
var arena: std.heap.ArenaAllocator = .init(gpa);
|
||||
defer arena.deinit();
|
||||
const a = arena.allocator();
|
||||
|
||||
const plain = try parsePathField(a, "src/main.zig");
|
||||
try std.testing.expectEqualStrings("src/main.zig", plain.old);
|
||||
try std.testing.expectEqualStrings("src/main.zig", plain.new);
|
||||
|
||||
const arrow = try parsePathField(a, "old.zig => new.zig");
|
||||
try std.testing.expectEqualStrings("old.zig", arrow.old);
|
||||
try std.testing.expectEqualStrings("new.zig", arrow.new);
|
||||
|
||||
const brace = try parsePathField(a, "src/{a => b}/file.zig");
|
||||
try std.testing.expectEqualStrings("src/a/file.zig", brace.old);
|
||||
try std.testing.expectEqualStrings("src/b/file.zig", brace.new);
|
||||
|
||||
// An empty half of the brace form would leave a doubled separator behind.
|
||||
const moved = try parsePathField(a, "src/{ => sub}/file.zig");
|
||||
try std.testing.expectEqualStrings("src/file.zig", moved.old);
|
||||
try std.testing.expectEqualStrings("src/sub/file.zig", moved.new);
|
||||
}
|
||||
|
||||
test "parseNameStatus keys renames by the new path" {
|
||||
const gpa = std.testing.allocator;
|
||||
var arena: std.heap.ArenaAllocator = .init(gpa);
|
||||
defer arena.deinit();
|
||||
const a = arena.allocator();
|
||||
|
||||
var map: std.StringHashMapUnmanaged([]const u8) = .empty;
|
||||
try parseNameStatus(a, "A\tadded.zig\n" ++
|
||||
"D\tgone.zig\n" ++
|
||||
"M\ttouched.zig\n" ++
|
||||
"R096\told.zig\tnew.zig\n", &map);
|
||||
|
||||
try std.testing.expectEqualStrings("added", map.get("added.zig").?);
|
||||
try std.testing.expectEqualStrings("deleted", map.get("gone.zig").?);
|
||||
try std.testing.expectEqualStrings("modified", map.get("touched.zig").?);
|
||||
try std.testing.expectEqualStrings("renamed", map.get("new.zig").?);
|
||||
try std.testing.expect(map.get("old.zig") == null);
|
||||
}
|
||||
|
||||
test "parseCommit reads a record with and without a shortstat" {
|
||||
const with = parseCommit(
|
||||
"abc123\x1fabc\x1fA Dev\x1f2026-01-02T03:04:05Z\x1fFix the thing\n" ++
|
||||
" 3 files changed, 12 insertions(+), 4 deletions(-)\n",
|
||||
).?;
|
||||
try std.testing.expectEqualStrings("abc123", with.sha);
|
||||
try std.testing.expectEqualStrings("Fix the thing", with.subject);
|
||||
try std.testing.expectEqual(@as(u32, 3), with.files);
|
||||
try std.testing.expectEqual(@as(u32, 12), with.additions);
|
||||
try std.testing.expectEqual(@as(u32, 4), with.deletions);
|
||||
|
||||
// A merge: git prints no summary line, so the counts stay zero.
|
||||
const merge = parseCommit("def\x1fdef\x1fA Dev\x1f2026-01-02T03:04:05Z\x1fMerge").?;
|
||||
try std.testing.expectEqual(@as(u32, 0), merge.files);
|
||||
|
||||
try std.testing.expect(parseCommit("") == null);
|
||||
try std.testing.expect(parseCommit("only\x1ftwo") == null);
|
||||
}
|
||||
|
||||
test "parseShortstat handles an absent clause" {
|
||||
var commit: model.Commit = .{
|
||||
.sha = "",
|
||||
.shortSha = "",
|
||||
.author = "",
|
||||
.date = "",
|
||||
.subject = "",
|
||||
};
|
||||
parseShortstat(" 1 file changed, 5 insertions(+)\n", &commit);
|
||||
try std.testing.expectEqual(@as(u32, 1), commit.files);
|
||||
try std.testing.expectEqual(@as(u32, 5), commit.additions);
|
||||
try std.testing.expectEqual(@as(u32, 0), commit.deletions);
|
||||
}
|
||||
|
||||
test "validateCommit refuses anything that is not a sha" {
|
||||
try validateCommit("");
|
||||
try validateCommit("abc1");
|
||||
try validateCommit("0123456789abcdef");
|
||||
try std.testing.expectError(error.BadCommit, validateCommit("--upload-pack=evil"));
|
||||
try std.testing.expectError(error.BadCommit, validateCommit("main"));
|
||||
try std.testing.expectError(error.BadCommit, validateCommit("abc"));
|
||||
}
|
||||
|
||||
test "diffArgs picks the right git subcommand for each selection" {
|
||||
const gpa = std.testing.allocator;
|
||||
var arena: std.heap.ArenaAllocator = .init(gpa);
|
||||
defer arena.deinit();
|
||||
const a = arena.allocator();
|
||||
|
||||
// Uncommitted work against a base: base alone, so git compares the work tree.
|
||||
const dirty = try diffArgs(a, .{ .base = "main", .uncommitted = true }, .{}, &.{"--numstat"});
|
||||
try std.testing.expectEqualDeep(@as([]const []const u8, &.{
|
||||
"diff", "--numstat", "main",
|
||||
}), dirty);
|
||||
|
||||
// Committed only: base against HEAD.
|
||||
const clean = try diffArgs(a, .{ .base = "main" }, .{}, &.{});
|
||||
try std.testing.expectEqualDeep(@as([]const []const u8, &.{
|
||||
"diff", "main", "HEAD",
|
||||
}), clean);
|
||||
|
||||
// A single commit is `show`, and the uncommitted toggle stops applying.
|
||||
const one = try diffArgs(
|
||||
a,
|
||||
.{ .base = "main", .uncommitted = true, .commit = "abc123" },
|
||||
.{ .ignore_whitespace = true },
|
||||
&.{},
|
||||
);
|
||||
try std.testing.expectEqualDeep(@as([]const []const u8, &.{
|
||||
"show", "--format=", "-m", "--first-parent", "-w", "abc123",
|
||||
}), one);
|
||||
|
||||
// An empty base is HEAD, which is what a review opens on.
|
||||
const head = try diffArgs(a, .{ .uncommitted = true }, .{}, &.{});
|
||||
try std.testing.expectEqualDeep(@as([]const []const u8, &.{ "diff", "HEAD" }), head);
|
||||
}
|
||||
|
||||
test "oversized counts lines, and files for the binary case" {
|
||||
const small: []const model.DiffFile = &.{
|
||||
.{ .oldPath = "a", .newPath = "a", .status = "modified", .additions = 10, .deletions = 5 },
|
||||
};
|
||||
try std.testing.expect(!oversized(small));
|
||||
|
||||
const huge: []const model.DiffFile = &.{
|
||||
.{ .oldPath = "a", .newPath = "a", .status = "modified", .additions = 20001, .deletions = 0 },
|
||||
};
|
||||
try std.testing.expect(oversized(huge));
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
//! The shapes the review API speaks: comments, replies, the diff payload, and
|
||||
//! the diff selection everything is scoped to.
|
||||
//!
|
||||
//! Field names here *are* the wire format — the web UI's `types.ts` reads them
|
||||
//! verbatim, and `std.json` derives both directions from the declarations — so
|
||||
//! they are camelCase rather than Zig's usual snake_case. Renaming one is a
|
||||
//! protocol change, not a refactor.
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
/// Which side of a hunk a comment is anchored to.
|
||||
///
|
||||
/// A string rather than an enum because a file- or review-level comment has no
|
||||
/// side at all, and the wire format spells that as `""`. An optional enum would
|
||||
/// serialize it as `null`, which the UI's anchoring code reads as a side it
|
||||
/// simply does not know.
|
||||
pub const side_old = "old";
|
||||
pub const side_new = "new";
|
||||
|
||||
pub const Author = enum { user, claude };
|
||||
|
||||
/// What a comment is anchored to.
|
||||
pub const Level = enum {
|
||||
/// A line, or an inclusive range of them, in one file.
|
||||
line,
|
||||
/// A whole file, no line.
|
||||
file,
|
||||
/// The change set as a whole, no file.
|
||||
review,
|
||||
};
|
||||
|
||||
pub const Status = enum {
|
||||
/// Still being composed. Invisible to an agent — see `Store.pending`.
|
||||
draft,
|
||||
/// Submitted, and so actionable.
|
||||
submitted,
|
||||
/// Dealt with and closed.
|
||||
resolved,
|
||||
};
|
||||
|
||||
/// The selection that produced a diff.
|
||||
///
|
||||
/// Used both as the query for fetching one and as the tag stored on every
|
||||
/// comment, which is what keeps comments written against one base ref from
|
||||
/// landing on another's line numbers.
|
||||
pub const DiffContext = struct {
|
||||
base: []const u8 = "",
|
||||
uncommitted: bool = false,
|
||||
|
||||
/// Narrows the view to a single commit out of the range `base` spans. The
|
||||
/// diff is then that commit alone and `uncommitted` no longer applies.
|
||||
///
|
||||
/// It belongs in the context rather than beside it because a line number
|
||||
/// only means something inside one revision: line 40 of a file as one
|
||||
/// commit left it is not line 40 at the tip of the branch.
|
||||
commit: []const u8 = "",
|
||||
|
||||
pub fn eql(a: DiffContext, b: DiffContext) bool {
|
||||
return a.uncommitted == b.uncommitted and
|
||||
std.mem.eql(u8, a.base, b.base) and
|
||||
std.mem.eql(u8, a.commit, b.commit);
|
||||
}
|
||||
};
|
||||
|
||||
pub const Reply = struct {
|
||||
id: []const u8,
|
||||
author: Author,
|
||||
body: []const u8,
|
||||
createdAt: []const u8,
|
||||
};
|
||||
|
||||
/// A comment thread. The anchor depends on `level`:
|
||||
///
|
||||
/// - `.line` — `file` + `side` + `line`..`endLine`, inclusive.
|
||||
/// - `.file` — `file` alone.
|
||||
/// - `.review` — nothing.
|
||||
pub const Comment = struct {
|
||||
id: []const u8,
|
||||
level: Level,
|
||||
file: []const u8,
|
||||
side: []const u8,
|
||||
line: u32,
|
||||
endLine: u32,
|
||||
body: []const u8,
|
||||
author: Author,
|
||||
status: Status,
|
||||
replies: []const Reply,
|
||||
context: DiffContext,
|
||||
createdAt: []const u8,
|
||||
updatedAt: []const u8,
|
||||
};
|
||||
|
||||
/// One commit in the range a diff spans — an entry in the list the UI offers so
|
||||
/// a large change set can be read a commit at a time.
|
||||
pub const Commit = struct {
|
||||
sha: []const u8,
|
||||
shortSha: []const u8,
|
||||
author: []const u8,
|
||||
date: []const u8,
|
||||
subject: []const u8,
|
||||
|
||||
/// What the commit changed on its own. Zero for a merge, whose diff
|
||||
/// `git log --shortstat` does not summarize.
|
||||
files: u32 = 0,
|
||||
additions: u32 = 0,
|
||||
deletions: u32 = 0,
|
||||
};
|
||||
|
||||
/// Summary metadata for one changed file.
|
||||
pub const DiffFile = struct {
|
||||
oldPath: []const u8,
|
||||
newPath: []const u8,
|
||||
/// "added" | "deleted" | "modified" | "renamed" | "copied"
|
||||
status: []const u8,
|
||||
additions: u32,
|
||||
deletions: u32,
|
||||
};
|
||||
|
||||
/// What `GET api/diff` returns.
|
||||
pub const DiffPayload = struct {
|
||||
context: DiffContext,
|
||||
patch: []const u8,
|
||||
files: []const DiffFile,
|
||||
|
||||
/// The commits the change set is made of, oldest first — the range
|
||||
/// `context.base` spans, whether or not `context.commit` narrows the patch
|
||||
/// to one of them. It rides along with the diff so the list and the patch
|
||||
/// can never describe different change sets, and it is filled in even when
|
||||
/// the patch is withheld for being oversized: picking one commit out of the
|
||||
/// range is the quickest way to get something readable on screen.
|
||||
commits: []const Commit,
|
||||
|
||||
/// The range holds more commits than `commits` lists. The newest are kept.
|
||||
moreCommits: bool = false,
|
||||
|
||||
/// The change set is past what the UI can render, so `patch` was withheld.
|
||||
/// `files` is still filled in, so the caller can say how big it is and
|
||||
/// offer to load it anyway with `force`.
|
||||
oversized: bool = false,
|
||||
};
|
||||
|
||||
/// What `GET api/repo` returns for a tab that has a review open.
|
||||
pub const RepoInfo = struct {
|
||||
path: []const u8,
|
||||
branch: []const u8,
|
||||
branches: []const []const u8,
|
||||
refs: []const []const u8,
|
||||
|
||||
/// The ref the base picker offers directly under HEAD. Decided server-side
|
||||
/// because the rule depends on the repository — see `git.suggestedBase`.
|
||||
/// Empty when there is nothing worth suggesting.
|
||||
suggestedBase: []const u8,
|
||||
};
|
||||
|
||||
/// One entry in `GET /api/tabs`: which tab is reviewing what, and how much is
|
||||
/// waiting on someone. This is the discovery endpoint an agent uses when it has
|
||||
/// no `PLAYPEN_REVIEW_URL` to go on.
|
||||
pub const TabState = struct {
|
||||
id: []const u8,
|
||||
open: bool,
|
||||
path: []const u8,
|
||||
branch: []const u8,
|
||||
drafts: u32,
|
||||
openComments: u32,
|
||||
context: ?DiffContext,
|
||||
};
|
||||
@@ -108,6 +108,7 @@ pub const Action = enum {
|
||||
close_pane,
|
||||
new_terminal,
|
||||
new_web,
|
||||
new_review,
|
||||
rename_tab,
|
||||
toggle_zoom,
|
||||
toggle_sidebar,
|
||||
@@ -156,6 +157,9 @@ pub const defaults: []const Binding = &.{
|
||||
.{ .chord = chord("ctrl+shift+w"), .action = .close_pane },
|
||||
.{ .chord = chord("ctrl+shift+e"), .action = .new_terminal },
|
||||
.{ .chord = chord("ctrl+shift+b"), .action = .new_web },
|
||||
// `d` for diff. `r` would read better but it is `rename_tab`'s, and that is
|
||||
// in people's fingers.
|
||||
.{ .chord = chord("ctrl+shift+d"), .action = .new_review },
|
||||
.{ .chord = chord("ctrl+shift+r"), .action = .rename_tab },
|
||||
.{ .chord = chord("ctrl+shift+z"), .action = .toggle_zoom },
|
||||
.{ .chord = chord("ctrl+shift+f"), .action = .toggle_zoom },
|
||||
|
||||
+7
-1
@@ -88,11 +88,17 @@ pub const WebView = opaque {
|
||||
/// they know about, so WebKit's own signals go through the untyped
|
||||
/// GObject entry point. Property changes don't need this: `notify` is
|
||||
/// declared on `gobject.Object`, which this can be cast to.
|
||||
///
|
||||
/// The handler is taken as it comes, for the same reason the find
|
||||
/// controller's is: these signals differ in shape — `close` carries
|
||||
/// nothing, `load-changed` carries a load event, `load-failed` carries
|
||||
/// three arguments and returns whether it handled the failure — so
|
||||
/// matching the handler to the signal is the caller's job.
|
||||
pub fn connectSignal(
|
||||
self: *WebView,
|
||||
comptime signal: [:0]const u8,
|
||||
comptime Data: type,
|
||||
handler: *const fn (*WebView, Data) callconv(.c) void,
|
||||
handler: anytype,
|
||||
data: Data,
|
||||
) void {
|
||||
_ = gobject.signalConnectData(
|
||||
|
||||
Reference in New Issue
Block a user