Add ability use script as cwd.
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
- If you ever need to edit layouts/settings, never delete or overwrite any currently-saved settings
|
||||
without backing them up and restoring them.
|
||||
@@ -121,6 +121,7 @@ OpenLayoutDialog.zig prompts for a layout's parameters
|
||||
SaveLayoutDialog.zig turns the current tab into a saved layout
|
||||
Session.zig libghostty-vt Terminal + parser, fed by the PTY
|
||||
Pty.zig openpt/fork/exec, controlling terminal setup
|
||||
script.zig runs a command for `$(...)` in a layout's directory
|
||||
key.zig GDK keyval -> libghostty-vt key mapping
|
||||
theme.zig colors libghostty-vt has no opinion about, per scheme
|
||||
Settings.zig preferences: model, JSON on disk
|
||||
@@ -198,6 +199,46 @@ leading `~` is expanded afterwards. An unknown `{{name}}` is left as written
|
||||
rather than blanked, so a typo shows up in the pane instead of silently
|
||||
producing an empty path.
|
||||
|
||||
### Directories from a script
|
||||
|
||||
A **directory** can also be `$(a command)`, and what the command prints becomes
|
||||
the path. A parameter can say *which* project; only a command can answer
|
||||
"wherever that branch is checked out":
|
||||
|
||||
```json
|
||||
"cwd": "$(git -C ~/src/{{repo}} worktree list | awk '/{{branch}}/ {print $1}')"
|
||||
```
|
||||
|
||||
It runs under `/bin/sh -c`, so pipes and `&&` work as written, and `$(...)` may
|
||||
appear anywhere in the field — `~/src/$(pick-project)/api` is fine, as are
|
||||
several in one path. Its stdout is the value, trimmed of surrounding
|
||||
whitespace; its stderr stays attached to Playpen's own, so a script that
|
||||
complains complains somewhere you can read it.
|
||||
|
||||
**Parameters reach the script two ways.** They are substituted into it first,
|
||||
as `{{name}}` above, and every one is also in its environment as
|
||||
`PLAYPEN_<NAME>` — uppercased, with anything that isn't a shell identifier
|
||||
character replaced by `_`, so `repo-path` arrives as `PLAYPEN_REPO_PATH`. Reach
|
||||
for the environment form whenever a value might contain a space: `{{name}}`
|
||||
splices text straight into the command line, where a path with a space in it
|
||||
silently becomes two arguments, and `"$PLAYPEN_NAME"` cannot.
|
||||
|
||||
Order is `{{name}}` → `$(...)` → leading `~`, so a script is free to print a
|
||||
`~/...` path and land where it meant to.
|
||||
|
||||
Two limits worth knowing. **A script blocks the window while it runs**, because
|
||||
layouts are built by a synchronous walk of the split tree; it is killed after
|
||||
five seconds, so a mistake costs a visible pause and an error rather than a
|
||||
window that never comes back. And **a script that fails leaves an empty string**
|
||||
rather than aborting the tab — the rest of the layout is usually fine, and a
|
||||
pane that opened in the wrong directory is both obvious and recoverable, which
|
||||
a tab that refused to open is not. Failures are logged with the command that
|
||||
caused them.
|
||||
|
||||
This applies to the directory only. A pane's *script* field is already typed
|
||||
into a shell, which does its own command substitution; running it here first
|
||||
would evaluate it twice, at two different moments.
|
||||
|
||||
**Scripts are typed into the shell, not run instead of it.** A pane starts your
|
||||
login shell as usual, and the script is fed to it once it is ready. The shell
|
||||
is still there when the script finishes, with your environment loaded and the
|
||||
@@ -568,11 +609,27 @@ not of the terminal. Note that this applies per invocation, so a trailing
|
||||
`wtype -k Return` in its own call is swallowed entirely — pass it as part of
|
||||
the same `wtype` command as the text it submits.
|
||||
|
||||
Only plain text injection reaches the app. Modifier chords (`wtype -M ctrl`)
|
||||
and synthetic clicks (`swaymsg seat - cursor`) are both accepted by the
|
||||
compositor and never delivered to the client, so shortcuts and buttons can't be
|
||||
exercised this way; to screenshot a state that a chord would reach, open it
|
||||
from code instead.
|
||||
**Modifier chords do work**, but only with that same caveat applied to the
|
||||
chord itself. `wtype -M ctrl -k comma -m ctrl` on its own is unreliable — the
|
||||
dropped first event is the *modifier press*, leaving a bare comma — so the
|
||||
throwaway key has to go inside the same invocation:
|
||||
|
||||
```sh
|
||||
./shot.sh out.png "-k Shift_L -M ctrl -k comma -m ctrl"
|
||||
```
|
||||
|
||||
Note that `shot.sh` quotes `$KEYS`, so a chord needs the quoting relaxed for
|
||||
the words to reach `wtype` as separate arguments.
|
||||
|
||||
**Synthetic clicks do not work.** `swaymsg seat seat0 cursor set/press` is
|
||||
accepted and reports success, but the headless backend has no pointer device to
|
||||
emit from and the client never sees it. Anything reachable only by clicking has
|
||||
to be reached another way — a keyboard shortcut, or `zig build test` if the
|
||||
thing being checked is logic rather than pixels.
|
||||
|
||||
`zig build test` runs the unit tests, which cover layout parsing and the
|
||||
parameter/`$(...)` substitution pipeline. They are rooted at `Layouts.zig` so
|
||||
the test binary never links GTK.
|
||||
|
||||
Web panes need an EGL display and the headless compositor has no GPU, so
|
||||
`shot.sh` forces the dev shell's mesa down to its software rasterizer. The mesa
|
||||
|
||||
@@ -63,4 +63,25 @@ pub fn build(b: *std.Build) void {
|
||||
run_cmd.step.dependOn(b.getInstallStep());
|
||||
if (b.args) |args| run_cmd.addArgs(args);
|
||||
b.step("run", "Run the app").dependOn(&run_cmd.step);
|
||||
|
||||
// Tests are rooted at Layouts.zig rather than main.zig: the parts of this
|
||||
// app worth testing in isolation are the ones that are pure data — layout
|
||||
// parsing, parameter substitution, the `$(...)` expansion and the script
|
||||
// runner underneath it. Everything else is a widget tree, which wants a
|
||||
// display and a person looking at it.
|
||||
//
|
||||
// Rooting there also keeps the test binary off GTK entirely; Layouts.zig
|
||||
// and script.zig reach for GLib and nothing above it.
|
||||
const tests = b.addTest(.{
|
||||
.root_module = b.createModule(.{
|
||||
.root_source_file = b.path("src/Layouts.zig"),
|
||||
.target = target,
|
||||
.optimize = optimize,
|
||||
.link_libc = true,
|
||||
}),
|
||||
});
|
||||
tests.root_module.addImport("glib", gobject.module("glib2"));
|
||||
|
||||
const run_tests = b.addRunArtifact(tests);
|
||||
b.step("test", "Run the tests").dependOn(&run_tests.step);
|
||||
}
|
||||
|
||||
+223
-3
@@ -7,6 +7,11 @@
|
||||
//! through `{{name}}` substitution first. That is what makes one layout usable
|
||||
//! against any number of projects.
|
||||
//!
|
||||
//! A `cwd` goes one step further: `$(...)` in it is run as a shell command and
|
||||
//! replaced by what it prints. A parameter can say *which* project; only a
|
||||
//! command can answer "wherever that branch is checked out". See `expandPath`,
|
||||
//! and `script.zig` for what running one costs.
|
||||
//!
|
||||
//! Everything lives in a single JSON file under the user's config directory.
|
||||
//! JSON because the app writes this file as well as reading it: layouts are
|
||||
//! authored in the app, and a format we can round-trip without a hand-written
|
||||
@@ -26,6 +31,8 @@
|
||||
const std = @import("std");
|
||||
const glib = @import("glib");
|
||||
|
||||
const script = @import("script.zig");
|
||||
|
||||
const Layouts = @This();
|
||||
|
||||
/// Bumped only if the on-disk shape changes incompatibly. Read but not yet
|
||||
@@ -553,15 +560,28 @@ fn lookup(bindings: []const Binding, name: []const u8) ?[]const u8 {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Expand a path the way a shell would treat it: `{{...}}` first, then a
|
||||
/// leading `~`. Returns an empty slice for an empty template, meaning
|
||||
/// Expand a path the way a shell would treat it: `{{...}}`, then `$(...)`,
|
||||
/// then a leading `~`. Returns an empty slice for an empty template, meaning
|
||||
/// "wherever the app was started".
|
||||
///
|
||||
/// Parameters are substituted before the scripts run, which is what lets a
|
||||
/// script be aimed by one: `$(git -C ~/src/{{repo}} rev-parse --show-toplevel)`.
|
||||
/// Every parameter is also in the script's environment as `PLAYPEN_<NAME>`,
|
||||
/// which is the form to reach for when a value might contain a space.
|
||||
///
|
||||
/// `~` is expanded last so that a script is free to print one — a helper that
|
||||
/// answers `~/projects/foo` lands in the same place as a field that says so.
|
||||
pub fn expandPath(
|
||||
alloc: std.mem.Allocator,
|
||||
template: []const u8,
|
||||
bindings: []const Binding,
|
||||
) ![]u8 {
|
||||
const expanded = try expand(alloc, template, bindings);
|
||||
const substituted = try expand(alloc, template, bindings);
|
||||
const expanded = blk: {
|
||||
defer alloc.free(substituted);
|
||||
break :blk try expandScripts(alloc, substituted, bindings);
|
||||
};
|
||||
|
||||
if (!std.mem.startsWith(u8, expanded, "~")) return expanded;
|
||||
if (expanded.len > 1 and expanded[1] != '/') return expanded;
|
||||
|
||||
@@ -571,3 +591,203 @@ pub fn expandPath(
|
||||
|
||||
return std.fmt.allocPrint(alloc, "{s}{s}", .{ home, expanded[1..] });
|
||||
}
|
||||
|
||||
/// Replace every `$(command)` with what the command prints.
|
||||
///
|
||||
/// Applied to the directory only. A pane's *script* field is already typed
|
||||
/// into a shell, which does its own command substitution — running it here
|
||||
/// first would evaluate it a second time, at a different moment, with
|
||||
/// different results.
|
||||
///
|
||||
/// A failing command leaves an empty string behind rather than aborting the
|
||||
/// tab. The rest of the layout is usually fine, and a pane that opened in the
|
||||
/// wrong directory is both obvious and recoverable; a tab that refused to open
|
||||
/// because one of six panes couldn't resolve its path is neither.
|
||||
fn expandScripts(
|
||||
alloc: std.mem.Allocator,
|
||||
template: []const u8,
|
||||
bindings: []const Binding,
|
||||
) ![]u8 {
|
||||
// The overwhelmingly common case is a plain path, and it should not pay
|
||||
// for the parameter marshalling below.
|
||||
if (std.mem.indexOf(u8, template, "$(") == null) return alloc.dupe(u8, template);
|
||||
|
||||
const vars = try alloc.alloc(script.Var, bindings.len);
|
||||
defer alloc.free(vars);
|
||||
for (bindings, 0..) |b, i| vars[i] = .{ .name = b.name, .value = b.value };
|
||||
|
||||
var out: std.ArrayListUnmanaged(u8) = .empty;
|
||||
errdefer out.deinit(alloc);
|
||||
|
||||
var rest = template;
|
||||
while (std.mem.indexOf(u8, rest, "$(")) |open| {
|
||||
const body = rest[open + 2 ..];
|
||||
|
||||
// An unclosed `$(` is left exactly as typed, for the same reason a
|
||||
// `{{name}}` with no such parameter is: it is a typo, and showing it
|
||||
// is how the typo gets found.
|
||||
const close = closingParen(body) orelse break;
|
||||
|
||||
try out.appendSlice(alloc, rest[0..open]);
|
||||
|
||||
const command = body[0..close];
|
||||
if (script.run(alloc, command, vars)) |result| {
|
||||
defer alloc.free(result);
|
||||
try out.appendSlice(alloc, result);
|
||||
} else |err| {
|
||||
std.log.warn("layout directory script failed ({s}): {s}", .{
|
||||
@errorName(err),
|
||||
command,
|
||||
});
|
||||
}
|
||||
|
||||
rest = body[close + 1 ..];
|
||||
}
|
||||
try out.appendSlice(alloc, rest);
|
||||
|
||||
return out.toOwnedSlice(alloc);
|
||||
}
|
||||
|
||||
/// Offset of the `)` that closes an already-consumed `$(`.
|
||||
///
|
||||
/// Nesting is counted so that `$(dirname $(which zig))` hands the whole thing
|
||||
/// to the shell in one piece, which then evaluates the inner one itself.
|
||||
fn closingParen(s: []const u8) ?usize {
|
||||
var depth: usize = 0;
|
||||
for (s, 0..) |ch, i| {
|
||||
switch (ch) {
|
||||
'(' => depth += 1,
|
||||
')' => {
|
||||
if (depth == 0) return i;
|
||||
depth -= 1;
|
||||
},
|
||||
else => {},
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Tests
|
||||
//
|
||||
// These cover the substitution pipeline, which is the part of a layout that
|
||||
// runs long after it was written and with values it has never seen. The
|
||||
// `$(...)` cases really do spawn a shell — the point of the feature is what
|
||||
// comes back from one, and a fake would only prove the parser.
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
/// Run `expandPath` and hand back the result for comparison.
|
||||
fn expectPath(
|
||||
expected: []const u8,
|
||||
template: []const u8,
|
||||
bindings: []const Binding,
|
||||
) !void {
|
||||
const got = try expandPath(testing.allocator, template, bindings);
|
||||
defer testing.allocator.free(got);
|
||||
try testing.expectEqualStrings(expected, got);
|
||||
}
|
||||
|
||||
test "expandPath leaves a plain path alone" {
|
||||
try expectPath("/tmp/x", "/tmp/x", &.{});
|
||||
}
|
||||
|
||||
test "expandPath substitutes parameters" {
|
||||
try expectPath("/tmp/foo", "/tmp/{{name}}", &.{.{ .name = "name", .value = "foo" }});
|
||||
}
|
||||
|
||||
test "expandPath runs a script" {
|
||||
try expectPath("hello", "$(echo hello)", &.{});
|
||||
}
|
||||
|
||||
test "expandPath trims the trailing newline a command leaves" {
|
||||
try expectPath("/tmp", "$(cd /tmp && pwd)", &.{});
|
||||
}
|
||||
|
||||
test "expandPath splices a script into the middle of a path" {
|
||||
try expectPath("/a/b/c", "/a/$(echo b)/c", &.{});
|
||||
}
|
||||
|
||||
test "expandPath runs several scripts in one field" {
|
||||
try expectPath("/a/b", "$(echo /a)/$(echo b)", &.{});
|
||||
}
|
||||
|
||||
// The two ways a script gets at a parameter. Substitution is the readable one;
|
||||
// the environment variable is the one that survives a value with a space in it.
|
||||
test "a script sees parameters substituted into it" {
|
||||
try expectPath(
|
||||
"/tmp/foo",
|
||||
"$(echo /tmp/{{name}})",
|
||||
&.{.{ .name = "name", .value = "foo" }},
|
||||
);
|
||||
}
|
||||
|
||||
test "a script sees parameters in its environment" {
|
||||
try expectPath(
|
||||
"/tmp/foo",
|
||||
"$(echo /tmp/$PLAYPEN_NAME)",
|
||||
&.{.{ .name = "name", .value = "foo" }},
|
||||
);
|
||||
}
|
||||
|
||||
test "an environment parameter survives a value containing spaces" {
|
||||
try expectPath(
|
||||
"a b c",
|
||||
"$(printf %s \"$PLAYPEN_WHAT\")",
|
||||
&.{.{ .name = "what", .value = "a b c" }},
|
||||
);
|
||||
}
|
||||
|
||||
test "a parameter name that isn't a shell identifier is still reachable" {
|
||||
try expectPath(
|
||||
"ok",
|
||||
"$(printf %s \"$PLAYPEN_REPO_PATH\")",
|
||||
&.{.{ .name = "repo-path", .value = "ok" }},
|
||||
);
|
||||
}
|
||||
|
||||
test "~ is expanded after the script, so a script may print one" {
|
||||
const home = std.mem.span(glib.getHomeDir());
|
||||
if (home.len == 0) return error.SkipZigTest;
|
||||
|
||||
const expected = try std.fmt.allocPrint(testing.allocator, "{s}/x", .{home});
|
||||
defer testing.allocator.free(expected);
|
||||
|
||||
try expectPath(expected, "$(printf '~/x')", &.{});
|
||||
}
|
||||
|
||||
test "nested substitution is handed to the shell whole" {
|
||||
try expectPath("b", "$(echo $(echo b))", &.{});
|
||||
}
|
||||
|
||||
// A script that fails leaves an empty string rather than taking the tab with
|
||||
// it, so the surrounding path is still assembled.
|
||||
test "a failing script substitutes nothing" {
|
||||
try expectPath("/a//c", "/a/$(exit 3)/c", &.{});
|
||||
}
|
||||
|
||||
test "a script that prints nothing substitutes nothing" {
|
||||
try expectPath("/a//c", "/a/$(true)/c", &.{});
|
||||
}
|
||||
|
||||
// An unclosed `$(` is a typo, and the way a typo gets found is by staying
|
||||
// visible rather than being silently eaten.
|
||||
test "an unclosed script marker is left as written" {
|
||||
try expectPath("/a/$(echo b", "/a/$(echo b", &.{});
|
||||
}
|
||||
|
||||
test "the script marker is inert in an ordinary path" {
|
||||
try expectPath("/a/$b/c", "/a/$b/c", &.{});
|
||||
}
|
||||
|
||||
test "closingParen counts nesting" {
|
||||
try testing.expectEqual(@as(?usize, 3), closingParen("abc)"));
|
||||
try testing.expectEqual(@as(?usize, 8), closingParen("a $(b) c)"));
|
||||
try testing.expectEqual(@as(?usize, null), closingParen("a $(b c"));
|
||||
}
|
||||
|
||||
// `expand` is older than the scripts, but it is the first stage of the same
|
||||
// pipeline and its edge cases decide what the shell ends up seeing.
|
||||
test "an unknown parameter is left visible rather than blanked" {
|
||||
try expectPath("/tmp/{{nope}}", "/tmp/{{nope}}", &.{});
|
||||
}
|
||||
|
||||
@@ -153,6 +153,10 @@ pub fn present(
|
||||
|
||||
// ---- panes -----------------------------------------------------------
|
||||
content.append(heading("Panes"));
|
||||
content.append(hint(
|
||||
"A directory can be $(a command), and what it prints becomes the path. " ++
|
||||
"Parameters reach it as {{name}} or as $PLAYPEN_NAME.",
|
||||
));
|
||||
|
||||
// Leaves in tree order, which reads top-left to bottom-right on screen.
|
||||
var leaves: std.ArrayListUnmanaged(*Layouts.Node) = .empty;
|
||||
|
||||
+214
@@ -0,0 +1,214 @@
|
||||
//! Running a command and taking what it prints, for the `$(...)` form in a
|
||||
//! layout's directory field.
|
||||
//!
|
||||
//! A layout's directory is usually a path, and a parameter is usually enough to
|
||||
//! aim it — `~/projects/{{name}}`. What a parameter cannot do is *look
|
||||
//! something up*: the worktree a branch is checked out in, the newest build
|
||||
//! directory, the project root above wherever you happen to be. Those are one
|
||||
//! line of shell each, and this is what lets that line go in the field.
|
||||
//!
|
||||
//! The command runs under `/bin/sh -c`, so pipes, `&&` and the rest work as
|
||||
//! written. Its stdout is the value; its stderr is left attached to Playpen's
|
||||
//! own, so a script that complains is complaining somewhere you can read it.
|
||||
//!
|
||||
//! **This blocks the main loop while it runs.** Layouts are built by a
|
||||
//! synchronous walk of the split tree — panes are created, wired up and
|
||||
//! appended in one pass — and unpicking that into a callback chain to await a
|
||||
//! subprocess would be a large change for a call that is normally a few
|
||||
//! milliseconds. The timeout below is what keeps that honest: a script that
|
||||
//! hangs costs a visible pause and then an error, rather than a window that
|
||||
//! never comes back.
|
||||
//!
|
||||
//! Like `Pty.zig`, the libc calls are declared through `std.c` rather than
|
||||
//! `std.posix`, which has been churning across Zig releases.
|
||||
|
||||
const std = @import("std");
|
||||
const glib = @import("glib");
|
||||
|
||||
const c = std.c;
|
||||
|
||||
/// How long a command may run before it is killed.
|
||||
///
|
||||
/// Deliberately short. This is a value being spliced into a directory field
|
||||
/// while a tab is opening, so anything that isn't nearly instant is already
|
||||
/// the wrong shape for the job — five seconds is long enough for a `git` call
|
||||
/// on a cold cache and short enough that a mistake is an annoyance rather than
|
||||
/// a hang.
|
||||
const timeout_ms: i64 = 5000;
|
||||
|
||||
/// Cap on captured output. A directory path is a couple of hundred bytes; this
|
||||
/// is only here so that pointing the field at something that streams forever
|
||||
/// stops rather than eats memory until the timeout.
|
||||
const max_output = 64 * 1024;
|
||||
|
||||
/// Prefix for the parameter environment variables. Namespaced because these
|
||||
/// land in the environment of a script the user wrote, alongside their own.
|
||||
const env_prefix = "PLAYPEN_";
|
||||
|
||||
/// One of the layout's parameters, as the script will see it.
|
||||
pub const Var = struct {
|
||||
name: []const u8,
|
||||
value: []const u8,
|
||||
};
|
||||
|
||||
pub const Error = error{
|
||||
OutOfMemory,
|
||||
SpawnFailed,
|
||||
Timeout,
|
||||
|
||||
/// The command ran and exited non-zero, or died on a signal.
|
||||
Failed,
|
||||
};
|
||||
|
||||
/// Run `command`, returning its trimmed stdout. Caller owns the result.
|
||||
pub fn run(alloc: std.mem.Allocator, command: []const u8, vars: []const Var) Error![]u8 {
|
||||
var argv0 = "/bin/sh".*;
|
||||
var argv1 = "-c".*;
|
||||
|
||||
const command_z = try alloc.dupeZ(u8, command);
|
||||
defer alloc.free(command_z);
|
||||
|
||||
var argv = [_:null]?[*:0]u8{ &argv0, &argv1, command_z.ptr };
|
||||
|
||||
const envp = try environment(alloc, vars);
|
||||
defer glib.strfreev(envp);
|
||||
|
||||
var pid: glib.Pid = undefined;
|
||||
var out_fd: c_int = -1;
|
||||
var spawn_err: ?*glib.Error = null;
|
||||
|
||||
// stdin and stderr are passed as null, which means "inherit". Inheriting
|
||||
// stderr is the point: it is where a script's own diagnostics go, and
|
||||
// swallowing them would make a broken one-liner in a text field about as
|
||||
// hard to debug as anything in this app gets.
|
||||
if (glib.spawnAsyncWithPipes(
|
||||
null,
|
||||
&argv,
|
||||
envp,
|
||||
.{ .do_not_reap_child = true },
|
||||
null,
|
||||
null,
|
||||
&pid,
|
||||
null,
|
||||
&out_fd,
|
||||
null,
|
||||
&spawn_err,
|
||||
) == 0) {
|
||||
if (spawn_err) |e| {
|
||||
std.log.warn("could not run script: {s}", .{e.f_message orelse "unknown"});
|
||||
e.free();
|
||||
}
|
||||
return error.SpawnFailed;
|
||||
}
|
||||
|
||||
var out: std.ArrayListUnmanaged(u8) = .empty;
|
||||
defer out.deinit(alloc);
|
||||
|
||||
const timed_out = collect(alloc, out_fd, &out) catch |err| switch (err) {
|
||||
error.OutOfMemory => blk: {
|
||||
// Still have to reap the child before giving up, so the failure is
|
||||
// recorded and handled after the wait below rather than returned
|
||||
// from here.
|
||||
break :blk true;
|
||||
},
|
||||
};
|
||||
|
||||
_ = c.close(out_fd);
|
||||
|
||||
// A timed-out child is killed rather than left behind: it is attached to
|
||||
// our stderr and would keep writing there long after the tab it was
|
||||
// opening had given up on it.
|
||||
if (timed_out) _ = c.kill(pid, c.SIG.KILL);
|
||||
|
||||
var status: c_int = 0;
|
||||
_ = c.waitpid(pid, &status, 0);
|
||||
glib.spawnClosePid(pid);
|
||||
|
||||
if (timed_out) return error.Timeout;
|
||||
|
||||
// Covers both a non-zero exit and death by signal. The distinction doesn't
|
||||
// change what we do about it, and the script's own stderr has already said
|
||||
// more than an exit code could.
|
||||
if (status != 0) return error.Failed;
|
||||
|
||||
// Trailing newline trimmed because every well-behaved command emits one and
|
||||
// no directory wants it. Leading whitespace goes too — it is never
|
||||
// meaningful in a path and is easy to leave in a heredoc.
|
||||
const text = std.mem.trim(u8, out.items, &std.ascii.whitespace);
|
||||
return alloc.dupe(u8, text);
|
||||
}
|
||||
|
||||
/// Read `fd` to EOF or until the deadline. Returns whether it timed out.
|
||||
fn collect(
|
||||
alloc: std.mem.Allocator,
|
||||
fd: c_int,
|
||||
out: *std.ArrayListUnmanaged(u8),
|
||||
) error{OutOfMemory}!bool {
|
||||
// GLib's monotonic clock rather than a wall clock: this is a duration, and
|
||||
// a clock step mid-lookup should not decide whether the script is killed.
|
||||
const start = glib.getMonotonicTime();
|
||||
|
||||
while (true) {
|
||||
const elapsed_ms = @divTrunc(glib.getMonotonicTime() - start, std.time.us_per_ms);
|
||||
const remaining = timeout_ms - elapsed_ms;
|
||||
if (remaining <= 0) return true;
|
||||
|
||||
var fds = [_]c.pollfd{.{ .fd = fd, .events = c.POLL.IN, .revents = 0 }};
|
||||
const ready = c.poll(&fds, 1, @intCast(remaining));
|
||||
|
||||
// Negative is an error — including EINTR, which we treat as "stop
|
||||
// reading" rather than retrying. A signal arriving mid-lookup is not
|
||||
// worth a retry loop, and whatever was read so far is still returned.
|
||||
if (ready < 0) return false;
|
||||
if (ready == 0) return true;
|
||||
|
||||
var buf: [4096]u8 = undefined;
|
||||
const got = c.read(fd, &buf, buf.len);
|
||||
if (got <= 0) return false; // EOF, or an error we treat as one.
|
||||
|
||||
const room = max_output - out.items.len;
|
||||
if (room == 0) return false;
|
||||
try out.appendSlice(alloc, buf[0..@min(@as(usize, @intCast(got)), room)]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Playpen's own environment plus one variable per layout parameter.
|
||||
///
|
||||
/// These exist alongside `{{name}}` substitution rather than instead of it,
|
||||
/// and they are the safer of the two whenever a value might contain a space:
|
||||
/// `{{name}}` splices text straight into the command line, where a path with a
|
||||
/// space in it silently becomes two arguments, while `"$PLAYPEN_NAME"` cannot.
|
||||
fn environment(alloc: std.mem.Allocator, vars: []const Var) Error![*:null]?[*:0]u8 {
|
||||
var envp = glib.getEnviron();
|
||||
|
||||
for (vars) |v| {
|
||||
const name = envName(alloc, v.name) catch continue;
|
||||
defer alloc.free(name);
|
||||
|
||||
const value = alloc.dupeZ(u8, v.value) catch continue;
|
||||
defer alloc.free(value);
|
||||
|
||||
// Takes ownership of `envp` and hands back the updated list.
|
||||
envp = glib.environSetenv(envp, name.ptr, value.ptr, 1);
|
||||
}
|
||||
|
||||
return envp;
|
||||
}
|
||||
|
||||
/// `branch` becomes `PLAYPEN_BRANCH`. Anything that can't appear in a shell
|
||||
/// variable name becomes an underscore, so a parameter named `repo-path` is
|
||||
/// still reachable rather than quietly absent.
|
||||
fn envName(alloc: std.mem.Allocator, name: []const u8) ![:0]u8 {
|
||||
const out = try alloc.allocSentinel(u8, env_prefix.len + name.len, 0);
|
||||
@memcpy(out[0..env_prefix.len], env_prefix);
|
||||
|
||||
for (name, env_prefix.len..) |ch, i| {
|
||||
out[i] = switch (ch) {
|
||||
'a'...'z' => std.ascii.toUpper(ch),
|
||||
'A'...'Z', '0'...'9' => ch,
|
||||
else => '_',
|
||||
};
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
Reference in New Issue
Block a user