Add a button to open in android studio.
This commit is contained in:
1 parent
0e8bc70151
commit
bb90255002
8 files changed
+298
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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/.."));
|
||||
}
|
||||
Reference in new issue
Block a user