Add ability use script as cwd.

This commit is contained in:
Greyson Parrelli
2026-08-13 08:51:03 -04:00
parent e16b147e16
commit 0836cbc772
6 changed files with 526 additions and 8 deletions
+223 -3
View File
@@ -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}}", &.{});
}
+4
View File
@@ -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
View File
@@ -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;
}