From 0836cbc77275d0478a153110436df913f96beb1c Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Thu, 13 Aug 2026 08:51:03 -0400 Subject: [PATCH] Add ability use script as cwd. --- CLAUDE.md | 2 + README.md | 67 +++++++++++- build.zig | 21 ++++ src/Layouts.zig | 226 ++++++++++++++++++++++++++++++++++++++- src/SaveLayoutDialog.zig | 4 + src/script.zig | 214 ++++++++++++++++++++++++++++++++++++ 6 files changed, 526 insertions(+), 8 deletions(-) create mode 100644 CLAUDE.md create mode 100644 src/script.zig diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..a6e164e --- /dev/null +++ b/CLAUDE.md @@ -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. diff --git a/README.md b/README.md index 1e0d978..1f0f2f1 100644 --- a/README.md +++ b/README.md @@ -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_` — 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 diff --git a/build.zig b/build.zig index e2e3df7..667e71e 100644 --- a/build.zig +++ b/build.zig @@ -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); } diff --git a/src/Layouts.zig b/src/Layouts.zig index c59ad99..929cd55 100644 --- a/src/Layouts.zig +++ b/src/Layouts.zig @@ -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_`, +/// 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}}", &.{}); +} diff --git a/src/SaveLayoutDialog.zig b/src/SaveLayoutDialog.zig index 1d34f3b..c290c23 100644 --- a/src/SaveLayoutDialog.zig +++ b/src/SaveLayoutDialog.zig @@ -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; diff --git a/src/script.zig b/src/script.zig new file mode 100644 index 0000000..2523d88 --- /dev/null +++ b/src/script.zig @@ -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; +}