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
+2
View File
@@ -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.
+62 -5
View File
@@ -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
+21
View File
@@ -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
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;
}