Add a button to open in android studio.

This commit is contained in:
Greyson Parrelli committed 2026-09-28 16:38:51 -04:00
1 parent 0e8bc70151
commit bb90255002
8 files changed
+298

No files matched your search

+63
View File
@@ -34,6 +34,7 @@ const Store = @import("Store.zig");
const assets = @import("assets.zig");
const git = @import("git.zig");
const model = @import("model.zig");
const studio = @import("studio.zig");
const Server = @This();
@@ -687,6 +688,10 @@ fn handleApi(
if (std.mem.eql(u8, path, "file")) {
return self.handleFile(a, request, repo, query);
}
if (std.mem.eql(u8, path, "studio/open")) {
if (method != .POST) return notFound(request);
return self.handleOpenInStudio(a, request, repo);
}
if (std.mem.eql(u8, path, "comments")) {
return switch (method) {
.GET => writeJson(a, request, .ok, try store.list(a)),
@@ -738,6 +743,10 @@ fn handleRepo(
.drafts = store.countByStatus(.draft),
.openComments = store.countByStatus(.submitted),
.context = tab.ctx,
// Whether the file headers should offer to open a file in Android
// Studio. Asked on every load rather than once, so installing it while
// the app runs is enough for the next reload to pick it up.
.studio = studio.find(a, self.io) != null,
});
}
@@ -918,6 +927,60 @@ fn handleFile(
});
}
/// Open one of the review's files in Android Studio, at a line when given one.
///
/// The path is the diff's, relative to the work-tree root, and has to stay
/// inside it; see `studio.isInside`. A file the diff deletes, or one reviewed in
/// a commit that the work tree has since dropped, is a 404 rather than an IDE
/// opening onto nothing.
fn handleOpenInStudio(
self: *Server,
a: std.mem.Allocator,
request: *std.http.Server.Request,
repo: git.Repo,
) !void {
const raw = try readBody(a, request) orelse return writeError(
a,
request,
.bad_request,
"path is required",
);
const body = std.json.parseFromSliceLeaky(
struct { path: []const u8 = "", line: ?u32 = null },
a,
raw,
.{ .ignore_unknown_fields = true },
) catch return writeError(a, request, .bad_request, "expected {\"path\": \"...\", \"line\": n}");
if (!studio.isInside(body.path)) return writeError(
a,
request,
.bad_request,
"path must be relative to the repository and stay inside it",
);
const file = try std.fs.path.join(a, &.{ repo.path, body.path });
std.Io.Dir.accessAbsolute(self.io, file, .{}) catch return writeError(
a,
request,
.not_found,
"that file isn't in the work tree",
);
const launcher = studio.find(a, self.io) orelse return writeError(
a,
request,
.not_found,
"Android Studio wasn't found — put its studio launcher on PATH",
);
studio.open(a, self.io, launcher, repo.path, file, body.line) catch return writeError(
a,
request,
.internal_server_error,
"Android Studio could not be started",
);
return request.respond("", .{ .status = .no_content });
}
fn handleCreateComment(
self: *Server,
a: std.mem.Allocator,
+165
View File
@@ -0,0 +1,165 @@
//! Opening a file under review in Android Studio.
//!
//! Done through the IDE's own command-line launcher, `studio --line N <file>`,
//! because that is the entry point that knows about windows. With Studio already
//! running, the launcher does not start a second copy: it hands the file to the
//! running instance and exits, and the instance opens it in the window of the
//! project that contains it — the checkout's own window, not whichever one was
//! focused last. With nothing running, the launcher *is* Studio starting up, and
//! it opens the project the file belongs to.
//!
//! That second case is why the launcher is not simply a child of the app. A
//! Studio started from here would land in playpen's own systemd scope, where it
//! running out of memory would take the app down with it — the failure
//! `scope.zig` exists to prevent for shells. So it goes into a scope of its own,
//! and is detached from the request that started it, which must not sit waiting
//! for an IDE to quit.
const std = @import("std");
/// Launcher names worth finding on PATH: JetBrains Toolbox's shim when its
/// scripts directory is on PATH, what distro and snap packages install, and the
/// script a plain tarball ships with.
const path_names = [_][]const u8{ "studio", "android-studio", "studio.sh" };
/// Where launchers live when nothing put them on PATH, under `$HOME`. Toolbox
/// writes its shim here whether or not the directory is on PATH, which on a
/// desktop session started from a launcher it usually is not.
const home_paths = [_][]const u8{
".local/share/JetBrains/Toolbox/scripts/studio",
"android-studio/bin/studio",
"android-studio/bin/studio.sh",
};
/// Where a tarball, a snap or a Flatpak leaves it system-wide.
const system_paths = [_][]const u8{
"/opt/android-studio/bin/studio",
"/opt/android-studio/bin/studio.sh",
"/usr/local/android-studio/bin/studio.sh",
"/snap/bin/android-studio",
"/var/lib/flatpak/exports/bin/com.google.AndroidStudio",
};
/// The Android Studio launcher on this machine, or null if there is none where
/// one is normally installed. Looked up afresh each time — a handful of
/// `access` calls — so installing Studio while the app runs is enough.
pub fn find(a: std.mem.Allocator, io: std.Io) ?[]const u8 {
if (std.c.getenv("PATH")) |raw| {
var dirs = std.mem.tokenizeScalar(u8, std.mem.span(raw), ':');
while (dirs.next()) |dir| {
if (!std.fs.path.isAbsolute(dir)) continue;
for (path_names) |name| {
const candidate = std.fs.path.join(a, &.{ dir, name }) catch return null;
if (executable(io, candidate)) return candidate;
}
}
}
if (std.c.getenv("HOME")) |raw| {
const home = std.mem.span(raw);
if (std.fs.path.isAbsolute(home)) for (home_paths) |rel| {
const candidate = std.fs.path.join(a, &.{ home, rel }) catch return null;
if (executable(io, candidate)) return candidate;
};
}
for (system_paths) |candidate| {
if (executable(io, candidate)) return candidate;
}
return null;
}
fn executable(io: std.Io, path: []const u8) bool {
std.Io.Dir.accessAbsolute(io, path, .{ .execute = true }) catch return false;
return true;
}
/// Whether `path` names something inside the directory it is relative to: not
/// absolute, and never stepping up out of it. Paths here come from the page, and
/// a request to show a file under review is no reason to hand an IDE any file
/// on disk.
pub fn isInside(path: []const u8) bool {
if (path.len == 0 or std.fs.path.isAbsolute(path)) return false;
if (std.mem.indexOfScalar(u8, path, 0) != null) return false;
var parts = std.mem.splitScalar(u8, path, '/');
while (parts.next()) |part| {
if (std.mem.eql(u8, part, "..")) return false;
}
return true;
}
/// Backgrounds the launcher and exits, so the request returns as soon as it has
/// started rather than when Studio quits. The launcher's own output goes nowhere:
/// it would otherwise hold the pipes `std.process.run` is reading open, and the
/// run would last as long as the IDE does.
///
/// The scope is the same bargain `scope.zig` makes for shells, made with
/// `systemd-run` because this side of the app has no D-Bus connection to make it
/// with. Only tried when there is a session bus to ask; without one the launcher
/// runs in the app's own cgroup, as anything did before scopes.
const launch_script =
\\if [ -S "${XDG_RUNTIME_DIR:-/nonexistent}/bus" ] && command -v systemd-run >/dev/null 2>&1; then
\\ set -- systemd-run --user --scope --collect --quiet --slice=app.slice \
\\ --description="Android Studio (from playpen)" -p OOMPolicy=continue -- "$@"
\\fi
\\"$@" </dev/null >/dev/null 2>&1 &
;
/// Open `file`, an absolute path, in Android Studio at `line` (1-based) if one
/// is given. Returns once the launcher has been started, not once it has opened
/// anything: the launcher answers nothing back either way.
pub fn open(
a: std.mem.Allocator,
io: std.Io,
launcher: []const u8,
cwd: []const u8,
file: []const u8,
line: ?u32,
) !void {
var argv: std.ArrayListUnmanaged([]const u8) = .empty;
try argv.appendSlice(a, &.{ "sh", "-c", launch_script, "sh", launcher });
if (line) |n| try argv.appendSlice(a, &.{ "--line", try std.fmt.allocPrint(a, "{d}", .{n}) });
try argv.append(a, file);
// The server's `Io` hands children an empty environment, which suits git
// and would leave the launcher unable to find a display to open a window
// on, a session bus for the scope, or even the running instance it is
// meant to hand the file to — that is found through `$HOME`. So this one
// gets the app's own.
var env = try processEnviron().createMap(a);
const result = std.process.run(a, io, .{
.argv = argv.items,
.cwd = .{ .path = cwd },
.environ_map = &env,
.stdout_limit = .limited(4 * 1024),
.stderr_limit = .limited(4 * 1024),
.timeout = .{ .duration = .{ .raw = .fromSeconds(5), .clock = .awake } },
}) catch |err| {
std.log.warn("review: launching Android Studio: {s}", .{@errorName(err)});
return error.LaunchFailed;
};
switch (result.term) {
.exited => |code| if (code != 0) {
std.log.warn("review: launching Android Studio exited {d}: {s}", .{
code, std.mem.trim(u8, result.stderr, " \t\r\n"),
});
return error.LaunchFailed;
},
else => return error.LaunchFailed,
}
}
fn processEnviron() std.process.Environ {
var n: usize = 0;
while (std.c.environ[n] != null) : (n += 1) {}
return .{ .block = .{ .slice = std.c.environ[0..n :null] } };
}
test "isInside keeps paths within the work tree" {
try std.testing.expect(isInside("app/src/main/Foo.kt"));
try std.testing.expect(isInside("README.md"));
try std.testing.expect(isInside("a/..b/c"));
try std.testing.expect(!isInside(""));
try std.testing.expect(!isInside("/etc/passwd"));
try std.testing.expect(!isInside("../elsewhere"));
try std.testing.expect(!isInside("app/../../elsewhere"));
try std.testing.expect(!isInside("app/.."));
}