diff --git a/README.md b/README.md index 0f0535c..6aec100 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,13 @@ # ghostty Example plugin for fizzy adding a ghostty terminal to the bottom panel + +## `terminal.run` + +Other plugins run a program in a terminal the person types into through the internal command +`terminal.run` (the chat's terminal sign-in, `claude /login`, is one). Its arguments, as ZON: +`.{ .title, .argv, .env = .{ .{ .name, .value } }, .cwd, .done, .tag }`. The program runs in a +pane of its own in the bottom panel, in `cwd` (empty: the project folder), over fizzy's +environment with the login shell's `PATH` plus `env`. When it exits, `terminal.run` calls the +command `done` with `.{ .tag, .code }`; the pane stays, showing the exit code, until the person +closes it. A caller checks `host.hasCommand("terminal.run")` before offering anything that needs +it. The contract is `src/launch.zig`. diff --git a/build.zig b/build.zig index 09870a4..9bad7ab 100644 --- a/build.zig +++ b/build.zig @@ -42,4 +42,13 @@ pub fn build(b: *std.Build) void { plugin.module.link_libc = true; fizzy.plugin.install(b, plugin.lib, .{}); + + // The parts that need no window: the `terminal.run` contract and the PTY running a command. + const tests = b.addTest(.{ .root_module = b.createModule(.{ + .root_source_file = b.path("src/tests.zig"), + .target = target, + .optimize = optimize, + .link_libc = true, + }) }); + b.step("test", "Run the unit tests").dependOn(&b.addRunArtifact(tests).step); } diff --git a/plugin.zig b/plugin.zig index 0b1ca9c..b1a7d9e 100644 --- a/plugin.zig +++ b/plugin.zig @@ -1,8 +1,11 @@ -//! Ghostty terminal plugin — contributes a single bottom-panel tab ("Ghostty") that embeds -//! a libghostty-vt terminal beside pixi's "Sprites" tab. The shell shows a tab strip +//! Ghostty terminal plugin — contributes a bottom-panel tab ("Ghostty") that embeds a +//! libghostty-vt terminal beside pixi's "Sprites" tab. The shell shows a tab strip //! automatically when more than one plugin contributes a bottom view, so this is purely //! additive — no shell or pixi changes. //! +//! It also offers `terminal.run` (`src/launch.zig`): another plugin runs a program in a pane of +//! its own here and hears its exit code, without either importing the other (`src/Run.zig`). +//! //! This is a "shell"/utility plugin: it owns no documents, so it implements none of the //! document vtable hooks (only `deinit`). See fizzy `docs/PLUGINS.md`. const sdk = @import("fizzy_sdk"); @@ -11,6 +14,8 @@ const dvui = @import("dvui"); const State = @import("src/State.zig"); const render = @import("src/render.zig"); const input = @import("src/input.zig"); +const launch = @import("src/launch.zig"); +const Run = @import("src/Run.zig"); /// Injected at build time from `plugin.zig.zon` — required by fizzy's generated dylib root, /// which reaches its own copy of this plugin's identity through this export rather than @@ -60,6 +65,9 @@ comptime { /// The plugin's own singleton state — a variable it owns. The SDK holds gpa/host. var plugin_state: State = .{}; +/// The `terminal.run` panes, open until the person closes them. +var runs: std.ArrayListUnmanaged(*Run) = .empty; +var next_run: u32 = 0; /// Entry point the host calls once at startup (static) or after dlopen (dynamic). Wire state, /// register the plugin, then add the bottom-panel Ghostty tab. @@ -77,6 +85,50 @@ pub fn register(host: *sdk.Host) !void { // Stay visible even with no active document, like pixi's Sprites tab. .persistent = true, }); + try host.registerCommand(.{ + .id = launch.command_id, + .owner = &plugin, + .title = "Terminal: Run a Program", + // Another plugin's call (`launch.Request`), not something a person runs by hand. + .visibility = .internal, + .runWith = runCommand, + }); +} + +/// `terminal.run`: open a pane running the request's program, and show it. +fn runCommand(_: *anyopaque, c: *sdk.Command.Call) anyerror!void { + const req = launch.parse(c.arena, c.args) catch |err| { + c.bad_args = true; + return c.fail("expected {s}'s arguments (title, argv, env, cwd, done, tag): {t}", .{ launch.command_id, err }); + }; + const gpa = sdk.allocator(); + const host = sdk.host(); + try runs.ensureUnusedCapacity(gpa, 1); + const id = try std.fmt.allocPrint(gpa, "ghostty.run.{d}", .{next_run}); + const run = Run.create(gpa, req, id) catch |err| { + gpa.free(id); + return c.fail("could not open a terminal for {s}: {t}", .{ req.argv[0], err }); + }; + errdefer run.destroy(gpa); + try host.registerSurface(.{ + .id = run.id, + .owner = &plugin, + .title = run.title, + .keywords = sdk.keywords.ide.panel, + .ctx = run, + .draw = Run.draw, + .persistent = true, + }); + if (!host.revealSurface(run.id)) { + host.unregisterSurface(run.id); + return c.fail("this app has no place to show a terminal", .{}); + } + run.begin() catch |err| { + host.unregisterSurface(run.id); + return c.fail("could not start {s}: {t}", .{ req.argv[0], err }); + }; + next_run += 1; + runs.appendAssumeCapacity(run); } fn drawSurface(ctx: ?*anyopaque) anyerror!dvui.App.Result { @@ -90,12 +142,35 @@ pub fn pluginPtr() *sdk.Plugin { } fn deinit(_: *anyopaque) void { - plugin_state.deinit(sdk.allocator()); + const gpa = sdk.allocator(); + for (runs.items) |run| { + // A program still running is hung up on; its caller hears so, if it is still loaded. + run.tell(Run.hung_up); + sdk.host().unregisterSurface(run.id); + run.destroy(gpa); + } + runs.deinit(gpa); + plugin_state.deinit(gpa); } fn endFrame(_: *anyopaque) void { - // Focus handling only while the terminal is the panel's chosen surface. - const chosen = sdk.host().selectionFor(sdk.keywords.ide.panel) orelse return; - if (!std.mem.eql(u8, chosen, bottom_terminal)) return; - input.handleLateFocus(&plugin_state); + const host = sdk.host(); + var i: usize = 0; + while (i < runs.items.len) { + const run = runs.items[i]; + run.poll(); + if (!run.closing) { + i += 1; + continue; + } + run.tell(Run.hung_up); + host.unregisterSurface(run.id); + _ = runs.orderedRemove(i); + run.destroy(sdk.allocator()); + } + + // Focus handling only for the terminal that is the panel's chosen surface. + const chosen = host.selectionFor(sdk.keywords.ide.panel) orelse return; + if (std.mem.eql(u8, chosen, bottom_terminal)) return input.handleLateFocus(&plugin_state); + for (runs.items) |run| if (std.mem.eql(u8, chosen, run.id)) return input.handleLateFocus(&run.state); } diff --git a/src/Pty.zig b/src/Pty.zig index 7c28a03..bb51108 100644 --- a/src/Pty.zig +++ b/src/Pty.zig @@ -1,4 +1,5 @@ -//! A pseudoterminal running the user's shell, with a background reader thread. +//! A pseudoterminal running the user's shell, or a program another plugin asked for, with a +//! background reader thread. //! //! Unix (macOS/Linux): `forkpty` + reader thread (`Pty_unix.zig`). //! Windows: ConPTY + PowerShell (`Pty_windows.zig`). @@ -11,4 +12,15 @@ else pub const supported = backend.supported; pub const OnData = backend.OnData; +pub const OnExit = backend.OnExit; pub const Pty = backend.Pty; + +pub const Env = struct { name: []const u8, value: []const u8 }; + +/// A program to run in the terminal instead of the user's shell. +pub const Command = struct { + /// `argv[0]` is found on the child's `PATH` (`env` included) unless it names a path. + argv: []const []const u8, + /// Over the app's own environment, with `TERM=xterm-256color` unless this sets it. + env: []const Env = &.{}, +}; diff --git a/src/Pty_unix.zig b/src/Pty_unix.zig index 9cbb280..46059ec 100644 --- a/src/Pty_unix.zig +++ b/src/Pty_unix.zig @@ -5,6 +5,11 @@ const builtin = @import("builtin"); pub const supported = true; pub const OnData = *const fn (ctx: *anyopaque, bytes: []const u8) void; +/// The program exited with `code` (128 + the signal when a signal ended it). Reader thread. +pub const OnExit = *const fn (ctx: *anyopaque, code: i32) void; + +const Env = @import("Pty.zig").Env; +const Command = @import("Pty.zig").Command; pub const Pty = struct { master: c_int = -1, @@ -13,6 +18,15 @@ pub const Pty = struct { stop: std.atomic.Value(bool) = .init(false), ctx: *anyopaque = undefined, on_data: OnData = undefined, + /// Called once the program has exited and its output is drained. Set before `startReader`. + on_exit: ?OnExit = null, + /// Waits on the program (`waitLoop`). + waiter: ?std.Thread = null, + /// The reader has reached the end of the terminal's output. + drained: std.atomic.Value(bool) = .init(false), + /// Guards `reaped` against a signal racing the reap: after it, the pid may be another's. + reap_lock: std.atomic.Mutex = .unlocked, + reaped: bool = false, const Winsize = extern struct { ws_row: u16 = 0, @@ -22,11 +36,15 @@ pub const Pty = struct { }; extern "c" fn forkpty(amaster: *c_int, name: ?[*:0]u8, termp: ?*const anyopaque, winp: ?*const Winsize) c_int; - extern "c" fn execvp(file: [*:0]const u8, argv: [*:null]const ?[*:0]const u8) c_int; - extern "c" fn setenv(name: [*:0]const u8, value: [*:0]const u8, overwrite: c_int) c_int; + extern "c" fn execve(path: [*:0]const u8, argv: [*:null]const ?[*:0]const u8, envp: [*:null]const ?[*:0]const u8) c_int; extern "c" fn chdir(path: [*:0]const u8) c_int; extern "c" fn ioctl(fd: c_int, request: c_ulong, ...) c_int; + extern "c" fn waitpid(pid: c_int, status: *c_int, options: c_int) c_int; extern "c" fn _exit(code: c_int) noreturn; + extern "c" fn waitid(idtype: c_int, id: c_uint, infop: *anyopaque, options: c_int) c_int; + const P_PID: c_int = 1; + const WEXITED: c_int = 4; + const WNOWAIT: c_int = if (builtin.target.os.tag == .linux) 0x01000000 else 0x20; const TIOCSWINSZ: c_ulong = switch (builtin.target.os.tag) { .linux => 0x5414, @@ -35,48 +53,101 @@ pub const Pty = struct { /// `cwd` — the shell opens here instead of wherever the app itself was launched from (its /// own bundle directory for a packaged `.app`), matching VSCode's integrated terminal - /// (opens in the current workspace root). Null (no project open yet) or too long for - /// `cwd_buf` falls back to whatever cwd the app process itself has. - pub fn open(ctx: *anyopaque, on_data: OnData, cols: u16, rows: u16, cwd: ?[]const u8) !Pty { - var ws = Winsize{ .ws_row = rows, .ws_col = cols }; - var amaster: c_int = -1; + /// (opens in the current workspace root). Null (no project open yet) falls back to + /// whatever cwd the app process itself has. `command` null runs the user's `$SHELL`. + pub fn open(ctx: *anyopaque, on_data: OnData, cols: u16, rows: u16, cwd: ?[]const u8, command: ?Command) !Pty { + // Everything the child needs is built *before* the fork: nothing between fork() and + // exec() in the child may allocate (another thread may hold the allocator's lock), and + // the parent's memory is copy-on-write duplicated into the child, so it is already there. + var arena_state: std.heap.ArenaAllocator = .init(std.heap.page_allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const shell: []const u8 = if (std.c.getenv("SHELL")) |s| std.mem.span(s) else "/bin/zsh"; + const cmd: Command = command orelse .{ .argv = &.{shell} }; + if (cmd.argv.len == 0) return error.NoCommand; - // Built *before* the fork: nothing between fork() and exec() in the child may - // allocate (see the sibling comment on `setenv`), and the stack this buffer lives on - // is copy-on-write duplicated into the child by fork() itself, so it's already there — - // no cross-fork pointer hazard. - var cwd_buf: [std.fs.max_path_bytes]u8 = undefined; - const cwd_z: ?[:0]const u8 = if (cwd) |c| blk: { - if (c.len >= cwd_buf.len) break :blk null; - @memcpy(cwd_buf[0..c.len], c); - cwd_buf[c.len] = 0; - break :blk cwd_buf[0..c.len :0]; - } else null; + const envp = try environment(arena, cmd.env); + const path = try resolve(arena, cmd.argv[0], lookup(envp, "PATH")); + const argv = try arena.allocSentinel(?[*:0]const u8, cmd.argv.len, null); + for (cmd.argv, argv) |a, *out| out.* = try arena.dupeSentinel(u8, a, 0); + const cwd_z: ?[:0]const u8 = if (cwd) |c| if (c.len > 0) try arena.dupeSentinel(u8, c, 0) else null else null; + var ws = Winsize{ .ws_row = rows, .ws_col = cols }; + var amaster: c_int = -1; const pid = forkpty(&amaster, null, null, &ws); if (pid < 0) return error.ForkptyFailed; if (pid == 0) { - // Child-only: `setenv` here would otherwise mutate the whole host editor - // process's environment, since `open` runs on the GUI thread before fork. - _ = setenv("TERM", "xterm-256color", 1); - // Best-effort: a failed chdir (deleted folder, etc.) just leaves the shell in + // Best-effort: a failed chdir (deleted folder, etc.) just leaves the program in // whatever cwd it inherited rather than aborting the launch. if (cwd_z) |c| _ = chdir(c.ptr); - const shell: [*:0]const u8 = std.c.getenv("SHELL") orelse "/bin/zsh"; - const argv = [_:null]?[*:0]const u8{shell}; - _ = execvp(shell, &argv); + _ = execve(path, argv.ptr, envp.ptr); _exit(127); } return .{ .master = amaster, .child = pid, .ctx = ctx, .on_data = on_data }; } + /// `KEY=VALUE` lines for the child: the app's own environment with `TERM` set, under + /// `overrides`, which win over both. + fn environment(arena: std.mem.Allocator, overrides: []const Env) ![:null]?[*:0]const u8 { + var out: std.ArrayListUnmanaged(?[*:0]const u8) = .empty; + var it: [*:null]const ?[*:0]const u8 = @ptrCast(appEnviron()); + while (it[0]) |line| : (it += 1) { + const entry = std.mem.span(line); + const eq = std.mem.indexOfScalar(u8, entry, '=') orelse continue; + const name = entry[0..eq]; + if (std.mem.eql(u8, name, "TERM") or named(overrides, name)) continue; + try out.append(arena, line); + } + if (!named(overrides, "TERM")) try out.append(arena, "TERM=xterm-256color"); + for (overrides) |e| try out.append(arena, try std.fmt.allocPrintSentinel(arena, "{s}={s}", .{ e.name, e.value }, 0)); + return out.toOwnedSliceSentinel(arena, null); + } + + fn named(list: []const Env, name: []const u8) bool { + for (list) |e| if (std.mem.eql(u8, e.name, name)) return true; + return false; + } + + fn lookup(envp: [:null]?[*:0]const u8, name: []const u8) ?[]const u8 { + for (envp) |line| { + const entry = std.mem.span(line.?); + if (entry.len > name.len and entry[name.len] == '=' and std.mem.startsWith(u8, entry, name)) + return entry[name.len + 1 ..]; + } + return null; + } + + /// `name` as `execvp` would find it, on the child's `PATH`: done here because `execvp` + /// searches the parent's. + fn resolve(arena: std.mem.Allocator, name: []const u8, path_value: ?[]const u8) ![:0]const u8 { + if (std.mem.indexOfScalar(u8, name, '/') != null) return arena.dupeSentinel(u8, name, 0); + var dirs = std.mem.splitScalar(u8, path_value orelse "/usr/bin:/bin", ':'); + while (dirs.next()) |dir| { + if (dir.len == 0) continue; + const candidate = try std.fmt.allocPrintSentinel(arena, "{s}/{s}", .{ dir, name }, 0); + if (std.c.access(candidate.ptr, std.c.X_OK) == 0) return candidate; + } + // Not found: exec fails in the child and the program exits 127, as a shell's would. + return arena.dupeSentinel(u8, name, 0); + } + + fn appEnviron() [*:null]?[*:0]u8 { + // A dylib on macOS has no `environ` of its own to link against; the C runtime hands it out. + if (comptime builtin.target.os.tag.isDarwin()) return _NSGetEnviron().*; + return std.c.environ; + } + extern "c" fn _NSGetEnviron() *[*:null]?[*:0]u8; + pub fn startReader(self: *Pty) !void { self.thread = try std.Thread.spawn(.{}, readLoop, .{self}); + self.waiter = try std.Thread.spawn(.{}, waitLoop, .{self}); } fn readLoop(self: *Pty) void { + defer self.drained.store(true, .release); var buf: [4096]u8 = undefined; while (!self.stop.load(.acquire)) { const n = std.posix.read(self.master, &buf) catch break; @@ -85,9 +156,76 @@ pub const Pty = struct { } } + /// Waits for the program itself, not for the terminal to close: a helper it left behind + /// holding the terminal (a browser opener, a node worker) must not hide its exit. + fn waitLoop(self: *Pty) void { + // Wait for the exit without reaping, so the pid stays the program's (a zombie) until + // `reap` takes it under the lock `signal` checks: a hangup never reaches a stranger. + var info: [256]u8 align(16) = undefined; + while (waitid(P_PID, @intCast(self.child), &info, WEXITED | WNOWAIT) != 0) { + if (std.c._errno().* != @backingInt(std.c.E.INTR)) break; + } + const code = self.reap() orelse return; + if (self.stop.load(.acquire)) return; + // Let the reader take what the program wrote last, so the exit reads after it. A helper + // holding the terminal open costs only this wait. + var waited: u32 = 0; + while (!self.drained.load(.acquire) and waited < drain_ms) : (waited += 10) sleepMs(10); + if (self.on_exit) |f| f(self.ctx, code); + } + + const drain_ms = 250; + + /// Reap the program: its exit code, or null when it was not ours to wait for. + fn reap(self: *Pty) ?i32 { + while (!self.reap_lock.tryLock()) std.atomic.spinLoopHint(); + defer self.reap_lock.unlock(); + var status: c_int = 0; + while (true) { + const r = waitpid(self.child, &status, 0); + if (r == self.child) break; + if (r < 0 and std.c._errno().* == @backingInt(std.c.E.INTR)) continue; + return null; + } + self.reaped = true; + return exitCode(status); + } + + /// Signal the program and whatever it started in its terminal (its process group: `forkpty` + /// made it a session leader). Never once it has been reaped. + fn signal(self: *Pty, sig: std.posix.SIG) bool { + while (!self.reap_lock.tryLock()) std.atomic.spinLoopHint(); + defer self.reap_lock.unlock(); + if (self.reaped or self.child <= 0) return false; + std.posix.kill(-self.child, sig) catch {}; + return true; + } + + fn sleepMs(ms: u32) void { + var ts: std.c.timespec = .{ .sec = 0, .nsec = @as(isize, ms) * std.time.ns_per_ms }; + _ = std.c.nanosleep(&ts, null); + } + + /// A `waitpid` status as a shell reports it: the exit code, or 128 + the signal. + pub fn exitCode(status: c_int) i32 { + const s: u32 = @bitCast(status); + if (s & 0x7f == 0) return @intCast((s >> 8) & 0xff); + return 128 + @as(i32, @intCast(s & 0x7f)); + } + + /// Hang up on the program, and kill it if it is still there a moment later: closing a pane + /// or quitting never waits on a program that ignores the hangup. pub fn deinit(self: *Pty) void { self.stop.store(true, .release); - if (self.child > 0) std.posix.kill(self.child, std.posix.SIG.HUP) catch {}; + if (self.signal(.HUP)) { + var waited: u32 = 0; + while (waited < 200 and !self.isReaped()) : (waited += 10) sleepMs(10); + _ = self.signal(.KILL); + } + if (self.waiter) |t| { + t.join(); + self.waiter = null; + } if (self.thread) |t| { t.join(); self.thread = null; @@ -98,6 +236,12 @@ pub const Pty = struct { } } + fn isReaped(self: *Pty) bool { + while (!self.reap_lock.tryLock()) std.atomic.spinLoopHint(); + defer self.reap_lock.unlock(); + return self.reaped; + } + pub fn write(self: *Pty, bytes: []const u8) void { var off: usize = 0; while (off < bytes.len) { @@ -118,3 +262,91 @@ pub const Pty = struct { _ = ioctl(self.master, TIOCSWINSZ, &ws); } }; + +// ---- tests ---------------------------------------------------------------------------------- + +const testing = std.testing; + +const Captured = struct { + mutex: std.atomic.Mutex = .unlocked, + out: [4096]u8 = undefined, + len: usize = 0, + code: std.atomic.Value(i32) = .init(std.math.minInt(i32)), + + fn onData(ctx: *anyopaque, bytes: []const u8) void { + const self: *Captured = @ptrCast(@alignCast(ctx)); + while (!self.mutex.tryLock()) std.atomic.spinLoopHint(); + defer self.mutex.unlock(); + const n = @min(bytes.len, self.out.len - self.len); + @memcpy(self.out[self.len..][0..n], bytes[0..n]); + self.len += n; + } + + fn onExit(ctx: *anyopaque, code: i32) void { + const self: *Captured = @ptrCast(@alignCast(ctx)); + self.code.store(code, .release); + } + + fn wait(self: *Captured) !i32 { + var tries: usize = 0; + while (tries < 500) : (tries += 1) { + const code = self.code.load(.acquire); + if (code != std.math.minInt(i32)) return code; + var ts: std.c.timespec = .{ .sec = 0, .nsec = 10 * std.time.ns_per_ms }; + _ = std.c.nanosleep(&ts, null); + } + return error.Timeout; + } +}; + +test "a command runs with its arguments, environment and folder, and its exit code comes back" { + var cap: Captured = .{}; + var pty = try Pty.open(&cap, Captured.onData, 80, 24, "/", .{ + // `sh` found on PATH, as a request's bare program name is. + .argv = &.{ "sh", "-c", "printf '%s|%s|%s|' \"$1\" \"$FIZZY_RUN_TEST\" \"$(pwd)\"; exit 3", "sh", "one arg" }, + .env = &.{.{ .name = "FIZZY_RUN_TEST", .value = "set" }}, + }); + pty.on_exit = Captured.onExit; + try pty.startReader(); + defer pty.deinit(); + + try testing.expectEqual(@as(i32, 3), try cap.wait()); + // The exit is reported only once the output is drained. + try testing.expect(std.mem.indexOf(u8, cap.out[0..cap.len], "one arg|set|/|") != null); +} + +test "a program killed by a signal exits as a shell reports it; one not found exits 127" { + var cap: Captured = .{}; + var pty = try Pty.open(&cap, Captured.onData, 80, 24, null, .{ .argv = &.{ "sh", "-c", "kill -TERM $$" } }); + pty.on_exit = Captured.onExit; + try pty.startReader(); + defer pty.deinit(); + try testing.expectEqual(@as(i32, 128 + 15), try cap.wait()); + + var missing: Captured = .{}; + var pty2 = try Pty.open(&missing, Captured.onData, 80, 24, null, .{ .argv = &.{"fizzy-no-such-program"} }); + pty2.on_exit = Captured.onExit; + try pty2.startReader(); + defer pty2.deinit(); + try testing.expectEqual(@as(i32, 127), try missing.wait()); +} + +test "a program's exit is heard though a helper it left keeps the terminal open" { + var cap: Captured = .{}; + var pty = try Pty.open(&cap, Captured.onData, 80, 24, null, .{ .argv = &.{ "sh", "-c", "sleep 30 & exit 4" } }); + pty.on_exit = Captured.onExit; + try pty.startReader(); + try testing.expectEqual(@as(i32, 4), try cap.wait()); + // Closing kills the helper with the program's process group, so this returns. + pty.deinit(); +} + +test "closing never waits on a program that ignores the hangup" { + var cap: Captured = .{}; + var pty = try Pty.open(&cap, Captured.onData, 80, 24, null, .{ .argv = &.{ "sh", "-c", "trap '' HUP; echo ready; sleep 30" } }); + pty.on_exit = Captured.onExit; + try pty.startReader(); + var tries: usize = 0; + while (std.mem.indexOf(u8, cap.out[0..cap.len], "ready") == null and tries < 300) : (tries += 1) Pty.sleepMs(10); + pty.deinit(); +} diff --git a/src/Pty_windows.zig b/src/Pty_windows.zig index eb53c49..ba52c38 100644 --- a/src/Pty_windows.zig +++ b/src/Pty_windows.zig @@ -13,6 +13,14 @@ const w = std.os.windows; pub const supported = true; pub const OnData = *const fn (ctx: *anyopaque, bytes: []const u8) void; +/// The program exited with `code`. Called from the waiter thread. +pub const OnExit = *const fn (ctx: *anyopaque, code: i32) void; + +const Env = @import("Pty.zig").Env; +/// A program to run instead of PowerShell. `argv[0]` is found on `PATH` with `PATHEXT`, as a +/// shell would (an `npx.cmd`, say); a batch file runs through `cmd.exe`, which reads its command +/// line by rules of its own, so an argument it would take as syntax is refused, not escaped. +const Command = @import("Pty.zig").Command; pub const Pty = struct { hpc: ?w.HANDLE = null, @@ -23,6 +31,12 @@ pub const Pty = struct { stop: std.atomic.Value(bool) = .init(false), ctx: *anyopaque = undefined, on_data: OnData = undefined, + /// Called once the program has exited. Set before `startReader`. + on_exit: ?OnExit = null, + /// Waits on the program, for `on_exit`: ConPTY keeps its output pipe open past the exit. + waiter: ?std.Thread = null, + /// The reader has reached the end of the output (rarely before `ClosePseudoConsole`). + drained: std.atomic.Value(bool) = .init(false), const STARTUPINFOEXW = extern struct { StartupInfo: w.STARTUPINFOW, @@ -44,8 +58,24 @@ pub const Pty = struct { extern "kernel32" fn InitializeProcThreadAttributeList(lpAttributeList: ?*anyopaque, dwAttributeCount: w.DWORD, dwFlags: w.DWORD, lpSize: *usize) callconv(.winapi) w.BOOL; extern "kernel32" fn UpdateProcThreadAttribute(lpAttributeList: *anyopaque, dwFlags: w.DWORD, Attribute: usize, lpValue: ?*anyopaque, cbSize: usize, lpPreviousValue: ?*anyopaque, lpReturnSize: ?*usize) callconv(.winapi) w.BOOL; extern "kernel32" fn DeleteProcThreadAttributeList(lpAttributeList: *anyopaque) callconv(.winapi) void; + extern "kernel32" fn GetEnvironmentStringsW() callconv(.winapi) ?[*:0]u16; + extern "kernel32" fn FreeEnvironmentStringsW(penv: [*:0]u16) callconv(.winapi) w.BOOL; + extern "kernel32" fn WaitForSingleObject(hHandle: w.HANDLE, dwMilliseconds: w.DWORD) callconv(.winapi) w.DWORD; + extern "kernel32" fn GetExitCodeProcess(hProcess: w.HANDLE, lpExitCode: *w.DWORD) callconv(.winapi) w.BOOL; + extern "kernel32" fn GetFileAttributesW(lpFileName: [*:0]const u16) callconv(.winapi) w.DWORD; + extern "kernel32" fn Sleep(dwMilliseconds: w.DWORD) callconv(.winapi) void; + const INFINITE: w.DWORD = 0xFFFFFFFF; + const INVALID_FILE_ATTRIBUTES: w.DWORD = 0xFFFFFFFF; + + pub fn open(ctx: *anyopaque, on_data: OnData, cols: u16, rows: u16, cwd: ?[]const u8, command: ?Command) !Pty { + var arena_state: std.heap.ArenaAllocator = .init(std.heap.page_allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + if (command) |c| if (c.argv.len == 0) return error.NoCommand; + // CreateProcessW may scribble on the command line; give it a mutable copy. + const cmdline = try std.unicode.utf8ToUtf16LeAllocZ(arena, if (command) |c| try commandLine(arena, c) else "powershell.exe"); + const env_block: ?[]u16 = if (command) |c| try environment(arena, c.env) else null; - pub fn open(ctx: *anyopaque, on_data: OnData, cols: u16, rows: u16, cwd: ?[]const u8) !Pty { // Pipe pair: we write keystrokes into in_write; ConPTY reads in_read. var in_read: w.HANDLE = undefined; var in_write: w.HANDLE = undefined; @@ -89,9 +119,6 @@ pub const Pty = struct { }; siex.StartupInfo.cb = @sizeOf(STARTUPINFOEXW); - // CreateProcessW may scribble on the command line; give it a mutable copy. - var cmdline = std.unicode.utf8ToUtf16LeStringLiteral("powershell.exe").*; - var cwd_buf: [std.fs.max_path_bytes]u16 = undefined; const cwd_w: ?[:0]const u16 = if (cwd) |c| blk: { const len = std.unicode.utf8ToUtf16Le(cwd_buf[0 .. cwd_buf.len - 1], c) catch break :blk null; @@ -102,12 +129,12 @@ pub const Pty = struct { var pi: w.PROCESS.INFORMATION = undefined; if (w.kernel32.CreateProcessW( null, - @ptrCast(&cmdline), + cmdline.ptr, null, null, .FALSE, - .{ .extended_startupinfo_present = true }, - null, + .{ .extended_startupinfo_present = true, .create_unicode_environment = env_block != null }, + if (env_block) |e| @ptrCast(e.ptr) else null, if (cwd_w) |c| c.ptr else null, &siex.StartupInfo, &pi, @@ -126,9 +153,135 @@ pub const Pty = struct { pub fn startReader(self: *Pty) !void { self.thread = try std.Thread.spawn(.{}, readLoop, .{self}); + if (self.on_exit != null) self.waiter = try std.Thread.spawn(.{}, waitLoop, .{self}); + } + + fn waitLoop(self: *Pty) void { + const p = self.process orelse return; + _ = WaitForSingleObject(p, INFINITE); + if (self.stop.load(.acquire)) return; + var code: w.DWORD = 0; + if (GetExitCodeProcess(p, &code) == .FALSE) return; + // Give the reader a moment for what the program wrote last, so the exit reads after it. + var waited: u32 = 0; + while (!self.drained.load(.acquire) and waited < 250) : (waited += 10) Sleep(10); + if (self.on_exit) |f| f(self.ctx, @bitCast(code)); + } + + /// The command line for `cmd`: its program found on `PATH`, and its arguments quoted as the + /// C runtime reads them; a batch file through `cmd.exe /d /c`. + fn commandLine(arena: std.mem.Allocator, cmd: Command) ![]const u8 { + var out: std.Io.Writer.Allocating = .init(arena); + const wr = &out.writer; + const path_value = for (cmd.env) |e| { + if (std.ascii.eqlIgnoreCase(e.name, "PATH")) break e.value; + } else getenv(arena, "PATH"); + const program = try find(arena, cmd.argv[0], path_value); + const batch = std.ascii.endsWithIgnoreCase(program, ".cmd") or std.ascii.endsWithIgnoreCase(program, ".bat"); + if (batch) { + // cmd.exe expands %VAR% and takes & | < > ^ ( ) as syntax, inside quotes or not as + // the quotes fall; no escaping holds for every argument, so those are refused. + for (cmd.argv) |arg| if (std.mem.indexOfAny(u8, arg, "%&|<>^()!\"\r\n") != null) return error.UnsafeBatchArgument; + try wr.writeAll("cmd.exe /d /c \""); + } + try quote(wr, program); + for (cmd.argv[1..]) |arg| { + try wr.writeByte(' '); + try quote(wr, arg); + } + if (batch) try wr.writeByte('"'); + return out.written(); + } + + /// `name` on `path_value` with each of `PATHEXT`'s extensions, as cmd.exe finds a program; + /// `name` itself when it names a path or is not found (CreateProcessW then says so). + fn find(arena: std.mem.Allocator, name: []const u8, path_value: ?[]const u8) ![]const u8 { + if (std.mem.indexOfAny(u8, name, "\\/:") != null) return name; + const exts = getenv(arena, "PATHEXT") orelse ".COM;.EXE;.BAT;.CMD"; + var dirs = std.mem.splitScalar(u8, path_value orelse return name, ';'); + while (dirs.next()) |dir| { + if (dir.len == 0) continue; + var it = std.mem.splitScalar(u8, exts, ';'); + while (it.next()) |ext| { + if (ext.len == 0) continue; + const candidate = try std.fmt.allocPrint(arena, "{s}\\{s}{s}", .{ dir, name, ext }); + const wide = try std.unicode.utf8ToUtf16LeAllocZ(arena, candidate); + if (GetFileAttributesW(wide.ptr) != INVALID_FILE_ATTRIBUTES) return candidate; + } + } + return name; + } + + fn getenv(arena: std.mem.Allocator, name: []const u8) ?[]const u8 { + const block = GetEnvironmentStringsW() orelse return null; + defer _ = FreeEnvironmentStringsW(block); + var p: [*:0]const u16 = block; + while (p[0] != 0) { + const entry = std.mem.span(p); + p += entry.len + 1; + const eq = std.mem.indexOfScalarPos(u16, entry, 1, '=') orelse continue; + const key = std.unicode.utf16LeToUtf8Alloc(arena, entry[0..eq]) catch continue; + if (!std.ascii.eqlIgnoreCase(key, name)) continue; + return std.unicode.utf16LeToUtf8Alloc(arena, entry[eq + 1 ..]) catch null; + } + return null; + } + + fn quote(wr: *std.Io.Writer, arg: []const u8) !void { + if (arg.len > 0 and std.mem.indexOfAny(u8, arg, " \t\"") == null) return wr.writeAll(arg); + try wr.writeByte('"'); + var backslashes: usize = 0; + for (arg) |ch| { + if (ch == '\\') { + backslashes += 1; + continue; + } + const n = if (ch == '"') backslashes * 2 + 1 else backslashes; + try wr.splatByteAll('\\', n); + backslashes = 0; + try wr.writeByte(ch); + } + try wr.splatByteAll('\\', backslashes * 2); + try wr.writeByte('"'); + } + + /// The app's environment with `TERM` set, under `overrides`, as the double-null-terminated + /// UTF-16 block `CreateProcessW` takes. Names compare without case, as Windows does. + fn environment(arena: std.mem.Allocator, overrides: []const Env) ![]u16 { + var out: std.ArrayListUnmanaged(u16) = .empty; + if (GetEnvironmentStringsW()) |block| { + defer _ = FreeEnvironmentStringsW(block); + var p: [*:0]const u16 = block; + while (p[0] != 0) { + const entry = std.mem.span(p); + p += entry.len + 1; + const eq = std.mem.indexOfScalarPos(u16, entry, 1, '=') orelse continue; + const name = try std.unicode.utf16LeToUtf8Alloc(arena, entry[0..eq]); + if (overridden(overrides, name) or std.ascii.eqlIgnoreCase(name, "TERM")) continue; + try out.appendSlice(arena, entry); + try out.append(arena, 0); + } + } + if (!overridden(overrides, "TERM")) { + try out.appendSlice(arena, std.unicode.utf8ToUtf16LeStringLiteral("TERM=xterm-256color")); + try out.append(arena, 0); + } + for (overrides) |e| { + const line = try std.fmt.allocPrint(arena, "{s}={s}", .{ e.name, e.value }); + try out.appendSlice(arena, try std.unicode.utf8ToUtf16LeAlloc(arena, line)); + try out.append(arena, 0); + } + try out.append(arena, 0); + return out.items; + } + + fn overridden(overrides: []const Env, name: []const u8) bool { + for (overrides) |e| if (std.ascii.eqlIgnoreCase(e.name, name)) return true; + return false; } fn readLoop(self: *Pty) void { + defer self.drained.store(true, .release); const out_read = self.out_read orelse return; var buf: [4096]u8 = undefined; while (!self.stop.load(.acquire)) { @@ -152,6 +305,10 @@ pub const Pty = struct { t.join(); self.thread = null; } + if (self.waiter) |t| { + t.join(); + self.waiter = null; + } if (self.out_read) |h| w.CloseHandle(h); self.out_read = null; if (self.in_write) |h| w.CloseHandle(h); diff --git a/src/Run.zig b/src/Run.zig new file mode 100644 index 0000000..47845c5 --- /dev/null +++ b/src/Run.zig @@ -0,0 +1,171 @@ +//! One `terminal.run`: a program another plugin asked for (the chat's `claude /login`), in a +//! pane of its own beside the shell, which the person types into. When the program exits, the +//! caller's `done` command hears the code, and the pane stays with the exit shown until the +//! person closes it, so what the program said last can still be read. +//! +//! Lives on the GUI thread except where marked: the program is found on the login shell's +//! `PATH` off it (that resolution can take seconds the first time), and the PTY reader reports +//! the exit from its own thread. `poll` (each `endFrame`) carries both out. +const std = @import("std"); +const dvui = @import("dvui"); +const sdk = @import("fizzy_sdk"); +const State = @import("State.zig"); +const Terminal = @import("Terminal.zig"); +const Pty = @import("Pty.zig"); +const launch = @import("launch.zig"); +const render = @import("render.zig"); + +const Run = @This(); + +/// The terminal and its PTY, drawn as the shell's are. `pty` is null until the program starts. +state: State = .{}, +/// The surface id, `ghostty.run.`, and its title. +id: []u8, +title: []u8, +/// The request, kept in `arena` until the program has started. +arena: std.heap.ArenaAllocator, +request: launch.Request, +/// Off the GUI thread: the login shell's `PATH` (`core.shell_env`), then `resolved` set. +resolver: ?std.Thread = null, +login_path: ?[]const u8 = null, +resolved: std.atomic.Value(bool) = .init(false), +/// Reader thread: the exit code, once the program has gone. +code: std.atomic.Value(i32) = .init(running), +/// `done` has been told (`tell`). +reported: bool = false, +/// The person closed the pane: `poll`'s caller takes it away. +closing: bool = false, + +const running = std.math.minInt(i32); +/// What `done` hears when the program could not be started at all, as a shell says it. +const not_started = 127; + +/// A pane named `id` (taken) for `req`. The program runs once `begin` has found its `PATH`. +pub fn create(gpa: std.mem.Allocator, req: launch.Request, id: []u8) !*Run { + const self = try gpa.create(Run); + errdefer gpa.destroy(self); + self.* = .{ .id = id, .title = &.{}, .arena = .init(gpa), .request = undefined }; + errdefer self.arena.deinit(); + const a = self.arena.allocator(); + // The caller's strings live in its arena, gone after the call. + const env = try a.alloc(launch.Env, req.env.len); + for (req.env, env) |e, *out| out.* = .{ .name = try a.dupe(u8, e.name), .value = try a.dupe(u8, e.value) }; + const argv = try a.alloc([]const u8, req.argv.len); + for (req.argv, argv) |arg, *out| out.* = try a.dupe(u8, arg); + self.request = .{ + .title = "", + .argv = argv, + .env = env, + .cwd = try a.dupe(u8, req.cwd), + .done = try a.dupe(u8, req.done), + .tag = req.tag, + }; + self.title = try gpa.dupe(u8, if (req.title.len > 0) req.title else req.argv[0]); + errdefer gpa.free(self.title); + // Drawn before the program starts; `poll` gives the PTY whatever size the pane made it. + self.state.terminal = try Terminal.init(80, 24); + return self; +} + +/// Start looking for the program, once its pane has a place: until then nothing has started +/// that `destroy` would wait on. +pub fn begin(self: *Run) !void { + self.resolver = try std.Thread.spawn(.{}, resolve, .{self}); +} + +fn resolve(self: *Run) void { + self.login_path = sdk.core.shell_env.path(std.heap.page_allocator, dvui.io); + self.resolved.store(true, .release); + sdk.refresh(); +} + +/// GUI thread, each frame: start the program once its `PATH` is known, and report its exit. +pub fn poll(self: *Run) void { + if (self.state.pty == null and self.code.load(.acquire) == running and self.resolved.load(.acquire)) self.start(); + const code = self.code.load(.acquire); + if (code == running or self.reported) return; + var buf: [64]u8 = undefined; + const line = std.fmt.bufPrint(&buf, "\r\n\x1b[2m[exited with code {d}]\x1b[0m\r\n\x1b[?25l", .{code}) catch ""; + if (self.state.terminal != null) State.feed(&self.state, line); + self.tell(code); +} + +/// What `done` hears when the pane goes before the program does (closed, or the plugin +/// unloaded): it was hung up on, as a terminal's close does. +pub const hung_up = 128 + 1; + +/// Call the caller's `done` with the exit, once. Unless it has gone (its plugin unloaded first). +pub fn tell(self: *Run, code: i32) void { + if (self.reported) return; + self.reported = true; + const host = sdk.host(); + if (!host.hasCommand(self.request.done)) return; + const arena = host.arena(); + const args = launch.write(arena, launch.Exited{ .tag = self.request.tag, .code = code }) catch return; + switch (host.callCommand(self.request.done, args, arena)) { + .ok => {}, + else => |o| dvui.log.warn("ghostty: {s} refused the exit of {s}: {any}", .{ self.request.done, self.title, o }), + } +} + +fn start(self: *Run) void { + const a = self.arena.allocator(); + const env = launch.environment(a, self.request, self.login_path) catch &.{}; + const cwd: ?[]const u8 = if (self.request.cwd.len > 0) self.request.cwd else sdk.host().folder() orelse render.defaultCwd(); + const term = &self.state.terminal.?; + self.state.lock(); + const cols = term.cols; + const rows = term.rows; + self.state.unlock(); + + self.state.pty = Pty.Pty.open(&self.state, State.feed, cols, rows, cwd, .{ .argv = self.request.argv, .env = env }) catch |err| { + var buf: [256]u8 = undefined; + State.feed(&self.state, std.fmt.bufPrint(&buf, "Could not start {s}: {t}\r\n", .{ self.request.argv[0], err }) catch ""); + self.code.store(not_started, .release); + return; + }; + self.state.pty.?.on_exit = exited; + self.state.pty.?.startReader() catch |err| { + dvui.log.warn("ghostty: no reader for {s}: {t}", .{ self.title, err }); + self.state.pty.?.deinit(); + self.state.pty = null; + self.code.store(not_started, .release); + }; +} + +/// Reader thread: the program exited. +fn exited(ctx: *anyopaque, code: i32) void { + const state: *State = @ptrCast(@alignCast(ctx)); + const self: *Run = @fieldParentPtr("state", state); + self.code.store(code, .release); + sdk.refresh(); +} + +/// The pane: a bar with what runs, how it ended, and a Close; then the terminal. +pub fn draw(ctx: ?*anyopaque) anyerror!dvui.App.Result { + const self: *Run = @ptrCast(@alignCast(ctx orelse return .ok)); + var outer = dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .both }); + defer outer.deinit(); + { + var bar = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .horizontal, .padding = .all(4) }); + defer bar.deinit(); + const code = self.code.load(.acquire); + if (code == running) + dvui.label(@src(), "{s}", .{self.title}, .{ .gravity_y = 0.5 }) + else + dvui.label(@src(), "{s} — exited with code {d}", .{ self.title, code }, .{ .gravity_y = 0.5 }); + // Closing a running program hangs up on it, and its caller hears so. + if (dvui.button(@src(), "Close", .{}, .{ .gravity_x = 1, .gravity_y = 0.5 })) self.closing = true; + } + try render.drawTerminal(&self.state); + return .ok; +} + +pub fn destroy(self: *Run, gpa: std.mem.Allocator) void { + if (self.resolver) |t| t.join(); + self.state.deinit(gpa); + self.arena.deinit(); + gpa.free(self.title); + gpa.free(self.id); + gpa.destroy(self); +} diff --git a/src/launch.zig b/src/launch.zig new file mode 100644 index 0000000..656b8b3 --- /dev/null +++ b/src/launch.zig @@ -0,0 +1,123 @@ +//! `terminal.run`: the seam another plugin calls to run a program in a terminal the person types +//! into (the chat's sign-in, `claude /login`), and the call back when it exits. Plugins meet only +//! at the host's commands, so this is the contract written down on this side; the chat writes the +//! same shape (`auth.TerminalRun`, `auth.Exited` in fizzyedit/chat's `src/auth.zig`). Std-only, +//! so its tests run without a window. +const std = @import("std"); + +/// The command's id: a seam named for what it does, not for this plugin, so any terminal plugin +/// can offer it. +pub const command_id = "terminal.run"; + +/// One variable over the app's environment (the PTY's own type, so a request passes straight through). +pub const Env = @import("Pty.zig").Env; + +/// `terminal.run`'s arguments: run `argv` in `cwd` (empty: the project folder), over the app's +/// environment plus `env`, in a pane titled `title`; when it exits, call command `done` with +/// `Exited{ .tag = tag, .code = … }`. +pub const Request = struct { + title: []const u8, + argv: []const []const u8, + env: []const Env = &.{}, + cwd: []const u8 = "", + done: []const u8, + tag: u64 = 0, +}; + +/// What `done` is called with. +pub const Exited = struct { tag: u64 = 0, code: i32 }; + +/// The arguments, in `arena`. Fields this plugin does not know yet are left alone, so a caller +/// can add one without breaking an older terminal. +pub fn parse(arena: std.mem.Allocator, zon: []const u8) !Request { + const src = try arena.dupeSentinel(u8, std.mem.trim(u8, zon, " \t\r\n"), 0); + var diag: std.zon.parse.Diagnostics = undefined; + const req = try std.zon.parse.fromSlice(Request, .{ + .gpa = arena, + .arena = arena, + .source = src, + .diagnostics = &diag, + .ignore_unknown_fields = true, + }); + if (req.argv.len == 0 or req.argv[0].len == 0) return error.NoProgram; + if (req.done.len == 0) return error.NoDoneCommand; + return req; +} + +/// `Exited` as `done`'s arguments. +pub fn write(arena: std.mem.Allocator, value: anytype) ![]const u8 { + var out: std.Io.Writer.Allocating = .init(arena); + try std.zon.stringify.serialize(value, .{ .whitespace = false, .emit_default_optional_fields = false }, &out.writer); + return out.written(); +} + +/// The environment the program gets over the app's: the login shell's `PATH` first (a fizzy +/// started from the Dock has only the system's, and the program, `npx` say, is on the person's), +/// then the request's own, which win. +pub fn environment(arena: std.mem.Allocator, req: Request, login_path: ?[]const u8) ![]const Env { + var out: std.ArrayListUnmanaged(Env) = .empty; + const own_path = for (req.env) |e| { + if (std.mem.eql(u8, e.name, "PATH")) break true; + } else false; + if (login_path) |p| if (!own_path) try out.append(arena, .{ .name = "PATH", .value = p }); + try out.appendSlice(arena, req.env); + return out.items; +} + +const testing = std.testing; + +test "the request reads as the chat writes it, and a later field is ignored" { + var arena_state: std.heap.ArenaAllocator = .init(testing.allocator); + defer arena_state.deinit(); + const a = arena_state.allocator(); + + const sent: Request = .{ + .title = "Sign in: Claude", + .argv = &.{ "claude", "/login" }, + .env = &.{.{ .name = "LOGIN_MODE", .value = "interactive" }}, + .cwd = "/tmp", + .done = "agent.terminal_done", + .tag = 7, + }; + const req = try parse(a, try write(a, sent)); + try testing.expectEqualStrings("Sign in: Claude", req.title); + try testing.expectEqual(@as(usize, 2), req.argv.len); + try testing.expectEqualStrings("/login", req.argv[1]); + try testing.expectEqualStrings("LOGIN_MODE", req.env[0].name); + try testing.expectEqualStrings("interactive", req.env[0].value); + try testing.expectEqualStrings("agent.terminal_done", req.done); + try testing.expectEqual(@as(u64, 7), req.tag); + + const newer = try parse(a, ".{ .title = \"t\", .argv = .{\"sh\"}, .done = \"x.done\", .shell = true }"); + try testing.expectEqualStrings("", newer.cwd); + try testing.expectEqual(@as(usize, 0), newer.env.len); + + try testing.expectError(error.NoProgram, parse(a, ".{ .title = \"t\", .argv = .{}, .done = \"x.done\" }")); + try testing.expectError(error.NoDoneCommand, parse(a, ".{ .title = \"t\", .argv = .{\"sh\"}, .done = \"\" }")); +} + +test "the exit goes back as the chat reads it" { + var arena_state: std.heap.ArenaAllocator = .init(testing.allocator); + defer arena_state.deinit(); + const a = arena_state.allocator(); + const zon = try write(a, Exited{ .tag = 7, .code = -1 }); + const src = try a.dupeSentinel(u8, zon, 0); + var diag: std.zon.parse.Diagnostics = undefined; + const back = try std.zon.parse.fromSlice(Exited, .{ .gpa = a, .arena = a, .source = src, .diagnostics = &diag }); + try testing.expectEqual(@as(u64, 7), back.tag); + try testing.expectEqual(@as(i32, -1), back.code); +} + +test "the login PATH goes under the request's environment" { + var arena_state: std.heap.ArenaAllocator = .init(testing.allocator); + defer arena_state.deinit(); + const a = arena_state.allocator(); + var req: Request = .{ .title = "", .argv = &.{"x"}, .done = "d", .env = &.{.{ .name = "A", .value = "1" }} }; + const env = try environment(a, req, "/opt/bin:/usr/bin"); + try testing.expectEqualStrings("PATH", env[0].name); + try testing.expectEqualStrings("A", env[1].name); + req.env = &.{.{ .name = "PATH", .value = "/mine" }}; + const own = try environment(a, req, "/opt/bin"); + try testing.expectEqual(@as(usize, 1), own.len); + try testing.expectEqualStrings("/mine", own[0].value); +} diff --git a/src/render.zig b/src/render.zig index 36a1a0e..fb4a9db 100644 --- a/src/render.zig +++ b/src/render.zig @@ -17,7 +17,7 @@ const input = @import("input.zig"); /// what a normal terminal app opens to. Without this, `Pty.open` falls back to whatever cwd /// the app process itself inherited, which for a packaged `.app` launched from Finder/Dock is /// `/` rather than `~`. Null if the relevant env var isn't set. -fn defaultCwd() ?[]const u8 { +pub fn defaultCwd() ?[]const u8 { const name = if (builtin.target.os.tag == .windows) "USERPROFILE" else "HOME"; const value = std.c.getenv(name) orelse return null; return std.mem.span(value); @@ -50,7 +50,7 @@ pub fn drawTerminal(ctx: ?*anyopaque) anyerror!void { // Opens the shell in the current project folder (matching VSCode's integrated // terminal) rather than wherever the app itself was launched from — see // `Pty.open`'s doc comment. Falls back to the home directory when no project is open. - state.pty = try Pty.open(state, State.feed, grid.cols, grid.rows, sdk.host().folder() orelse defaultCwd()); + state.pty = try Pty.open(state, State.feed, grid.cols, grid.rows, sdk.host().folder() orelse defaultCwd(), null); state.pty.?.setSize(grid.cols, grid.rows, grid.cell_w_px, grid.cell_h_px); try state.pty.?.startReader(); } diff --git a/src/tests.zig b/src/tests.zig new file mode 100644 index 0000000..74020ce --- /dev/null +++ b/src/tests.zig @@ -0,0 +1,7 @@ +//! The tests that need no window: `zig build test`. +const builtin = @import("builtin"); + +test { + _ = @import("launch.zig"); + if (builtin.target.os.tag != .windows) _ = @import("Pty_unix.zig"); +}