From 65bffe2c7b7fcdbd82cc112315e64e1bca8dd150 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 6 Jul 2026 13:54:34 -0300 Subject: file search: / on a file pane types a pattern into the tag tail (the existing modal one-line editor — persistent, no popup); Enter runs grep -nH '' '' in a shell in the file's dir via the existing exec path (ttyForDir + write effects), so results are ordinary persistent terminal text. n/N walk the result rows of that pane (paneCursorLines already blanks prompt/echo rows via the OSC 133 semantic marks), select the row with the modal line selection, and hand path:NNN: to the existing look (actOnSelection right) which scrolls the file pane there. Esc cancels and restores the tag. State: four Pane fields (search_edit/search_at/search_pane/search_row) — no results mirror, no highlight engine. New snap script fsearch.snap + golden; all 18 frozen goldens stay green (19/19). MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pardes.zig | 108 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 108 insertions(+) (limited to 'src') diff --git a/src/pardes.zig b/src/pardes.zig index c4893b25..88b10374 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -462,6 +462,14 @@ pub const Pane = struct { vsel: CharSel = .{}, /// one pending prefix codepoint (0 = none): `g` and `z` pending: u21 = 0, + /// `/` file search: the pattern is typed into the tag tail (no popup); + /// search_at is the tail length before " /" was appended (submit/cancel + /// restore it). n/N walk the grep results in pane search_pane, starting + /// just below search_row (the results shell's prompt row at submit). + search_edit: bool = false, + search_at: u16 = 0, + search_pane: ?usize = null, + search_row: i32 = 0, /// the editable tag tail: seeded with the default builtins on first touch; /// edited in place by a modal one-line editor sharing the pane's mode tag_tail: std.ArrayList(u8) = .empty, @@ -878,6 +886,7 @@ pub const Pardes = struct { fn exitTagEdit(pane: *Pane) void { pane.tag_edit = false; pane.tag_sel = false; + pane.search_edit = false; // an abandoned `/` input stays as tag text } fn tagSelBounds(pane: *Pane) struct { lo: usize, hi: usize } { @@ -1165,6 +1174,16 @@ pub const Pardes = struct { } else p.enterTty(p.active); return; } + // `/` search input in flight: Enter greps, Esc abandons; both restore + // the tag tail and hand focus back to the body. + if (pane.search_edit and (is(key, Key.enter) or is(key, Key.escape))) { + if (is(key, Key.enter)) p.submitSearch(pane); + pane.tag_tail.shrinkRetainingCapacity(@min(pane.search_at, pane.tag_tail.items.len)); + exitTagEdit(pane); + pane.mode = .normal; + pane.pending = 0; + return; + } // normal-mode Enter/Tab: the acme chords — look at (Enter) or execute // (Tab) the modal selection, else the file-ish word under the cursor // (on the tag tail when it's focused for editing, else the body). @@ -1522,6 +1541,95 @@ pub const Pardes = struct { if (is(key, 'y')) return p.normalYank(pane); if (is(key, 'u')) return p.doUndo(pane); if (is(key, 'U')) return p.doRedo(pane); + // file search: `/` types a pattern into the tag, n/N walk the results + if (pane.file != null and is(key, '/')) return p.startSearch(pane); + if (pane.file != null and is(key, 'n')) return p.searchStep(pane, 1); + if (pane.file != null and is(key, 'N')) return p.searchStep(pane, -1); + } + + // ---- file search (`/` + n/N): grep into a shell pane, look the results ---- + + /// `/` on a file pane: type the pattern into the tag tail after a " /" + /// marker — the existing modal one-line editor, visible while typing, + /// nothing that disappears. Enter/Esc are intercepted in handleKey. + fn startSearch(p: *Pardes, pane: *Pane) void { + if (!pane.tag_init) { + pane.tag_tail.appendSlice(p.gpa, file_pane_tail) catch return; + pane.tag_init = true; + } + pane.search_at = @intCast(pane.tag_tail.items.len); + pane.tag_tail.appendSlice(p.gpa, " /") catch return; + pane.search_edit = true; + pane.tag_edit = true; + pane.tag_sel = false; + pane.mode = .insert; + pane.pending = 0; + pane.tag_col = @intCast(pane.tag_tail.items.len); + } + + /// Enter on a `/` input: run `grep -nH '' ` in a shell in the + /// file's directory (the same path middle-click send takes), so the + /// results are ordinary persistent terminal text. Remember the results + /// pane and start n/N below its current prompt row. + fn submitSearch(p: *Pardes, pane: *Pane) void { + const f = pane.file orelse return; + const tail = pane.tag_tail.items; + const pat = std.mem.trim(u8, tail[@min(@as(usize, pane.search_at) + 2, tail.len)..], " \t\r\n"); + if (pat.len == 0) return; + const arena = p.scratch.allocator(); + var cmd: std.ArrayList(u8) = .empty; + cmd.appendSlice(arena, "grep -nH '") catch return; + for (pat) |ch| { + if (ch == '\'') cmd.appendSlice(arena, "'\\''") catch return else cmd.append(arena, ch) catch return; + } + cmd.appendSlice(arena, "' '") catch return; + cmd.appendSlice(arena, f.path) catch return; + cmd.append(arena, '\'') catch return; + const dst = p.ttyForDir(std.fs.path.dirname(f.path) orelse "/") orelse return; + const rp = p.panes[dst] orelse return; + p.padOutputBelowEdits(dst); + p.emitWrite(dst, cmd.items); + p.emitWrite(dst, "\r"); + pane.search_pane = dst; + // absolute row of the shell's cursor (the prompt echoing the grep); + // results land below it, so n scans from there — older output and + // stale results of a previous search stay behind + const sb = rp.vt.screens.active.pages.scrollbar(); + pane.search_row = @intCast(sb.total -| rp.rows + rp.vt.screens.active.cursor.y); + } + + /// n/N: select the next/previous grep result row in the results pane and + /// look it. Result rows read `path:NNN:...` and are found by their path + /// prefix; prompt/echo rows are already blanked by paneCursorLines (the + /// OSC 133 semantic row marks), so only real output is walked. + fn searchStep(p: *Pardes, pane: *Pane, delta: i32) void { + const f = pane.file orelse return; + const rid = pane.search_pane orelse return; + const rp = p.panes[rid] orelse return; + if (!rp.isTerminal()) return; + const pl = p.paneCursorLines(rp) catch return; + const nrows: i32 = @intCast(pl.lines.len); + var r = pane.search_row + delta; + while (r >= 0 and r < nrows) : (r += delta) { + const ln = pl.lines[@intCast(r)]; + if (!std.mem.startsWith(u8, ln, f.path) or ln.len <= f.path.len or ln[f.path.len] != ':') continue; + pane.search_row = r; + // select the result line in the results pane and keep it in view + rp.msel = .{ .active = true, .r0 = r, .r1 = r }; + rp.vsel.active = false; + rp.cur_row = r; + rp.cur_col = 0; + rp.cur_off = 0; + rp.cur_pinned = true; + rp.ensureCursorVisible(); + // look the leading file-ish word (path:NNN:) — the ordinary look + // path scrolls this file pane to the line + var hi: usize = 0; + while (hi < ln.len and look.isFileChar(ln[hi])) hi += 1; + p.actOnSelection(.right, rid, ln[0..hi], null); + return; + } + // no result that way (or none at all): nowhere to go, stay put } const InsertAt = enum { at, append, line_start, line_end, open_below, open_above }; -- cgit v1.3 From 7a83d1548493b043e2e77022c1c87d348377ac7b Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 6 Jul 2026 18:00:54 -0300 Subject: posix portability: linux syscalls -> libc (std.c) in one shared path. All raw std.os.linux.* call sites converted with the errno-convention flip (libc returns -1 + thread-local errno, not -errno): open/close/read/write/rename in tty.zig, gui.zig, look.zig; harness too (snapshot.zig: getcwd/mkdir/chdir/fork+waitpid/getdents64->opendir-readdir, e2e_harness.zig: clock_gettime/write/close), so the parity suite itself is POSIX-portable. Per-OS branches kept only where platforms genuinely diverge: shellCwd in look.zig (linux /proc//cwd readlink; darwin libproc proc_pidinfo PROC_PIDVNODEPATHINFO with a hand-declared extern; other POSIX degrades to null), bash path (/usr/bin/bash on linux, /bin/bash elsewhere), TIOCSWINSZ constant (absent from std.c.T on darwin). forkpty/execv/chdir externs were already libc and are portable (fork-safety preserved: absolute-path execv, no malloc between fork and exec, initial spawns before worker threads). build.zig: forward target/optimize to the ghostty dep for native/cross (web keeps its no-target fetch), disable ghostty simd for darwin cross-builds (apple_sdk needs xcrun; scalar fallback = web config), link libutil on the BSDs. Pinned ghostty needs two one-line zig-0.16 darwin fixes, applied in the zig-pkg cache and documented in build.zig. Verified: aarch64-macos + x86_64-macos + x86_64-freebsd cross-compiles green; linux native tty + gui + web green; snapshot suite 18/18 with goldens untouched. Out of scope: gui-on-macos needs Metal shaders (SDL GPU rejects our SPIR-V there). --- build.zig | 27 +++++++++++++++- src/gui.zig | 89 ++++++++++++++++++++++------------------------------ src/look.zig | 64 +++++++++++++++++++++++++++++-------- src/tty.zig | 66 +++++++++++++++++--------------------- test/e2e_harness.zig | 37 +++++++++++----------- test/snapshot.zig | 63 ++++++++++++++----------------------- 6 files changed, 184 insertions(+), 162 deletions(-) (limited to 'src') diff --git a/build.zig b/build.zig index 22dd7158..f9aa1070 100644 --- a/build.zig +++ b/build.zig @@ -59,6 +59,10 @@ pub fn build(b: *std.Build) void { // static archives the emcc link needs beyond the zig object (grammars, SDL) var wasm_libs: std.ArrayList(std.Build.LazyPath) = .empty; + // forkpty: in libc proper on glibc>=2.34 and darwin; the BSDs keep it in libutil + if (target.result.os.tag == .freebsd or target.result.os.tag == .netbsd or target.result.os.tag == .openbsd) + root_mod.linkSystemLibrary("util", .{}); + const opts = b.addOptions(); opts.addOption(Platform, "platform", platform); opts.addOption(bool, "syntax_highlighting", tree_sitter_grammars != .disabled); @@ -171,7 +175,26 @@ pub fn build(b: *std.Build) void { }).module("vaxis"); var ghostty_vt_for_snap: ?*std.Build.Module = null; - if (b.lazyDependency("ghostty", .{ .simd = target.result.os.tag != .emscripten })) |dep| { + // ghostty's simd libs (simdutf/highway, C++) locate the Apple SDK via + // xcrun on darwin targets, so cross-compiling to macOS from elsewhere + // uses the scalar fallback (the same configuration the web shell ships). + // Native/cross builds hand ghostty the real target (it defaults to the + // host otherwise); the web path keeps its original no-target fetch, whose + // zig object never uses ghostty's artifacts. + // NOTE: darwin targets also need two one-line zig-0.16 fixes in the + // pinned ghostty (applied in the zig-pkg cache; re-apply after a fresh + // fetch, or bump the pin once upstream carries them): + // src/os/mach.zig — std.heap.next_mmap_addr_hint is gone (make the hint + // var module-local) and posix.mmap prot is now a packed struct + // (.{ .READ = true, .WRITE = true }); + // src/terminal/kitty/graphics_image.zig:185 — shm_open's variadic mode + // literal 0 must be @as(std.c.mode_t, 0). + const ghostty_simd = !target.result.os.tag.isDarwin() or b.graph.host.result.os.tag.isDarwin(); + const ghostty_dep = if (is_emscripten) + b.lazyDependency("ghostty", .{ .simd = false }) + else + b.lazyDependency("ghostty", .{ .target = target, .optimize = optimize, .simd = ghostty_simd }); + if (ghostty_dep) |dep| { const ghostty_vt = dep.module("ghostty-vt"); ghostty_vt_for_snap = ghostty_vt; ghostty_vt.addImport("uucode", uucode_mod); @@ -309,6 +332,8 @@ pub fn build(b: *std.Build) void { }), }); if (ghostty_vt_for_snap) |vt| snap.root_module.addImport("ghostty-vt", vt); + if (target.result.os.tag == .freebsd or target.result.os.tag == .netbsd or target.result.os.tag == .openbsd) + snap.root_module.linkSystemLibrary("util", .{}); b.installArtifact(snap); const run_snap = b.addRunArtifact(snap); run_snap.addArtifactArg(exe); diff --git a/src/gui.zig b/src/gui.zig index 516a6b79..c1881c7c 100644 --- a/src/gui.zig +++ b/src/gui.zig @@ -1,6 +1,6 @@ //! The SDL3 GPU shell: owns an SDL window + event loop, translates SDL input //! into core events, performs the core's effects (fork ptys, write them, -//! resize them — same duties as tty.zig, this is also native linux), and +//! resize them — same duties as tty.zig, this is also native), and //! rasterizes the core's Surface: one instanced quad per cell, glyphs from an //! stb_truetype R8 atlas. Test modes: PARDES_TEST_GRID=1 is headless (no SDL, //! stdin escape sequences in, text grid frames out); PARDES_TEST=1 keeps the @@ -13,10 +13,11 @@ const std = @import("std"); const builtin = @import("builtin"); const posix = std.posix; -const linux = std.os.linux; +const libc = std.c; const vaxis = @import("vaxis"); // test modes only: the stdin escape-seq parser const ghostty_vt = @import("ghostty-vt"); // 256-color palette for .index cells const pardes = @import("pardes.zig"); +const look = @import("look.zig"); const is_emscripten = builtin.os.tag == .emscripten; @@ -34,6 +35,12 @@ extern "c" fn _exit(status: c_int) noreturn; extern "c" fn emscripten_get_element_css_size(target: [*:0]const u8, width: *f64, height: *f64) c_int; const EMSCRIPTEN_RESULT_SUCCESS: c_int = 0; +// absolute shell path per OS: execv must not search PATH (no allocation +// between fork and exec) +const bash_path: [*:0]const u8 = if (builtin.os.tag == .linux) "/usr/bin/bash" else "/bin/bash"; +// TIOCSWINSZ: absent from std.c.T on darwin — _IOW('t', 103, winsize) +const TIOCSWINSZ: c_int = @bitCast(@as(u32, if (@hasDecl(posix.T, "IOCSWINSZ")) posix.T.IOCSWINSZ else 0x80087467)); + const log = std.log.scoped(.gui); const font_ttf = @embedFile("AdwaitaMono-Regular.ttf"); @@ -271,7 +278,7 @@ fn normCell(norm: f32, win: f32, cell: f32) u16 { // ---- pty plumbing: reader threads feed a mutex-protected queue ---- -const Pty = struct { fd: c_int, pid: linux.pid_t }; +const Pty = struct { fd: c_int, pid: libc.pid_t }; const Msg = union(enum) { output: struct { pane: u8, bytes: []u8 }, eof: u8 }; @@ -338,10 +345,9 @@ const Queue = struct { fn readPtyThread(gpa: std.mem.Allocator, fd: c_int, pane: u8, q: *Queue) void { var buf: [0x10000]u8 = undefined; while (true) { - const rc = linux.read(fd, &buf, buf.len); - const n: isize = @bitCast(rc); + const n = libc.read(fd, &buf, buf.len); if (n < 0) { - if (n == -@as(isize, @intFromEnum(linux.E.INTR))) continue; + if (libc.errno(n) == .INTR) continue; break; // EIO when the child exits: treat as EOF } if (n == 0) break; @@ -622,7 +628,7 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void { var ptys: [pardes.MAX_PANES]?Pty = @splat(null); defer for (&ptys) |*slot| if (slot.*) |pt| { - _ = linux.close(pt.fd); + _ = libc.close(pt.fd); slot.* = null; }; var queue: Queue = .{ .gpa = gpa, .sdl_wake = true }; @@ -662,7 +668,7 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void { }, .eof => |id| { if (ptys[id]) |pt| { - _ = linux.close(pt.fd); + _ = libc.close(pt.fd); ptys[id] = null; } core.update(.{ .eof = .{ .pane = id } }); @@ -895,7 +901,7 @@ fn runGrid(init: std.process.Init, opts_in: pardes.Options) !void { var ptys: [pardes.MAX_PANES]?Pty = @splat(null); defer for (&ptys) |*slot| if (slot.*) |pt| { - _ = linux.close(pt.fd); + _ = libc.close(pt.fd); slot.* = null; }; var queue: Queue = .{ .gpa = gpa, .sdl_wake = false }; @@ -927,7 +933,7 @@ fn runGrid(init: std.process.Init, opts_in: pardes.Options) !void { }, .eof => |id| { if (ptys[id]) |pt| { - _ = linux.close(pt.fd); + _ = libc.close(pt.fd); ptys[id] = null; } core.update(.{ .eof = .{ .pane = id } }); @@ -981,8 +987,7 @@ const StdinFeed = struct { if ((fds[0].revents & (posix.POLL.HUP | posix.POLL.ERR)) != 0) out.eof = true; return out; } - const rc = linux.read(0, f.buf[f.fill..].ptr, f.buf.len - f.fill); - const rn: isize = @bitCast(rc); + const rn = libc.read(0, f.buf[f.fill..].ptr, f.buf.len - f.fill); if (rn < 0) return out; if (rn == 0) { out.eof = true; @@ -1433,12 +1438,7 @@ fn drainEffects( ptys[sp.pane] = pt; // report the pane's starting directory back to the core (tags) var lbuf: [1024]u8 = undefined; - var pbuf: [64]u8 = undefined; - if (std.fmt.bufPrintSentinel(&pbuf, "/proc/{d}/cwd", .{pt.pid}, 0) catch null) |path| { - const rc = linux.readlinkat(linux.AT.FDCWD, path, &lbuf, lbuf.len); - const n: isize = @bitCast(rc); - if (n > 0) core.setCwd(sp.pane, lbuf[0..@intCast(n)]); - } + if (look.shellCwd(pt.pid, &lbuf)) |wd| core.setCwd(sp.pane, wd); if (threads_ok) spawnReader(gpa, pt, sp.pane, queue); }, .write => |w| { @@ -1447,7 +1447,7 @@ fn drainEffects( .resize_pty => |rs| { if (ptys[rs.pane]) |pt| { const ws: posix.winsize = .{ .row = rs.rows, .col = rs.cols, .xpixel = 0, .ypixel = 0 }; - _ = posix.system.ioctl(pt.fd, posix.T.IOCSWINSZ, @intFromPtr(&ws)); + _ = posix.system.ioctl(pt.fd, TIOCSWINSZ, @intFromPtr(&ws)); } }, .open_link => {}, // native: look opens panes instead; nothing to launch @@ -1458,21 +1458,17 @@ fn drainEffects( if (f.path.len >= pathbuf.len) continue; @memcpy(pathbuf[0..f.path.len], f.path); pathbuf[f.path.len] = 0; - const rc = linux.open(pathbuf[0..f.path.len :0], .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, 0o644); - const sfd: isize = @bitCast(rc); - if (sfd < 0) continue; - const fd: c_int = @intCast(sfd); + const fd = libc.open(pathbuf[0..f.path.len :0], .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o644)); + if (fd < 0) continue; writeFd(fd, f.content); - _ = linux.close(fd); + _ = libc.close(fd); }, .write_dump => { const out = core.dump_out orelse continue; - const rc = linux.open(pardes.dump.default_path, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, 0o644); - const sfd: isize = @bitCast(rc); - if (sfd < 0) continue; - const fd: c_int = @intCast(sfd); + const fd = libc.open(pardes.dump.default_path, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o644)); + if (fd < 0) continue; writeFd(fd, out); - _ = linux.close(fd); + _ = libc.close(fd); }, .set_clipboard => { if (g == null) continue; @@ -1500,8 +1496,8 @@ fn forkShell(cwd: ?[*:0]const u8, rows: u16, cols: u16) Pty { const pid = forkpty(&master, null, null, &ws); if (pid == 0) { if (cwd) |cd| _ = chdir(cd); - const argv: [4:null]?[*:0]const u8 = .{ "/usr/bin/bash", "--rcfile", "/tmp/pardes-osc133.bash", null }; - _ = execv("/usr/bin/bash", &argv); + const argv: [4:null]?[*:0]const u8 = .{ bash_path, "--rcfile", "/tmp/pardes-osc133.bash", null }; + _ = execv(bash_path, &argv); _exit(127); } return .{ .fd = master, .pid = pid }; @@ -1510,11 +1506,7 @@ fn forkShell(cwd: ?[*:0]const u8, rows: u16, cols: u16) Pty { fn pollCwds(core: *pardes.Pardes, ptys: *[pardes.MAX_PANES]?Pty) void { for (ptys, 0..) |slot, id| if (slot) |pt| { var lbuf: [1024]u8 = undefined; - var pbuf: [64]u8 = undefined; - const path = std.fmt.bufPrintSentinel(&pbuf, "/proc/{d}/cwd", .{pt.pid}, 0) catch continue; - const rc = linux.readlinkat(linux.AT.FDCWD, path, &lbuf, lbuf.len); - const n: isize = @bitCast(rc); - if (n > 0) core.setCwd(id, lbuf[0..@intCast(n)]); + if (look.shellCwd(pt.pid, &lbuf)) |cwd| core.setCwd(id, cwd); }; } @@ -2073,11 +2065,9 @@ fn writeCapturePpm(g: *Gui, gpa: std.mem.Allocator, pixels: []const u8, width: u const tmp_path = std.fmt.bufPrintSentinel(&tmp_buf, "{s}/latest.ppm.tmp", .{g.capture_dir}, 0) catch return error.CapturePathTooLong; const final_path = std.fmt.bufPrintSentinel(&final_buf, "{s}/latest.ppm", .{g.capture_dir}, 0) catch return error.CapturePathTooLong; - const orc = linux.open(tmp_path, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, 0o644); - const sfd: isize = @bitCast(orc); - if (sfd < 0) return error.CaptureWriteFailed; - const fd: c_int = @intCast(sfd); - defer _ = linux.close(fd); + const fd = libc.open(tmp_path, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o644)); + if (fd < 0) return error.CaptureWriteFailed; + defer _ = libc.close(fd); var header: [64]u8 = undefined; const hdr = std.fmt.bufPrint(&header, "P6\n{d} {d}\n255\n", .{ width, height }) catch return error.CaptureWriteFailed; @@ -2096,9 +2086,7 @@ fn writeCapturePpm(g: *Gui, gpa: std.mem.Allocator, pixels: []const u8, width: u } writeFd(fd, row_rgb); } - const rrc = linux.rename(tmp_path, final_path); - const rs: isize = @bitCast(rrc); - if (rs < 0) return error.CaptureWriteFailed; + if (libc.rename(tmp_path, final_path) != 0) return error.CaptureWriteFailed; } // ===================================================================== @@ -2377,21 +2365,18 @@ fn envU16(env: *std.process.Environ.Map, name: []const u8) ?u16 { } fn writeFile(data: []const u8) void { - const rc = linux.open("/tmp/pardes-osc133.bash", .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, 0o644); - const sfd: isize = @bitCast(rc); - if (sfd < 0) return; - const fd: c_int = @intCast(sfd); - defer _ = linux.close(fd); + const fd = libc.open("/tmp/pardes-osc133.bash", .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o644)); + if (fd < 0) return; + defer _ = libc.close(fd); writeFd(fd, data); } fn writeFd(fd: c_int, data: []const u8) void { var off: usize = 0; while (off < data.len) { - const rc = linux.write(fd, data[off..].ptr, data.len - off); - const n: isize = @bitCast(rc); + const n = libc.write(fd, data[off..].ptr, data.len - off); if (n < 0) { - if (n == -@as(isize, @intFromEnum(linux.E.INTR))) continue; + if (libc.errno(n) == .INTR) continue; return; } off += @intCast(n); diff --git a/src/look.zig b/src/look.zig index 67fbf8c5..8744da24 100644 --- a/src/look.zig +++ b/src/look.zig @@ -8,7 +8,8 @@ //! web — there is no filesystem; only URLs mean anything, and a URL //! opens in a new tab (the open_link effect). const std = @import("std"); -const linux = std.os.linux; +const builtin = @import("builtin"); +const libc = std.c; const pardes = @import("pardes.zig"); extern "c" fn realpath(path: [*:0]const u8, resolved: [*]u8) ?[*:0]u8; @@ -88,10 +89,9 @@ const platform_has_fs = switch (pardes.platform) { /// true if `path` exists and is a directory (open(O_DIRECTORY), no stat needed) fn isDir(path: [*:0]const u8) bool { - const rc = linux.open(path, .{ .ACCMODE = .RDONLY, .DIRECTORY = true, .CLOEXEC = true }, 0); - const sfd: isize = @bitCast(rc); - if (sfd < 0) return false; - _ = linux.close(@intCast(sfd)); + const fd = libc.open(path, .{ .ACCMODE = .RDONLY, .DIRECTORY = true, .CLOEXEC = true }); + if (fd < 0) return false; + _ = libc.close(fd); return true; } @@ -101,19 +101,16 @@ pub fn readFile(gpa: std.mem.Allocator, path: []const u8) ![]u8 { if (!platform_has_fs) return error.NoFilesystem; var pathbuf: [4096]u8 = undefined; const path_z = std.fmt.bufPrintSentinel(&pathbuf, "{s}", .{path}, 0) catch return error.PathTooLong; - const rc = linux.open(path_z, .{ .ACCMODE = .RDONLY }, 0); - const sfd: isize = @bitCast(rc); - if (sfd < 0) return error.OpenFailed; - const fd: i32 = @intCast(sfd); - defer _ = linux.close(fd); + const fd = libc.open(path_z, .{ .ACCMODE = .RDONLY }); + if (fd < 0) return error.OpenFailed; + defer _ = libc.close(fd); var buf: std.ArrayList(u8) = .empty; errdefer buf.deinit(gpa); var chunk: [16384]u8 = undefined; while (true) { - const r = linux.read(fd, &chunk, chunk.len); - const n: isize = @bitCast(r); + const n = libc.read(fd, &chunk, chunk.len); if (n < 0) { - if (n == -@as(isize, @intFromEnum(linux.E.INTR))) continue; + if (libc.errno(n) == .INTR) continue; return error.ReadFailed; } if (n == 0) break; @@ -121,3 +118,44 @@ pub fn readFile(gpa: std.mem.Allocator, path: []const u8) ![]u8 { } return buf.toOwnedSlice(gpa); } + +// ---- shell cwd: what directory a pane's looks resolve against ---- + +// macOS has no /proc; libproc's proc_pidinfo(PROC_PIDVNODEPATHINFO) yields the +// cwd vnode path. Not in std.c — layout from xnu's sys/proc_info.h. +const vnode_info_path = extern struct { + vi: [152]u8 align(8), // struct vnode_info: vinfo_stat + type + pad + fsid + path: [1024]u8, // MAXPATHLEN +}; +const proc_vnodepathinfo = extern struct { + cdir: vnode_info_path, + rdir: vnode_info_path, +}; +const PROC_PIDVNODEPATHINFO: c_int = 9; +extern "c" fn proc_pidinfo(pid: c_int, flavor: c_int, arg: u64, buffer: *anyopaque, buffersize: c_int) c_int; + +/// Live cwd of a shell process (pane tags, look resolution). linux reads +/// /proc//cwd, darwin asks libproc; other POSIX systems have no cheap +/// answer — return null and panes keep their spawn-time cwd (callers already +/// tolerate failure: dead shells have no cwd either). +pub fn shellCwd(pid: libc.pid_t, buf: *[1024]u8) ?[]const u8 { + switch (builtin.os.tag) { + .linux => { + var pbuf: [64]u8 = undefined; + const path = std.fmt.bufPrintSentinel(&pbuf, "/proc/{d}/cwd", .{pid}, 0) catch return null; + const n = libc.readlink(path, buf, buf.len); + if (n <= 0) return null; + return buf[0..@intCast(n)]; + }, + .macos, .ios, .tvos, .watchos, .visionos => { + var info: proc_vnodepathinfo = undefined; + const n = proc_pidinfo(pid, PROC_PIDVNODEPATHINFO, 0, &info, @sizeOf(proc_vnodepathinfo)); + if (n < @as(c_int, @sizeOf(proc_vnodepathinfo))) return null; + const path = std.mem.sliceTo(&info.cdir.path, 0); + if (path.len == 0) return null; + @memcpy(buf[0..path.len], path); + return buf[0..path.len]; + }, + else => return null, + } +} diff --git a/src/tty.zig b/src/tty.zig index d9821343..9e2da26b 100644 --- a/src/tty.zig +++ b/src/tty.zig @@ -3,16 +3,24 @@ //! them, resize them), and hands the core's Surface to vaxis cell-for-cell — //! the canonical interface rendered with no interpretation. const std = @import("std"); +const builtin = @import("builtin"); const posix = std.posix; -const linux = std.os.linux; +const libc = std.c; const vaxis = @import("vaxis"); const pardes = @import("pardes.zig"); +const look = @import("look.zig"); extern "c" fn forkpty(amaster: *c_int, name: ?[*:0]u8, termp: ?*const anyopaque, winp: ?*const posix.winsize) c_int; extern "c" fn execv(path: [*:0]const u8, argv: [*:null]const ?[*:0]const u8) c_int; extern "c" fn chdir(path: [*:0]const u8) c_int; extern "c" fn _exit(status: c_int) noreturn; +// absolute shell path per OS: execv must not search PATH (no allocation +// between fork and exec) +const bash_path: [*:0]const u8 = if (builtin.os.tag == .linux) "/usr/bin/bash" else "/bin/bash"; +// TIOCSWINSZ: absent from std.c.T on darwin — _IOW('t', 103, winsize) +const TIOCSWINSZ: c_int = @bitCast(@as(u32, if (@hasDecl(posix.T, "IOCSWINSZ")) posix.T.IOCSWINSZ else 0x80087467)); + pub const Command = struct { pub var value: union(enum) { nop, @@ -54,7 +62,7 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { defer pardes.image.stop(); const core = if (opts.load_path) |lp| blk: { - const bytes = try @import("look.zig").readFile(gpa, lp); + const bytes = try look.readFile(gpa, lp); defer gpa.free(bytes); break :blk try pardes.Pardes.initFromDump(gpa, opts, bytes); } else try pardes.Pardes.init(gpa, opts); @@ -66,7 +74,7 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { var kitty_handles: [pardes.MAX_PANES]?vaxis.Image = @splat(null); var ptys: [pardes.MAX_PANES]?Pty = @splat(null); defer for (&ptys) |*slot| if (slot.*) |*pt| { - _ = linux.close(pt.file.handle); + _ = libc.close(pt.file.handle); slot.* = null; }; @@ -104,7 +112,7 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { }, .pty_eof => |id| { if (ptys[id]) |*pt| { - _ = linux.close(pt.file.handle); + _ = libc.close(pt.file.handle); ptys[id] = null; } core.update(.{ .eof = .{ .pane = @intCast(id) } }); @@ -146,14 +154,10 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { drainEffects(core, &ptys, io, gpa, &loop, &vx, &tty, true); if (core.quit) break; - // live cwd for tags/look: cheap /proc readlink per pane, per frame + // live cwd for tags/look: cheap per-pane lookup, per frame for (&ptys, 0..) |*slot, id| if (slot.*) |pt| { var lbuf: [1024]u8 = undefined; - var pbuf: [64]u8 = undefined; - const path = std.fmt.bufPrintSentinel(&pbuf, "/proc/{d}/cwd", .{pt.pid}, 0) catch continue; - const rc = linux.readlinkat(linux.AT.FDCWD, path, &lbuf, lbuf.len); - const n: isize = @bitCast(rc); - if (n > 0) core.setCwd(id, lbuf[0..@intCast(n)]); + if (look.shellCwd(pt.pid, &lbuf)) |cwd| core.setCwd(id, cwd); }; // ---- render: surface -> vaxis, cell for cell ---- @@ -220,12 +224,7 @@ fn drainEffects( ptys[sp.pane] = .{ .file = child.file, .pid = child.pid, .reader = .{ .any_future = null, .result = {} } }; // report the pane's starting directory back to the core (tags) var lbuf: [1024]u8 = undefined; - var pbuf: [64]u8 = undefined; - if (std.fmt.bufPrintSentinel(&pbuf, "/proc/{d}/cwd", .{child.pid}, 0) catch null) |path| { - const rc = linux.readlinkat(linux.AT.FDCWD, path, &lbuf, lbuf.len); - const n: isize = @bitCast(rc); - if (n > 0) core.setCwd(sp.pane, lbuf[0..@intCast(n)]); - } + if (look.shellCwd(child.pid, &lbuf)) |wd| core.setCwd(sp.pane, wd); if (threads_ok) { if (ptys[sp.pane]) |*pt| { pt.reader = io.concurrent(readPty, .{ io, gpa, pt.file, @as(usize, sp.pane), loop }) catch pt.reader; @@ -238,7 +237,7 @@ fn drainEffects( .resize_pty => |rs| { if (ptys[rs.pane]) |pt| { const ws: posix.winsize = .{ .row = rs.rows, .col = rs.cols, .xpixel = 0, .ypixel = 0 }; - _ = posix.system.ioctl(pt.file.handle, posix.T.IOCSWINSZ, @intFromPtr(&ws)); + _ = posix.system.ioctl(pt.file.handle, TIOCSWINSZ, @intFromPtr(&ws)); } }, .open_link => {}, // tty: no link handling (look opens panes instead) @@ -249,21 +248,17 @@ fn drainEffects( if (f.path.len >= pathbuf.len) continue; @memcpy(pathbuf[0..f.path.len], f.path); pathbuf[f.path.len] = 0; - const rc = linux.open(pathbuf[0..f.path.len :0], .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, 0o644); - const sfd: isize = @bitCast(rc); - if (sfd < 0) continue; - const fd: c_int = @intCast(sfd); + const fd = libc.open(pathbuf[0..f.path.len :0], .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o644)); + if (fd < 0) continue; writeFd(fd, f.content); - _ = linux.close(fd); + _ = libc.close(fd); }, .write_dump => { const out = core.dump_out orelse continue; - const rc = linux.open(pardes.dump.default_path, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, 0o644); - const sfd: isize = @bitCast(rc); - if (sfd < 0) continue; - const fd: c_int = @intCast(sfd); + const fd = libc.open(pardes.dump.default_path, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o644)); + if (fd < 0) continue; writeFd(fd, out); - _ = linux.close(fd); + _ = libc.close(fd); }, .set_clipboard => { // mirror the core's yank register out via OSC 52 @@ -280,8 +275,8 @@ fn forkShell(cwd: ?[*:0]const u8, rows: u16, cols: u16) struct { file: std.Io.Fi const pid = forkpty(&master, null, null, &ws); if (pid == 0) { if (cwd) |c| _ = chdir(c); - const argv: [4:null]?[*:0]const u8 = .{ "/usr/bin/bash", "--rcfile", "/tmp/pardes-osc133.bash", null }; - _ = execv("/usr/bin/bash", &argv); + const argv: [4:null]?[*:0]const u8 = .{ bash_path, "--rcfile", "/tmp/pardes-osc133.bash", null }; + _ = execv(bash_path, &argv); _exit(127); } return .{ .file = .{ .handle = master, .flags = .{ .nonblocking = false } }, .pid = pid }; @@ -364,21 +359,18 @@ fn vaxisColor(c: pardes.Color) vaxis.Color { } fn writeFile(data: []const u8) void { - const rc = linux.open("/tmp/pardes-osc133.bash", .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, 0o644); - const sfd: isize = @bitCast(rc); - if (sfd < 0) return; - const fd: c_int = @intCast(sfd); - defer _ = linux.close(fd); + const fd = libc.open("/tmp/pardes-osc133.bash", .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o644)); + if (fd < 0) return; + defer _ = libc.close(fd); writeFd(fd, data); } fn writeFd(fd: c_int, data: []const u8) void { var off: usize = 0; while (off < data.len) { - const rc = linux.write(fd, data[off..].ptr, data.len - off); - const n: isize = @bitCast(rc); + const n = libc.write(fd, data[off..].ptr, data.len - off); if (n < 0) { - if (n == -@as(isize, @intFromEnum(linux.E.INTR))) continue; + if (libc.errno(n) == .INTR) continue; return; } off += @intCast(n); diff --git a/test/e2e_harness.zig b/test/e2e_harness.zig index 6354aa4b..9099f13e 100644 --- a/test/e2e_harness.zig +++ b/test/e2e_harness.zig @@ -1,8 +1,11 @@ const std = @import("std"); const posix = std.posix; -const linux = std.os.linux; +const libc = std.c; const ghostty_vt = @import("ghostty-vt"); +// TIOCSWINSZ: absent from std.c.T on darwin — _IOW('t', 103, winsize) +const TIOCSWINSZ: c_int = @bitCast(@as(u32, if (@hasDecl(posix.T, "IOCSWINSZ")) posix.T.IOCSWINSZ else 0x80087467)); + extern "c" fn forkpty( amaster: *c_int, name: ?[*:0]u8, @@ -13,9 +16,9 @@ extern "c" fn execvp(file: [*:0]const u8, argv: [*:null]const ?[*:0]const u8) c_ extern "c" fn _exit(status: c_int) noreturn; fn nowMs() i64 { - var ts: linux.timespec = undefined; - _ = linux.clock_gettime(linux.CLOCK.MONOTONIC, &ts); - return ts.sec * 1000 + @divFloor(@as(i64, ts.nsec), 1_000_000); + var ts: libc.timespec = undefined; + _ = libc.clock_gettime(.MONOTONIC, &ts); + return @as(i64, @intCast(ts.sec)) * 1000 + @divFloor(@as(i64, @intCast(ts.nsec)), 1_000_000); } /// E2E test harness: forks the app in a pty and parses its output with a @@ -92,7 +95,7 @@ pub const Harness = struct { pub fn deinit(self: *Harness) void { posix.kill(self.pid, posix.SIG.KILL) catch {}; - _ = linux.close(self.master); + _ = libc.close(self.master); self.stream.deinit(); self.raw.deinit(self.gpa); self.term.deinit(self.gpa); @@ -103,10 +106,9 @@ pub const Harness = struct { pub fn send(self: *Harness, bytes: []const u8) !void { var off: usize = 0; while (off < bytes.len) { - const rc = linux.write(self.master, bytes[off..].ptr, bytes.len - off); - const n: isize = @bitCast(rc); + const n = libc.write(self.master, bytes[off..].ptr, bytes.len - off); if (n < 0) { - if (n == -@as(isize, @intFromEnum(linux.E.INTR))) continue; + if (libc.errno(n) == .INTR) continue; return error.WriteFailed; } off += @intCast(n); @@ -139,7 +141,7 @@ pub const Harness = struct { self.cols = cols; self.term.resize(self.gpa, cols, rows) catch {}; const ws = posix.winsize{ .row = rows, .col = cols, .xpixel = 0, .ypixel = 0 }; - _ = posix.system.ioctl(self.master, posix.T.IOCSWINSZ, @intFromPtr(&ws)); + _ = posix.system.ioctl(self.master, TIOCSWINSZ, @intFromPtr(&ws)); } /// The full viewport text (plainString), newline-joined rows. Caller frees. @@ -261,18 +263,15 @@ pub const Harness = struct { } }; -/// Write a file (helper for test setup, raw linux syscalls). +/// Write a file (helper for test setup, raw libc). pub fn writeFile(path: [*:0]const u8, data: []const u8) !void { - const rc = linux.open(path, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, 0o644); - const sfd: isize = @bitCast(rc); - if (sfd < 0) return error.OpenFailed; - const fd: c_int = @intCast(sfd); - defer _ = linux.close(fd); + const fd = libc.open(path, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o644)); + if (fd < 0) return error.OpenFailed; + defer _ = libc.close(fd); var off: usize = 0; while (off < data.len) { - const w = linux.write(fd, data[off..].ptr, data.len - off); - const sw: isize = @bitCast(w); - if (sw <= 0) break; - off += @intCast(sw); + const w = libc.write(fd, data[off..].ptr, data.len - off); + if (w <= 0) break; + off += @intCast(w); } } diff --git a/test/snapshot.zig b/test/snapshot.zig index c1bf03c0..10aa0c86 100644 --- a/test/snapshot.zig +++ b/test/snapshot.zig @@ -28,8 +28,7 @@ // snap