diff --git a/CLAUDE.md b/CLAUDE.md index ece0115f..2bb6d7c3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -188,6 +188,9 @@ const result = grid_row * GRID_COLS + grid_col; // usize, works correctly - `std.process.Child` keeps only `kill(io)` and `wait(io)`. Creation is `std.process.spawn(io, opts)`; the collect-output pattern is `std.process.run(gpa, io, opts)`. `Child.Term` tags are lowercase (`.exited`, `.signal`, `.stopped`, `.unknown`) and `signal` carries `std.posix.SIG`, not `u32`. Use `src/proc.zig` for the application process helpers. - Link and include configuration lives on `std.Build.Module`, not `std.Build.Step.Compile`. `link_libc` is a Module field, settable in `b.createModule`. - `std.posix.getenv` is gone. Read the environment through `src/env.zig`. +- `std.fmt.bufPrintZ` is deprecated; use `std.fmt.bufPrintSentinel(buf, fmt, args, 0)`. +- Default `std.heap.DebugAllocator` initialization is deprecated; prefer `init.gpa` from `main` or the `.init` declaration when a local allocator is unavoidable. +- Tests use `std.testing.io` rather than constructing `std.Io.Threaded` instances. ### Inventory greps for std API migrations `const fs = std.fs;` and `const posix = std.posix;` aliases hide call sites from a `std.`-qualified grep — this cost real time during the 0.16 migration. Always match `\b(std\.)?fs\.` and `\b(std\.)?posix\.`. When excluding survivors, put the underscore in the character class: `fs\.[A-Za-z]+` truncates `fs.max_path_bytes` to `fs.max` and lets it slip past the filter. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index e0f3d2d6..545dca84 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -2,7 +2,7 @@ ## System Overview -Architect is a **single-process, layered desktop application** built in Zig that functions as a grid-based terminal multiplexer optimized for multi-agent AI coding workflows. It follows a five-layer architecture: a thin entrypoint delegates to an application runtime that owns the frame loop, platform abstraction (SDL3), session management (PTY + ghostty-vt terminal emulation), scene rendering, and a component-based UI overlay system. The UI and terminal loop run on the main thread; background threads are used only for bounded auxiliary work (notification socket listener, local control socket listener, a PTY reader thread that drains session output into per-session ring buffers at wire speed, and quit-time agent-teardown worker). The frame loop uses a wakeable wait model: both idle and active-frame pacing block in SDL until either a wake-worthy event arrives or the relevant deadline expires, so PTY output, keystrokes, notification/control socket activity, and window events all interrupt the wait immediately instead of waiting out a fixed timeout. The application uses an action-queue pattern for UI-to-app mutations, request queues for external socket inputs, epoch-based cache invalidation for efficient rendering, and a vtable-based component registry for extensible UI overlays. Renderer cache entries are reused for both grid tiles and the steady-state full-screen terminal view; overlays remain live unless an effect needs them baked into the cached texture. +Architect is a **single-process, layered desktop application** built in Zig that functions as a grid-based terminal multiplexer optimized for multi-agent AI coding workflows. It follows a five-layer architecture: a thin entrypoint delegates to an application runtime that owns the frame loop, platform abstraction (SDL3), session management (PTY + ghostty-vt terminal emulation), scene rendering, and a component-based UI overlay system. The UI and terminal loop run on the main thread; background threads are used only for bounded auxiliary work (notification socket listener, local control socket listener, a PTY reader thread that drains session output into per-session ring buffers at wire speed, a bounded URL opener worker set, and quit-time agent-teardown worker). The frame loop uses a wakeable wait model: both idle and active-frame pacing block in SDL until either a wake-worthy event arrives or the relevant deadline expires, so PTY output, keystrokes, notification/control socket activity, and window events all interrupt the wait immediately instead of waiting out a fixed timeout. The application uses an action-queue pattern for UI-to-app mutations, request queues for external socket inputs, epoch-based cache invalidation for efficient rendering, and a vtable-based component registry for extensible UI overlays. Renderer cache entries are reused for both grid tiles and the steady-state full-screen terminal view; overlays remain live unless an effect needs them baked into the cached texture. ## Component Diagram @@ -91,8 +91,8 @@ Platform Session Rendering UI Overlay **Invariants:** - Session, Rendering, and UI Overlay layers never import from each other directly. All cross-layer communication flows through the Application layer or shared types. - UI components communicate with the application exclusively via the `UiAction` queue (never direct state mutation). -- `main(init: std.process.Init)` passes `init.io` to `runtime.run(io, ...)`, which threads it through the application, session, and UI layers. I/O-owning structs store it beside their allocator; worker contexts copy it when their thread outlives the spawner. -- Background threads are intentionally limited to four cases: the notification socket listener (`session/notify.zig`), the local control socket listener (`app/control.zig`), the PTY reader (`session/pty_reader.zig`), and a quit-time agent-teardown worker in `app/runtime.zig`. They communicate completion/state back to the main thread through thread-safe primitives. The notification listener and control listener block in `poll(2)` on the listening socket plus a `WakePipe` self-pipe (`src/wake_pipe.zig`); shutdown stores the stop flag and signals the pipe, so no thread ever sleeps in a fixed-interval loop. The notification listener, control listener, and PTY reader also post a custom SDL wake event after queueing work or draining PTY bytes, so the frame loop breaks out of `SDL_WaitEventTimeout(...)` promptly during both idle and active-frame pacing. The PTY reader blocks in `poll(2)` on the master fds of all spawned sessions plus its `WakePipe`, which `register`/`retire` and shutdown signal; when one becomes readable, it drains it into that session's mutex-guarded ring buffer (`PtyOutputBuffer`, 1 MiB) — so producer processes are never backpressured by render pacing, and DEC-2026 sync windows close in the buffer as fast as the producer writes them. Sessions register their fd+buffer on spawn and retire it during teardown; reads happen only under the registry mutex, so `retire()` returning guarantees the reader can no longer touch the fd or buffer. The main thread's `processOutput` consumes from the buffer (VT parsing stays main-thread-only) and clears a shared `wake_pending` flag at the top of each frame; the reader posts at most one SDL wake event per frame via that flag. +- `main(init: std.process.Init)` passes `init.gpa` and `init.io` to `runtime.run(allocator, io, ...)`, which threads them through the application, session, and UI layers. I/O-owning structs store them together; worker contexts copy `io` when their thread outlives the spawner. +- Background threads are intentionally limited to five cases: the notification socket listener (`session/notify.zig`), the local control socket listener (`app/control.zig`), the PTY reader (`session/pty_reader.zig`), the bounded URL opener worker set (`os/open.zig`), and a quit-time agent-teardown worker in `app/runtime.zig`. The URL opener joins its active workers during runtime shutdown. The notification listener and control listener block in `poll(2)` on the listening socket plus a `WakePipe` self-pipe (`src/wake_pipe.zig`); shutdown stores the stop flag and signals the pipe, so no thread ever sleeps in a fixed-interval loop. The notification listener, control listener, and PTY reader also post a custom SDL wake event after queueing work or draining PTY bytes, so the frame loop breaks out of `SDL_WaitEventTimeout(...)` promptly during both idle and active-frame pacing. The PTY reader blocks in `poll(2)` on the master fds of all spawned sessions plus its `WakePipe`, which `register`/`retire` and shutdown signal; when one becomes readable, it drains it into that session's mutex-guarded ring buffer (`PtyOutputBuffer`, 1 MiB) — so producer processes are never backpressured by render pacing, and DEC-2026 sync windows close in the buffer as fast as the producer writes them. Sessions register their fd+buffer on spawn and retire it during teardown; reads happen only under the registry mutex, so `retire()` returning guarantees the reader can no longer touch the fd or buffer. The main thread's `processOutput` consumes from the buffer (VT parsing stays main-thread-only) and clears a shared `wake_pending` flag at the top of each frame; the reader posts at most one SDL wake event per frame via that flag. - Shutdown order is UI-first for teardown dependencies: `UiRoot.deinit()` runs before session teardown so components that reference sessions are released while session memory is still valid. - Runtime uses a one-shot teardown guard around UI cleanup so mixed `errdefer`/`defer` error unwind paths cannot deinitialize `UiRoot` twice. - Runtime persistence is updated during the frame loop when runtime state changes (cwd changes, terminal spawn/despawn, window move/resize, font size changes), and finalization is explicit at the end of `app/runtime.zig`: final save and deinit `Persistence` before deferred subsystem teardown begins. Every change site only marks a dirty flag and records the time it first became dirty (`markPersistenceDirty`); the actual TOML write happens at most once per frame and only once the dirty state is at least 500ms old (`shouldSavePersistenceNow`), so a window drag or resize does not trigger a synchronous file write per mouse tick. The dirty timestamp is set once per dirty period (not refreshed by later changes), which caps the deferral at the debounce window even under continuous events. @@ -139,10 +139,10 @@ These patterns are mandatory for all new code. They are derived from the archite ``` main(init: std.process.Init) - | init.io + | init.gpa, init.io v -runtime.run(io, ...) - | explicit parameter or stored field +runtime.run(allocator, io, ...) + | explicit parameters or stored fields +--> application and session layers +--> UI components that perform I/O +--> copied into worker-thread contexts @@ -482,7 +482,7 @@ Rotate: rename active file to architect-.log and continue in new |--------|---------------|----------------------------------|--------------| | `main.zig` | Thin entrypoint + global logging hook registration | `main(init: std.process.Init)`, `std_options.logFn` | `app/runtime`, `logging` | | `mcp/main.zig` | Separate `architect-mcp` stdio MCP server. Handles JSON-RPC lifecycle methods and exposes the single `spawn_session` tool. | `main(init: std.process.Init)`, `run()` | `app/control` module import, std | -| `app/runtime.zig` | Application lifetime, frame loop, session spawning, config persistence, logging lifecycle/view-transition markers | `run(io, ...)`, frame loop internals | `platform/sdl`, `session/state`, `render/renderer`, `ui/root`, `config`, `logging`, all `app/*` modules | +| `app/runtime.zig` | Application lifetime, frame loop, session spawning, config persistence, logging lifecycle/view-transition markers | `run(allocator, io, ...)`, frame loop internals | `platform/sdl`, `session/state`, `render/renderer`, `ui/root`, `config`, `logging`, all `app/*` modules | | `app/frame_schedule.zig` | Pure change-driven frame scheduling policy: render demand classification, output cadence, timer deadlines, occlusion handling, and SDL wait timeout conversion | `Demand`, `Input`, `Schedule`, `schedule()`, `waitTimeoutMs()` | std | | `app/control.zig` | Local control channel shared by the app and `architect-mcp`: spawn request schema, discovery file, Unix socket listener, request queue, and response serialization | `SpawnRequest`, `SpawnResponse`, `SpawnQueue`, `startControlThread()`, `connectAndSendSpawnRequest()` | std (socket, thread, JSON) | | `app/terminal_history.zig` | Extract focused terminal scrollback + viewport text, strip ANSI escape sequences, convert OSC 133 prompt markers into reader-friendly prompt marker lines, and extract agent session IDs from PTY output for resumption | `extractSessionText()`, `extractTerminalText()`, `stripAnsiAlloc()`, `extractAgentSessionId()`, `buildResumeCommand()` | `session/state`, `ghostty-vt`, std | @@ -499,6 +499,7 @@ Rotate: rename active file to architect-.log and continue in new | `font.zig` + `font_cache.zig` | Font rendering, HarfBuzz shaping, glyph LRU cache, shared font cache | `Font`, `openFont()`, `renderGlyph()`, `FontCache`, `getOrCreate()` | `font_paths`, `c` (SDL3_ttf) | | `gfx/*` (box_drawing, primitives) | Procedural box-drawing characters (U+2500-U+257F), rounded/thick border helpers, bezier arrow rendering | `renderBoxDrawing()`, `drawRoundedRect()`, `drawThickBorder()`, `fillRoundedRect()`, `renderBezierArrow()` | `c` | | `env.zig`, `clock.zig`, `proc.zig` | Process-environment access, I/O-aware timestamps/sleep, and I/O-aware process execution helpers | `get()`, `now*()`, `sleepNanos()`, `run()`, `spawnDetached()` | std | +| `os/open.zig` | Bounded, joinable URL-opening worker owner | `Opener`, `open()`, `deinit()` | `proc`, std | | `ui/root.zig` | UI component registry, z-index dispatch, action drain | `UiRoot`, `register()`, `handleEvent()`, `update()`, `render()`, `needsFrame()` | `ui/component`, `ui/types` | | `ui/component.zig` | UI component vtable interface | `UiComponent`, `VTable` (handleEvent, update, render, hitTest, wantsFrame, deinit) | `ui/types`, `c` | | `ui/types.zig` | Shared UI type definitions, including per-session attention state needed by grid chrome | `UiHost`, `UiAction`, `UiActionQueue`, `UiAssets`, `SessionUiInfo` | `app/app_state`, `colors`, `font`, `geom` | @@ -521,7 +522,7 @@ Rotate: rename active file to architect-.log and continue in new | `ui/components/pr_dropdown_fetch.zig` | `gh pr list` process execution, bounded diagnostic previews, ANSI normalization, and JSON parsing | `runGhPrList()`, `parseGhJson()` | `pr_dropdown_model`, std | | `ui/components/pr_dropdown_view.zig` | Pull request pill and dropdown rendering, cached textures, label truncation/highlighting, and render-state projection | `Cache`, `RenderState`, `ensureCache()`, `renderGlyph()`, `renderOverlay()` | `pr_dropdown_model`, `flowing_line`, `search_utils`, `font_cache`, `geom`, `ui/types`, `ui/text_edit`, `colors`, `dpi`, `c` | | `logging.zig` | File-backed structured logger with runtime level filtering and size-based rotation | `init()`, `deinit()`, `logFn()`, `writeEvent()`, `writeStartupMarker()`, `writeShutdownMarker()` | std | -| Shared Utilities (`geom`, `colors`, `dpi`, `config`, `logging`, `metrics`, `url_matcher`, `os/open`, `anim/easing`) | Geometry primitives, theme/palette management, DPI scaling helpers, TOML config loading/persistence, file-backed logging, performance metrics (including frame-loop and cache-refresh counters), URL detection, cross-platform URL opening, easing functions | `Rect`, `Theme`, `Config`, `logFn`, `Metrics`, `dpi.scale()`, `matchUrl()`, `open()`, `easeInOutCubic()`, `easeOutCubic()` | std, zig-toml, `c` | +| Shared Utilities (`geom`, `colors`, `dpi`, `config`, `logging`, `metrics`, `url_matcher`, `anim/easing`) | Geometry primitives, theme/palette management, DPI scaling helpers, TOML config loading/persistence, file-backed logging, performance metrics (including frame-loop and cache-refresh counters), URL detection, easing functions | `Rect`, `Theme`, `Config`, `logFn`, `Metrics`, `dpi.scale()`, `matchUrl()`, `easeInOutCubic()`, `easeOutCubic()` | std, zig-toml, `c` | ## Key Architectural Decisions diff --git a/src/app/control.zig b/src/app/control.zig index 65125ef2..c1acc3ef 100644 --- a/src/app/control.zig +++ b/src/app/control.zig @@ -1030,9 +1030,7 @@ test "SpawnQueue drains queued requests" { test "control thread stops promptly when signaled while blocked in poll" { const allocator = std.testing.allocator; - var threaded: std.Io.Threaded = .init(allocator, .{}); - defer threaded.deinit(); - const io = threaded.io(); + const io = std.testing.io; std.Io.Dir.cwd().createDirPath(io, ".tmp") catch |err| switch (err) { error.PathAlreadyExists => {}, diff --git a/src/app/runtime.zig b/src/app/runtime.zig index 7612ba71..68ec3b7f 100644 --- a/src/app/runtime.zig +++ b/src/app/runtime.zig @@ -1418,11 +1418,7 @@ fn startQuitFlow( return false; } -pub fn run(io: std.Io, log_dir_override: ?[]const u8) !void { - var gpa = std.heap.DebugAllocator(.{}){}; - defer _ = gpa.deinit(); - const allocator = gpa.allocator(); - +pub fn run(allocator: std.mem.Allocator, io: std.Io, log_dir_override: ?[]const u8) !void { // Socket listener relays external "awaiting approval / done" signals from // shells (or other tools) into the UI thread without blocking rendering. var notify_queue = NotificationQueue{}; @@ -1599,6 +1595,8 @@ pub fn run(io: std.Io, log_dir_override: ?[]const u8) !void { pty_reader_wake.signal(); pty_reader_thread.join(); } + var opener = open_url.Opener.init(allocator, io); + defer opener.deinit(); var text_input_active = true; var input_source_tracker = macos_input.InputSourceTracker.init(); defer input_source_tracker.deinit(); @@ -1797,7 +1795,7 @@ pub fn run(io: std.Io, log_dir_override: ?[]const u8) !void { pending_sends.deinit(allocator); } - const session_interaction_component = try ui_mod.SessionInteractionComponent.init(allocator, sessions, &font); + const session_interaction_component = try ui_mod.SessionInteractionComponent.init(allocator, &opener, sessions, &font); try ui.register(session_interaction_component.asComponent()); const worktree_comp_ptr = try allocator.create(ui_mod.worktree_overlay.WorktreeOverlayComponent); @@ -1863,9 +1861,9 @@ pub fn run(io: std.Io, log_dir_override: ?[]const u8) !void { try ui.register(metrics_overlay_component.asComponent()); const diff_overlay_component = try ui_mod.diff_overlay.DiffOverlayComponent.init(allocator, io); try ui.register(diff_overlay_component.asComponent()); - const reader_overlay_component = try ui_mod.reader_overlay.ReaderOverlayComponent.init(allocator, sessions); + const reader_overlay_component = try ui_mod.reader_overlay.ReaderOverlayComponent.init(allocator, &opener, sessions); try ui.register(reader_overlay_component.asComponent()); - const story_overlay_component = try ui_mod.story_overlay.StoryOverlayComponent.init(allocator, io); + const story_overlay_component = try ui_mod.story_overlay.StoryOverlayComponent.init(allocator, io, &opener); try ui.register(story_overlay_component.asComponent()); const selection_agent_overlay_component = try ui_mod.selection_agent_overlay.SelectionAgentOverlayComponent.init(allocator); try ui.register(selection_agent_overlay_component.asComponent()); @@ -2995,11 +2993,11 @@ pub fn run(io: std.Io, log_dir_override: ?[]const u8) !void { if (config.ui.show_hotkey_feedback) ui.showHotkey("⌘,", now); if (builtin.os.tag == .macos) { - _ = proc.spawnDetached(allocator, io, &.{ "open", "-t", config_path }) catch |err| { + _ = proc.spawnDetached(io, &.{ "open", "-t", config_path }) catch |err| { std.debug.print("Failed to open config file: {}\n", .{err}); }; } else { - open_url.openUrl(allocator, config_path) catch |err| { + opener.open(config_path) catch |err| { std.debug.print("Failed to open config file: {}\n", .{err}); }; } diff --git a/src/clock.zig b/src/clock.zig index 267e98e2..1887608e 100644 --- a/src/clock.zig +++ b/src/clock.zig @@ -30,9 +30,7 @@ pub fn sleepNanos(io: std.Io, nanoseconds: u64) void { } test "the three clock reads agree on the same instant" { - var threaded: std.Io.Threaded = .init(std.testing.allocator, .{}); - defer threaded.deinit(); - const io = threaded.io(); + const io = std.testing.io; const secs = nowSeconds(io); const millis = nowMillis(io); @@ -54,17 +52,13 @@ test "the three clock reads agree on the same instant" { } test "nowSeconds returns a plausible wall-clock time" { - var threaded: std.Io.Threaded = .init(std.testing.allocator, .{}); - defer threaded.deinit(); // 2026-01-01T00:00:00Z. Guards against a clock source that returns // uptime or zero instead of Unix time. - try std.testing.expect(nowSeconds(threaded.io()) > 1_767_225_600); + try std.testing.expect(nowSeconds(std.testing.io) > 1_767_225_600); } test "sleepNanos advances the clock by at least the requested span" { - var threaded: std.Io.Threaded = .init(std.testing.allocator, .{}); - defer threaded.deinit(); - const io = threaded.io(); + const io = std.testing.io; const requested_ns: u64 = 5 * std.time.ns_per_ms; const before = nowNanos(io); diff --git a/src/config.zig b/src/config.zig index b9ed1fb0..d1c6cd5b 100644 --- a/src/config.zig +++ b/src/config.zig @@ -1176,9 +1176,7 @@ test "Persistence.appendLegacyTerminalEntries migrates row-major order" { test "Persistence save/load round-trip preserves all fields" { const allocator = std.testing.allocator; - var threaded: std.Io.Threaded = .init(allocator, .{}); - defer threaded.deinit(); - const io = threaded.io(); + const io = std.testing.io; var tmp_dir = std.testing.tmpDir(.{}); defer tmp_dir.cleanup(); @@ -1269,9 +1267,7 @@ test "Persistence treats missing onboarding state as already shown" { test "writeFileAtomicallyAbsolute replaces file with valid TOML" { const allocator = std.testing.allocator; - var threaded: std.Io.Threaded = .init(allocator, .{}); - defer threaded.deinit(); - const io = threaded.io(); + const io = std.testing.io; var tmp_dir = std.testing.tmpDir(.{}); defer tmp_dir.cleanup(); diff --git a/src/main.zig b/src/main.zig index 9fb0fa02..12dd0518 100644 --- a/src/main.zig +++ b/src/main.zig @@ -28,7 +28,7 @@ pub fn main(init: std.process.Init) !void { std.process.exit(1); }; - try runtime.run(init.io, parsed.log_dir_override); + try runtime.run(init.gpa, init.io, parsed.log_dir_override); } // Zig only collects tests from files reachable through this block, so every @@ -58,6 +58,7 @@ test { _ = @import("metrics.zig"); _ = @import("pty.zig"); _ = @import("platform/sdl.zig"); + _ = @import("os/open.zig"); _ = @import("proc.zig"); _ = @import("render/renderer.zig"); _ = @import("session/notify.zig"); diff --git a/src/os/open.zig b/src/os/open.zig index 5ed0f283..ee4284fa 100644 --- a/src/os/open.zig +++ b/src/os/open.zig @@ -9,58 +9,146 @@ const OpenError = error{ OutOfMemory, }; -const argv_len: comptime_int = switch (builtin.os.tag) { - .linux, .freebsd => 2, - .windows => 3, - .macos => 2, - else => @compileError("unsupported platform for openUrl"), -}; +const max_in_flight: usize = 4; const ThreadContext = struct { allocator: std.mem.Allocator, - url: []const u8, - argv: [argv_len][]const u8, + io: std.Io, + done: *std.atomic.Value(bool), + owned_url: ?[]u8, + argv: []const []const u8, fn deinit(self: *ThreadContext) void { - self.allocator.free(self.url); + self.allocator.free(self.argv); + if (self.owned_url) |url| self.allocator.free(url); self.allocator.destroy(self); } }; -pub fn openUrl(_: std.mem.Allocator, url: []const u8) OpenError!void { - // Use c_allocator because it's thread-safe and the context is freed on a worker thread. - const thread_allocator = std.heap.c_allocator; +const Slot = struct { + thread: ?std.Thread = null, + done: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), + sequence: u64 = 0, +}; - const ctx = thread_allocator.create(ThreadContext) catch return error.OutOfMemory; - errdefer thread_allocator.destroy(ctx); +pub const Opener = struct { + allocator: std.mem.Allocator, + io: std.Io, + slots: [max_in_flight]Slot = [_]Slot{.{}} ** max_in_flight, + next_sequence: u64 = 0, - ctx.allocator = thread_allocator; - ctx.url = thread_allocator.dupe(u8, url) catch return error.OutOfMemory; - errdefer thread_allocator.free(ctx.url); + pub fn init(allocator: std.mem.Allocator, io: std.Io) Opener { + return .{ .allocator = allocator, .io = io }; + } - ctx.argv = switch (builtin.os.tag) { - .linux, .freebsd => .{ "xdg-open", ctx.url }, - .windows => .{ "rundll32", "url.dll,FileProtocolHandler", ctx.url }, - .macos => .{ "open", ctx.url }, - else => comptime unreachable, - }; + pub fn open(self: *Opener, url: []const u8) OpenError!void { + const owned_url = self.allocator.dupe(u8, url) catch return error.OutOfMemory; + return switch (builtin.os.tag) { + .linux, .freebsd => self.spawnCommand(&.{ "xdg-open", owned_url }, owned_url), + .windows => self.spawnCommand(&.{ "rundll32", "url.dll,FileProtocolHandler", owned_url }, owned_url), + .macos => self.spawnCommand(&.{ "open", owned_url }, owned_url), + else => comptime unreachable, + }; + } + + pub fn deinit(self: *Opener) void { + for (&self.slots) |*slot| self.joinSlot(slot); + } - const thread = std.Thread.spawn(.{}, openUrlThread, .{ctx}) catch |err| { - return switch (err) { - error.OutOfMemory => error.OutOfMemory, - else => error.SpawnFailed, + fn spawnCommand(self: *Opener, argv: []const []const u8, owned_url: ?[]u8) OpenError!void { + self.reapFinished(); + const slot = self.freeSlot() orelse blk: { + self.joinOldest(); + break :blk self.freeSlot() orelse unreachable; }; - }; - thread.detach(); -} + + const ctx = self.allocator.create(ThreadContext) catch { + if (owned_url) |url| self.allocator.free(url); + return error.OutOfMemory; + }; + const argv_copy = self.allocator.dupe([]const u8, argv) catch { + self.allocator.destroy(ctx); + if (owned_url) |url| self.allocator.free(url); + return error.OutOfMemory; + }; + ctx.* = .{ + .allocator = self.allocator, + .io = self.io, + .done = &slot.done, + .owned_url = owned_url, + .argv = argv_copy, + }; + errdefer ctx.deinit(); + + slot.done.store(false, .seq_cst); + slot.thread = std.Thread.spawn(.{}, openUrlThread, .{ctx}) catch |err| { + return switch (err) { + error.OutOfMemory => error.OutOfMemory, + else => error.SpawnFailed, + }; + }; + slot.sequence = self.next_sequence; + self.next_sequence +%= 1; + } + + fn reapFinished(self: *Opener) void { + for (&self.slots) |*slot| { + if (slot.thread != null and slot.done.load(.seq_cst)) self.joinSlot(slot); + } + } + + fn freeSlot(self: *Opener) ?*Slot { + for (&self.slots) |*slot| { + if (slot.thread == null) return slot; + } + return null; + } + + fn joinOldest(self: *Opener) void { + var oldest = &self.slots[0]; + for (1..self.slots.len) |idx| { + const slot = &self.slots[idx]; + if (slot.sequence < oldest.sequence) oldest = slot; + } + self.joinSlot(oldest); + } + + fn joinSlot(_: *Opener, slot: *Slot) void { + const thread = slot.thread orelse return; + thread.join(); + slot.thread = null; + } + + fn inFlightCount(self: *const Opener) usize { + var count: usize = 0; + for (self.slots) |slot| { + if (slot.thread != null) count += 1; + } + return count; + } +}; fn openUrlThread(ctx: *ThreadContext) void { defer ctx.deinit(); + defer ctx.done.store(true, .seq_cst); - var threaded: std.Io.Threaded = .init(ctx.allocator, .{}); - defer threaded.deinit(); - _ = proc.spawnDetached(ctx.allocator, threaded.io(), &ctx.argv) catch |err| { - log.warn("failed to open URL '{s}': {}", .{ ctx.url, err }); + _ = proc.spawnDetached(ctx.io, ctx.argv) catch |err| { + if (ctx.owned_url) |url| { + log.warn("failed to open URL '{s}': {}", .{ url, err }); + } else { + log.warn("failed to open URL: {}", .{err}); + } return; }; } + +test "Opener deinit joins a spawned command" { + if (builtin.os.tag == .windows) return error.SkipZigTest; + + var opener = Opener.init(std.testing.allocator, std.testing.io); + defer opener.deinit(); + + try opener.spawnCommand(&.{ "/bin/sh", "-c", "exit 0" }, null); + opener.deinit(); + try std.testing.expectEqual(@as(usize, 0), opener.inFlightCount()); +} diff --git a/src/posix_util.zig b/src/posix_util.zig index ba685eb0..69f30efc 100644 --- a/src/posix_util.zig +++ b/src/posix_util.zig @@ -99,11 +99,10 @@ pub const AcceptError = error{ BlockedByFirewall, } || std.posix.UnexpectedError; -/// Accepts a connection on `sock` without capturing the peer address, mirroring -/// Zig 0.15.2's `std.posix.accept(sock, null, null, 0)`. `std.Io.net.Server.accept` -/// cannot be used here: it treats `EAGAIN` on a non-blocking listening socket as a -/// programmer bug and panics, but the call sites need non-blocking accept to poll -/// a stop flag alongside listening for connections. +/// Accepts a connection on `sock` without capturing the peer address. The +/// `std.Io.net.Server.accept` implementation panics on `EAGAIN` for a +/// non-blocking listening socket, while the call sites need non-blocking accept +/// to poll a stop flag alongside listening for connections. pub fn accept(sock: std.posix.fd_t) AcceptError!std.posix.fd_t { while (true) { const rc = std.posix.system.accept(sock, null, null); diff --git a/src/proc.zig b/src/proc.zig index 7c8408d2..d5d1b8bd 100644 --- a/src/proc.zig +++ b/src/proc.zig @@ -57,18 +57,14 @@ pub fn run(allocator: std.mem.Allocator, io: std.Io, options: RunOptions) !RunRe } /// Spawns `argv`, waits for it, and discards its output. -pub fn spawnDetached(allocator: std.mem.Allocator, io: std.Io, argv: []const []const u8) !Term { - _ = allocator; +pub fn spawnDetached(io: std.Io, argv: []const []const u8) !Term { var child = try std.process.spawn(io, .{ .argv = argv }); return fromStdTerm(try child.wait(io)); } test "run collects stdout and reports a zero exit" { - var threaded: std.Io.Threaded = .init(std.testing.allocator, .{}); - defer threaded.deinit(); - const allocator = std.testing.allocator; - const result = try run(allocator, threaded.io(), .{ + const result = try run(allocator, std.testing.io, .{ .argv = &.{ "/bin/sh", "-c", "printf hello" }, }); defer allocator.free(result.stdout); @@ -79,11 +75,8 @@ test "run collects stdout and reports a zero exit" { } test "run separates stderr from stdout and reports a nonzero exit" { - var threaded: std.Io.Threaded = .init(std.testing.allocator, .{}); - defer threaded.deinit(); - const allocator = std.testing.allocator; - const result = try run(allocator, threaded.io(), .{ + const result = try run(allocator, std.testing.io, .{ .argv = &.{ "/bin/sh", "-c", "printf out; printf err 1>&2; exit 3" }, }); defer allocator.free(result.stdout); @@ -95,11 +88,8 @@ test "run separates stderr from stdout and reports a nonzero exit" { } test "run honors cwd" { - var threaded: std.Io.Threaded = .init(std.testing.allocator, .{}); - defer threaded.deinit(); - const allocator = std.testing.allocator; - const result = try run(allocator, threaded.io(), .{ + const result = try run(allocator, std.testing.io, .{ .argv = &.{ "/bin/sh", "-c", "pwd" }, .cwd = "/", }); @@ -110,20 +100,13 @@ test "run honors cwd" { } test "run surfaces a missing executable as an error rather than a term" { - var threaded: std.Io.Threaded = .init(std.testing.allocator, .{}); - defer threaded.deinit(); - const allocator = std.testing.allocator; - try std.testing.expectError(error.FileNotFound, run(allocator, threaded.io(), .{ + try std.testing.expectError(error.FileNotFound, run(allocator, std.testing.io, .{ .argv = &.{"/nonexistent/architect-test-binary"}, })); } test "spawnDetached waits for the child and returns its term" { - var threaded: std.Io.Threaded = .init(std.testing.allocator, .{}); - defer threaded.deinit(); - - const allocator = std.testing.allocator; - const term = try spawnDetached(allocator, threaded.io(), &.{ "/bin/sh", "-c", "exit 7" }); + const term = try spawnDetached(std.testing.io, &.{ "/bin/sh", "-c", "exit 7" }); try std.testing.expectEqual(Term{ .exited = 7 }, term); } diff --git a/src/session/notify.zig b/src/session/notify.zig index 33bb433a..9d0185df 100644 --- a/src/session/notify.zig +++ b/src/session/notify.zig @@ -374,9 +374,7 @@ test "enqueueNotification skips wake when queueing fails" { test "notify thread stops promptly when signaled while blocked in poll" { const allocator = std.testing.allocator; - var threaded: std.Io.Threaded = .init(allocator, .{}); - defer threaded.deinit(); - const io = threaded.io(); + const io = std.testing.io; std.Io.Dir.cwd().createDirPath(io, ".tmp") catch |err| switch (err) { error.PathAlreadyExists => {}, diff --git a/src/session/pty_reader.zig b/src/session/pty_reader.zig index 108ee054..992f8f6d 100644 --- a/src/session/pty_reader.zig +++ b/src/session/pty_reader.zig @@ -529,9 +529,7 @@ test "PtyReader snapshot distinguishes full and closed buffers" { test "registering an fd wakes a reader that is blocked with nothing to poll" { const allocator = std.testing.allocator; - var threaded: std.Io.Threaded = .init(allocator, .{}); - defer threaded.deinit(); - const io = threaded.io(); + const io = std.testing.io; var wake = try wake_pipe.WakePipe.init(); defer wake.deinit(); @@ -568,9 +566,8 @@ test "registering an fd wakes a reader that is blocked with nothing to poll" { test "PtyReader thread drains a pipe into the buffer and posts one wake" { const allocator = std.testing.allocator; - var threaded: std.Io.Threaded = .init(allocator, .{}); - defer threaded.deinit(); - const buffer = try PtyOutputBuffer.create(allocator, threaded.io()); + const io = std.testing.io; + const buffer = try PtyOutputBuffer.create(allocator, io); defer buffer.destroy(allocator); var fds: [2]posix.fd_t = undefined; @@ -580,12 +577,12 @@ test "PtyReader thread drains a pipe into the buffer and posts one wake" { var wake = try wake_pipe.WakePipe.init(); defer wake.deinit(); - var reader = PtyReader.init(threaded.io(), &wake); + var reader = PtyReader.init(io, &wake); var stop = atomic.Value(bool).init(false); var wake_pending = atomic.Value(bool).init(false); var wake_count = atomic.Value(usize).init(0); - const thread = try start(threaded.io(), &reader, &stop, &wake_pending, .{ + const thread = try start(io, &reader, &stop, &wake_pending, .{ .context = &wake_count, .callback = incrementWakeCount, }); @@ -598,27 +595,26 @@ test "PtyReader thread drains a pipe into the buffer and posts one wake" { reader.register(fds[0], buffer); _ = try posix_util.write(fds[1], "ping"); - try std.testing.expect(waitForBufferBytes(threaded.io(), buffer, 2_000)); + try std.testing.expect(waitForBufferBytes(io, buffer, 2_000)); try std.testing.expect(wake_pending.load(.seq_cst)); try std.testing.expectEqual(@as(usize, 1), wake_count.load(.seq_cst)); _ = try posix_util.write(fds[1], "pong"); - clock.sleepNanos(threaded.io(), 300 * std.time.ns_per_ms); + clock.sleepNanos(io, 300 * std.time.ns_per_ms); try std.testing.expectEqual(@as(usize, 1), wake_count.load(.seq_cst)); var out: [16]u8 = undefined; _ = buffer.consume(&out); wake_pending.store(false, .seq_cst); _ = try posix_util.write(fds[1], "again"); - try std.testing.expect(waitForBufferBytes(threaded.io(), buffer, 2_000)); + try std.testing.expect(waitForBufferBytes(io, buffer, 2_000)); try std.testing.expectEqual(@as(usize, 2), wake_count.load(.seq_cst)); } test "PtyReader.retire prevents any further reads of the fd" { const allocator = std.testing.allocator; - var threaded: std.Io.Threaded = .init(allocator, .{}); - defer threaded.deinit(); - const buffer = try PtyOutputBuffer.create(allocator, threaded.io()); + const io = std.testing.io; + const buffer = try PtyOutputBuffer.create(allocator, io); defer buffer.destroy(allocator); var fds: [2]posix.fd_t = undefined; @@ -628,11 +624,11 @@ test "PtyReader.retire prevents any further reads of the fd" { var wake = try wake_pipe.WakePipe.init(); defer wake.deinit(); - var reader = PtyReader.init(threaded.io(), &wake); + var reader = PtyReader.init(io, &wake); var stop = atomic.Value(bool).init(false); var wake_pending = atomic.Value(bool).init(false); - const thread = try start(threaded.io(), &reader, &stop, &wake_pending, null); + const thread = try start(io, &reader, &stop, &wake_pending, null); defer { stop.store(true, .seq_cst); wake.signal(); @@ -641,12 +637,12 @@ test "PtyReader.retire prevents any further reads of the fd" { reader.register(fds[0], buffer); _ = try posix_util.write(fds[1], "before"); - try std.testing.expect(waitForBufferBytes(threaded.io(), buffer, 2_000)); + try std.testing.expect(waitForBufferBytes(io, buffer, 2_000)); var out: [32]u8 = undefined; _ = buffer.consume(&out); reader.retire(fds[0]); _ = try posix_util.write(fds[1], "after"); - clock.sleepNanos(threaded.io(), 300 * std.time.ns_per_ms); + clock.sleepNanos(io, 300 * std.time.ns_per_ms); try std.testing.expect(!bufferHasBytes(buffer)); } diff --git a/src/session/state.zig b/src/session/state.zig index b1fd7636..e06f9610 100644 --- a/src/session/state.zig +++ b/src/session/state.zig @@ -1386,8 +1386,7 @@ test "SessionState assigns incrementing ids" { test "checkAlive skips waitpid polling when a process watcher owns exit detection" { const allocator = std.testing.allocator; - var threaded: std.Io.Threaded = .init(allocator, .{}); - defer threaded.deinit(); + const io = std.testing.io; const theme = colors_mod.Theme.default(); const size = pty_mod.winsize{ .ws_row = 24, @@ -1403,14 +1402,14 @@ test "checkAlive skips waitpid polling when a process watcher owns exit detectio } defer _ = std.c.waitpid(pid, null, 0); // Give the forked child a moment to exit and become reapable. - clock.sleepNanos(threaded.io(), 50 * std.time.ns_per_ms); + clock.sleepNanos(io, 50 * std.time.ns_per_ms); var pipe_fds: [2]std.posix.fd_t = undefined; try posix_util.pipe(&pipe_fds); // pty.deinit() only closes the master fd; close the slave ourselves. defer _ = std.c.close(pipe_fds[0]); - var session = try SessionState.init(allocator, threaded.io(), 0, "/bin/zsh", size, notify_sock, theme, null); + var session = try SessionState.init(allocator, io, 0, "/bin/zsh", size, notify_sock, theme, null); // Closes pipe_fds[1] (master) via shell.deinit() -> pty.deinit(). defer session.deinit(allocator); @@ -1418,7 +1417,7 @@ test "checkAlive skips waitpid polling when a process watcher owns exit detectio session.dead = false; session.render_epoch = 1; session.shell = shell_mod.Shell{ - .io = threaded.io(), + .io = io, .pty = .{ .master = pipe_fds[1], .slave = pipe_fds[0] }, .child_pid = pid, }; diff --git a/src/shell.zig b/src/shell.zig index 1d987182..2637b78d 100644 --- a/src/shell.zig +++ b/src/shell.zig @@ -664,7 +664,7 @@ pub fn ensureTerminfoSetup(io: std.Io) void { return; }; - const cache_dir_z = std.fmt.bufPrintZ(&terminfo_dir_buf, "{s}/.cache/architect/terminfo", .{home}) catch { + const cache_dir_z = std.fmt.bufPrintSentinel(&terminfo_dir_buf, "{s}/.cache/architect/terminfo", .{home}, 0) catch { log.warn("Failed to format terminfo cache path", .{}); return; }; @@ -716,7 +716,7 @@ pub fn ensureTerminfoSetup(io: std.Io) void { // Write terminfo source to temp file (need null-terminated paths for execve) var src_path_buf: [std.fs.max_path_bytes]u8 = undefined; - const src_path_z = std.fmt.bufPrintZ(&src_path_buf, "{s}/xterm-ghostty.ti", .{cache_dir}) catch return; + const src_path_z = std.fmt.bufPrintSentinel(&src_path_buf, "{s}/xterm-ghostty.ti", .{cache_dir}, 0) catch return; const src_file = std.Io.Dir.createFileAbsolute(io, src_path_z, .{}) catch |err| { log.warn("Failed to create terminfo source file: {}", .{err}); @@ -743,12 +743,15 @@ pub fn ensureTerminfoSetup(io: std.Io) void { null, }; - const fork_result = std.c.fork(); + const fork_result = posix_util.fork() catch |err| { + log.warn("Failed to fork for tic ({}), falling back to {s}", .{ err, fallback_term }); + return; + }; if (fork_result == 0) { // Child: exec tic _ = std.c.execve(tic_path.ptr, &tic_argv, @ptrCast(std.c.environ)); std.c._exit(1); - } else if (fork_result > 0) { + } else { // Parent: wait for tic to complete var status: c_int = 0; _ = std.c.waitpid(fork_result, &status, 0); @@ -760,8 +763,6 @@ pub fn ensureTerminfoSetup(io: std.Io) void { } else { log.warn("tic failed to compile terminfo (status={}), falling back to {s}", .{ status, fallback_term }); } - } else { - log.warn("Failed to fork for tic, falling back to {s}", .{fallback_term}); } } @@ -772,12 +773,12 @@ fn ensureArchitectCommandSetup(io: std.Io) void { const runtime_dir = env.get("XDG_RUNTIME_DIR"); const home = env.get("HOME"); const base_dir_z: [:0]const u8 = if (runtime_dir) |dir| - std.fmt.bufPrintZ(&architect_command_base_buf, "{s}/architect", .{dir}) catch |err| { + std.fmt.bufPrintSentinel(&architect_command_base_buf, "{s}/architect", .{dir}, 0) catch |err| { log.warn("failed to format architect runtime path: {}", .{err}); return; } else if (home) |home_dir| - std.fmt.bufPrintZ(&architect_command_base_buf, "{s}/.cache/architect", .{home_dir}) catch |err| { + std.fmt.bufPrintSentinel(&architect_command_base_buf, "{s}/.cache/architect", .{home_dir}, 0) catch |err| { log.warn("failed to format architect cache path: {}", .{err}); return; } @@ -795,7 +796,7 @@ fn ensureArchitectCommandSetup(io: std.Io) void { }, }; - const bin_dir_z = std.fmt.bufPrintZ(&architect_command_dir_buf, "{s}/bin", .{base_dir}) catch |err| { + const bin_dir_z = std.fmt.bufPrintSentinel(&architect_command_dir_buf, "{s}/bin", .{base_dir}, 0) catch |err| { log.warn("failed to format architect bin path: {}", .{err}); return; }; @@ -809,7 +810,7 @@ fn ensureArchitectCommandSetup(io: std.Io) void { }, }; - const script_path_z = std.fmt.bufPrintZ(&architect_command_path_buf, "{s}/architect", .{bin_dir}) catch |err| { + const script_path_z = std.fmt.bufPrintSentinel(&architect_command_path_buf, "{s}/architect", .{bin_dir}, 0) catch |err| { log.warn("failed to format architect command path: {}", .{err}); return; }; @@ -846,7 +847,7 @@ fn ensureArchitectZshProfileSetup(io: std.Io) void { const base_dir = std.mem.sliceTo(base_dir_z, 0); var zsh_dir_buf: [std.fs.max_path_bytes]u8 = undefined; - const zsh_dir_z = std.fmt.bufPrintZ(&zsh_dir_buf, "{s}/zsh", .{base_dir}) catch |err| { + const zsh_dir_z = std.fmt.bufPrintSentinel(&zsh_dir_buf, "{s}/zsh", .{base_dir}, 0) catch |err| { log.warn("failed to format architect zsh dir: {}", .{err}); return; }; @@ -860,7 +861,7 @@ fn ensureArchitectZshProfileSetup(io: std.Io) void { }; var env_path_buf: [std.fs.max_path_bytes]u8 = undefined; - const env_path_z = std.fmt.bufPrintZ(&env_path_buf, "{s}/.zshenv", .{zsh_dir_z}) catch |err| { + const env_path_z = std.fmt.bufPrintSentinel(&env_path_buf, "{s}/.zshenv", .{zsh_dir_z}, 0) catch |err| { log.warn("failed to format architect zsh env path: {}", .{err}); return; }; @@ -877,7 +878,7 @@ fn ensureArchitectZshProfileSetup(io: std.Io) void { }; var profile_path_buf: [std.fs.max_path_bytes]u8 = undefined; - const profile_path_z = std.fmt.bufPrintZ(&profile_path_buf, "{s}/.zprofile", .{zsh_dir_z}) catch |err| { + const profile_path_z = std.fmt.bufPrintSentinel(&profile_path_buf, "{s}/.zprofile", .{zsh_dir_z}, 0) catch |err| { log.warn("failed to format architect zsh profile path: {}", .{err}); return; }; @@ -894,7 +895,7 @@ fn ensureArchitectZshProfileSetup(io: std.Io) void { }; var rc_path_buf: [std.fs.max_path_bytes]u8 = undefined; - const rc_path_z = std.fmt.bufPrintZ(&rc_path_buf, "{s}/.zshrc", .{zsh_dir_z}) catch |err| { + const rc_path_z = std.fmt.bufPrintSentinel(&rc_path_buf, "{s}/.zshrc", .{zsh_dir_z}, 0) catch |err| { log.warn("failed to format architect zsh rc path: {}", .{err}); return; }; @@ -911,7 +912,7 @@ fn ensureArchitectZshProfileSetup(io: std.Io) void { }; var login_path_buf: [std.fs.max_path_bytes]u8 = undefined; - const login_path_z = std.fmt.bufPrintZ(&login_path_buf, "{s}/.zlogin", .{zsh_dir_z}) catch |err| { + const login_path_z = std.fmt.bufPrintSentinel(&login_path_buf, "{s}/.zlogin", .{zsh_dir_z}, 0) catch |err| { log.warn("failed to format architect zsh login path: {}", .{err}); return; }; @@ -945,7 +946,7 @@ fn configureZshPathInjection(shell_path: []const u8) void { const base_dir = std.mem.sliceTo(base_dir_z, 0); var zsh_dir_buf: [std.fs.max_path_bytes]u8 = undefined; - const zsh_dir_z = std.fmt.bufPrintZ(&zsh_dir_buf, "{s}/zsh", .{base_dir}) catch |err| { + const zsh_dir_z = std.fmt.bufPrintSentinel(&zsh_dir_buf, "{s}/zsh", .{base_dir}, 0) catch |err| { log.warn("failed to format architect zsh dir for env: {}", .{err}); return; }; @@ -1002,7 +1003,7 @@ fn findExecutableInPath(io: std.Io, name: []const u8) ?[:0]const u8 { var it = std.mem.splitScalar(u8, path_env_slice, ':'); while (it.next()) |dir| { if (dir.len == 0) continue; - const candidate = std.fmt.bufPrintZ(&tic_path_buf, "{s}/{s}", .{ dir, name }) catch |err| { + const candidate = std.fmt.bufPrintSentinel(&tic_path_buf, "{s}/{s}", .{ dir, name }, 0) catch |err| { log.warn("failed to format candidate path: {}", .{err}); continue; }; @@ -1040,8 +1041,7 @@ pub const Shell = struct { pty_copy.deinit(); } - const pid = std.c.fork(); - if (pid < 0) return error.ForkFailed; + const pid = posix_util.fork() catch return error.ForkFailed; if (pid == 0) { // Match ghostty's order: dup2 first so stdin/stdout/stderr point at the @@ -1169,13 +1169,11 @@ test "pathContainsEntry" { test "bundled terminfo compiles to legacy short-int format" { const testing = std.testing; const allocator = testing.allocator; - var threaded: std.Io.Threaded = .init(allocator, .{}); - defer threaded.deinit(); + const io = testing.io; var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); - const io = threaded.io(); const tmp_path = try tmp.dir.realPathFileAlloc(io, ".", allocator); defer allocator.free(tmp_path); @@ -1190,7 +1188,7 @@ test "bundled terminfo compiles to legacy short-int format" { const tic_path = findExecutableInPath(io, "tic") orelse return error.SkipZigTest; - const term = try proc.spawnDetached(allocator, io, &.{ tic_path, "-x", "-o", tmp_path, src_path }); + const term = try proc.spawnDetached(io, &.{ tic_path, "-x", "-o", tmp_path, src_path }); try testing.expectEqual(proc.Term{ .exited = 0 }, term); // tic stores the compiled entry in either `78/xterm-ghostty` (hashed, diff --git a/src/ui/components/pr_dropdown_fetch.zig b/src/ui/components/pr_dropdown_fetch.zig index 62b1e857..568d7ae6 100644 --- a/src/ui/components/pr_dropdown_fetch.zig +++ b/src/ui/components/pr_dropdown_fetch.zig @@ -151,9 +151,7 @@ fn canonicalizeExecutablePath( test "gh resolver finds an executable in PATH" { const allocator = std.testing.allocator; - var threaded: std.Io.Threaded = .init(allocator, .{}); - defer threaded.deinit(); - const io = threaded.io(); + const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); @@ -173,9 +171,7 @@ test "gh resolver finds an executable in PATH" { test "gh resolver canonicalizes relative PATH entries" { const allocator = std.testing.allocator; - var threaded: std.Io.Threaded = .init(allocator, .{}); - defer threaded.deinit(); - const io = threaded.io(); + const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); @@ -200,9 +196,7 @@ test "gh resolver canonicalizes relative PATH entries" { test "gh resolver finds a known location when PATH omits it" { const allocator = std.testing.allocator; - var threaded: std.Io.Threaded = .init(allocator, .{}); - defer threaded.deinit(); - const io = threaded.io(); + const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); @@ -226,9 +220,7 @@ test "gh resolver finds a known location when PATH omits it" { test "gh resolver reports no executable when PATH and known locations are empty" { const allocator = std.testing.allocator; - var threaded: std.Io.Threaded = .init(allocator, .{}); - defer threaded.deinit(); - const io = threaded.io(); + const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); @@ -247,9 +239,7 @@ test "gh resolver skips inaccessible PATH and known candidates" { if (builtin.os.tag == .windows) return error.SkipZigTest; const allocator = std.testing.allocator; - var threaded: std.Io.Threaded = .init(allocator, .{}); - defer threaded.deinit(); - const io = threaded.io(); + const io = std.testing.io; var path_tmp = std.testing.tmpDir(.{}); defer path_tmp.cleanup(); @@ -284,9 +274,7 @@ test "gh resolver skips inaccessible PATH and known candidates" { test "gh resolver skips directory PATH and known candidates" { const allocator = std.testing.allocator; - var threaded: std.Io.Threaded = .init(allocator, .{}); - defer threaded.deinit(); - const io = threaded.io(); + const io = std.testing.io; var path_tmp = std.testing.tmpDir(.{}); defer path_tmp.cleanup(); diff --git a/src/ui/components/quit_confirm.zig b/src/ui/components/quit_confirm.zig index de43714a..5e6b2883 100644 --- a/src/ui/components/quit_confirm.zig +++ b/src/ui/components/quit_confirm.zig @@ -308,10 +308,11 @@ pub const QuitConfirmComponent = struct { const plural = if (self.process_count == 1) "" else "s"; const verb = if (self.process_count == 1) "has" else "have"; const process_plural = if (self.process_count == 1) "" else "es"; - return std.fmt.bufPrintZ( + return std.fmt.bufPrintSentinel( buffer, "{d} terminal{s} {s} running process{s}. Quit anyway?", .{ self.process_count, plural, verb, process_plural }, + 0, ) catch |err| blk: { log.warn("failed to format quit message: {}", .{err}); break :blk "Quit anyway?"; diff --git a/src/ui/components/reader_overlay.zig b/src/ui/components/reader_overlay.zig index e23d26c9..fff43a26 100644 --- a/src/ui/components/reader_overlay.zig +++ b/src/ui/components/reader_overlay.zig @@ -59,6 +59,7 @@ pub const ToggleResult = enum { pub const ReaderOverlayComponent = struct { allocator: std.mem.Allocator, + opener: *open_url.Opener, sessions: []*SessionState, overlay: FullscreenOverlay = .{}, scrollbar_state: scrollbar.State = .{}, @@ -87,10 +88,11 @@ pub const ReaderOverlayComponent = struct { const base_font_size: c_int = 14; const code_font_size: c_int = 13; - pub fn init(allocator: std.mem.Allocator, sessions: []*SessionState) !*ReaderOverlayComponent { + pub fn init(allocator: std.mem.Allocator, opener: *open_url.Opener, sessions: []*SessionState) !*ReaderOverlayComponent { const comp = try allocator.create(ReaderOverlayComponent); comp.* = .{ .allocator = allocator, + .opener = opener, .sessions = sessions, .arrow_cursor = c.SDL_CreateSystemCursor(c.SDL_SYSTEM_CURSOR_DEFAULT), .pointer_cursor = c.SDL_CreateSystemCursor(c.SDL_SYSTEM_CURSOR_POINTER), @@ -794,7 +796,7 @@ pub const ReaderOverlayComponent = struct { if (event.button.button == c.SDL_BUTTON_LEFT) { if (self.linkHitIndexAt(mx, my)) |hit_idx| { const href = self.link_hits.items[hit_idx].href; - open_url.openUrl(self.allocator, href) catch |err| { + self.opener.open(href) catch |err| { log.warn("failed to open reader link {s}: {}", .{ href, err }); }; return true; diff --git a/src/ui/components/session_interaction.zig b/src/ui/components/session_interaction.zig index c93cccbd..396a5449 100644 --- a/src/ui/components/session_interaction.zig +++ b/src/ui/components/session_interaction.zig @@ -37,6 +37,7 @@ const CursorKind = enum { arrow, ibeam, pointer }; pub const SessionInteractionComponent = struct { allocator: std.mem.Allocator, + opener: *open_url.Opener, sessions: []*SessionState, views: []SessionViewState, font: *font_mod.Font, @@ -50,6 +51,7 @@ pub const SessionInteractionComponent = struct { pub fn init( allocator: std.mem.Allocator, + opener: *open_url.Opener, sessions: []*SessionState, font: *font_mod.Font, ) !*SessionInteractionComponent { @@ -64,6 +66,7 @@ pub const SessionInteractionComponent = struct { self.* = .{ .allocator = allocator, + .opener = opener, .sessions = sessions, .views = views, .font = font, @@ -247,7 +250,7 @@ pub const SessionInteractionComponent = struct { if (cmd_held) { if (getLinkAtPin(self.allocator, &focused.terminal.?, pin, view.is_viewing_scrollback)) |uri| { defer self.allocator.free(uri); - open_url.openUrl(self.allocator, uri) catch |err| { + self.opener.open(uri) catch |err| { log.err("failed to open URL: {}", .{err}); }; } else { diff --git a/src/ui/components/story_overlay.zig b/src/ui/components/story_overlay.zig index 72dd67be..7296823d 100644 --- a/src/ui/components/story_overlay.zig +++ b/src/ui/components/story_overlay.zig @@ -43,6 +43,7 @@ const AnchorPosition = struct { pub const StoryOverlayComponent = struct { allocator: std.mem.Allocator, io: std.Io, + opener: *open_url.Opener, overlay: FullscreenOverlay = .{}, scrollbar_state: scrollbar.State = .{}, @@ -74,11 +75,12 @@ pub const StoryOverlayComponent = struct { const marker_width: c_int = 20; const code_indent: c_int = 8; - pub fn init(allocator: std.mem.Allocator, io: std.Io) !*StoryOverlayComponent { + pub fn init(allocator: std.mem.Allocator, io: std.Io, opener: *open_url.Opener) !*StoryOverlayComponent { const comp = try allocator.create(StoryOverlayComponent); comp.* = .{ .allocator = allocator, .io = io, + .opener = opener, .pointer_cursor = c.SDL_CreateSystemCursor(c.SDL_SYSTEM_CURSOR_POINTER), .arrow_cursor = c.SDL_CreateSystemCursor(c.SDL_SYSTEM_CURSOR_DEFAULT), }; @@ -364,7 +366,7 @@ pub const StoryOverlayComponent = struct { if (self.linkHitIndexAt(mouse_x, mouse_y)) |hit_idx| { const href = self.link_hits.items[hit_idx].href; - open_url.openUrl(self.allocator, href) catch |err| { + self.opener.open(href) catch |err| { log.warn("failed to open story link {s}: {}", .{ href, err }); }; return true;