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
|
SaveLayoutDialog.zig turns the current tab into a saved layout
|
||||||
Session.zig libghostty-vt Terminal + parser, fed by the PTY
|
Session.zig libghostty-vt Terminal + parser, fed by the PTY
|
||||||
Pty.zig openpt/fork/exec, controlling terminal setup
|
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
|
key.zig GDK keyval -> libghostty-vt key mapping
|
||||||
theme.zig colors libghostty-vt has no opinion about, per scheme
|
theme.zig colors libghostty-vt has no opinion about, per scheme
|
||||||
Settings.zig preferences: model, JSON on disk
|
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
|
rather than blanked, so a typo shows up in the pane instead of silently
|
||||||
producing an empty path.
|
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
|
**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
|
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
|
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
|
`wtype -k Return` in its own call is swallowed entirely — pass it as part of
|
||||||
the same `wtype` command as the text it submits.
|
the same `wtype` command as the text it submits.
|
||||||
|
|
||||||
Only plain text injection reaches the app. Modifier chords (`wtype -M ctrl`)
|
**Modifier chords do work**, but only with that same caveat applied to the
|
||||||
and synthetic clicks (`swaymsg seat - cursor`) are both accepted by the
|
chord itself. `wtype -M ctrl -k comma -m ctrl` on its own is unreliable — the
|
||||||
compositor and never delivered to the client, so shortcuts and buttons can't be
|
dropped first event is the *modifier press*, leaving a bare comma — so the
|
||||||
exercised this way; to screenshot a state that a chord would reach, open it
|
throwaway key has to go inside the same invocation:
|
||||||
from code instead.
|
|
||||||
|
```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
|
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
|
`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());
|
run_cmd.step.dependOn(b.getInstallStep());
|
||||||
if (b.args) |args| run_cmd.addArgs(args);
|
if (b.args) |args| run_cmd.addArgs(args);
|
||||||
b.step("run", "Run the app").dependOn(&run_cmd.step);
|
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
|
//! through `{{name}}` substitution first. That is what makes one layout usable
|
||||||
//! against any number of projects.
|
//! 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.
|
//! 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
|
//! 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
|
//! authored in the app, and a format we can round-trip without a hand-written
|
||||||
@@ -26,6 +31,8 @@
|
|||||||
const std = @import("std");
|
const std = @import("std");
|
||||||
const glib = @import("glib");
|
const glib = @import("glib");
|
||||||
|
|
||||||
|
const script = @import("script.zig");
|
||||||
|
|
||||||
const Layouts = @This();
|
const Layouts = @This();
|
||||||
|
|
||||||
/// Bumped only if the on-disk shape changes incompatibly. Read but not yet
|
/// 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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Expand a path the way a shell would treat it: `{{...}}` first, then a
|
/// Expand a path the way a shell would treat it: `{{...}}`, then `$(...)`,
|
||||||
/// leading `~`. Returns an empty slice for an empty template, meaning
|
/// then a leading `~`. Returns an empty slice for an empty template, meaning
|
||||||
/// "wherever the app was started".
|
/// "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(
|
pub fn expandPath(
|
||||||
alloc: std.mem.Allocator,
|
alloc: std.mem.Allocator,
|
||||||
template: []const u8,
|
template: []const u8,
|
||||||
bindings: []const Binding,
|
bindings: []const Binding,
|
||||||
) ![]u8 {
|
) ![]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 (!std.mem.startsWith(u8, expanded, "~")) return expanded;
|
||||||
if (expanded.len > 1 and expanded[1] != '/') 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..] });
|
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 -----------------------------------------------------------
|
// ---- panes -----------------------------------------------------------
|
||||||
content.append(heading("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.
|
// Leaves in tree order, which reads top-left to bottom-right on screen.
|
||||||
var leaves: std.ArrayListUnmanaged(*Layouts.Node) = .empty;
|
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