From 60367d8fe23f6af98ec28e3cf6c2094dfe332df0 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Sun, 6 Sep 2026 18:11:36 -0300 Subject: Refactor panes and filesystem; replace FUSE with 9P Consolidate pane, layout, memory and host code. Serve 9P by default over Unix sockets, with runtime mounts and optional TCP/QUIC transports. Remove FUSE and obsolete proof-of-concept examples. Fix highlighting and terminal-history performance, expand differential and stress-test infrastructure, sort navigation results while preserving the next occurrence, add syntax-colored Braille minimaps, remove SPC-k, and document 9P interaction as a repository skill. --- src/main.zig | 459 +++++++++++++++++------------------------------------------ 1 file changed, 129 insertions(+), 330 deletions(-) (limited to 'src/main.zig') diff --git a/src/main.zig b/src/main.zig index b08a64da..e4aef61e 100644 --- a/src/main.zig +++ b/src/main.zig @@ -1,15 +1,12 @@ const std = @import("std"); const builtin = @import("builtin"); const pardes = @import("pardes.zig"); -const nested = @import("nested.zig"); +const ninep_io = @import("9p_io.zig"); const is_emscripten = builtin.os.tag == .emscripten; extern "c" fn emscripten_console_error(utf8: [*:0]const u8) void; -// emscripten: std.debug's default threaded-io singleton doesn't run on wasm; -// fail it (init errors surface via emscripten_console_error below). Native -// values match the std defaults. Same shim as the prototype's replay.zig. pub const std_options_debug_threaded_io: ?*std.Io.Threaded = if (is_emscripten) null else @@ -19,18 +16,6 @@ pub const std_options_debug_io: std.Io = if (is_emscripten) else std_options_debug_threaded_io.?.io(); -// Every std.log call in the process — ours and every dependency's — funnels -// through this one function. ghostty-vt narrates whatever it does not -// implement in the bytes a child writes to its pty (`debug(stream)`, -// `warning(stream): ignoring unimplemented CSI p`, `debug(kitty_gfx)`; opening -// yazi is worth several lines before it has drawn anything), and in the tty -// shell stderr IS the screen — those land on top of the rendered grid, and in -// the gui/web shells on the console. So drop the libraries at every level: an -// `err` painted over the UI is no better than a debug one. Only pardes' own -// scopes get through, because their messages carry detail the error returns -// don't (gui's SDL_GetError strings, dump's zon parse diagnostic) — .default -// is NOT one of them, ghostty and uucode both log unscoped. Set PARDES_LOG to -// get the real logger back: `PARDES_LOG=1 pardes 2>/tmp/pardes.log`. pub const std_options: std.Options = .{ .logFn = logFn }; fn logFn( @@ -43,40 +28,16 @@ fn logFn( std.log.defaultLog(level, scope, format, args); } -// A panic must restore the terminal (cooked mode, main screen, mouse off) -// before the trace prints, or it lands garbled in a raw alt screen. recover() -// no-ops unless the vaxis tty is live, so gui/tty share the handler. -// -// Then the same message and trace are appended to `/crashes` -// BEFORE stderr gets them, because stderr is the one place this program cannot -// keep them: see crash.zig. Silent on every failure, so the fallback is -// exactly the behaviour that was here before. pub const panic = if (is_emscripten) std.debug.FullPanic(std.debug.defaultPanic) else std.debug.FullPanic(struct { fn call(msg: []const u8, ret_addr: ?usize) noreturn { - // ONCE. This handler is re-entered whenever something panics while it - // runs, and std's own trace printer does exactly that — `defaultPanic` - // survives its own recursion through a private `panic_stage`, and - // everything up here is in front of that guard. `recover()` is not - // idempotent: it closes the vaxis tty and never clears the global that - // says there is one, so a second call double-closes, which std answers - // with `recoverableOsBugDetected` and an `unreachable` in a Debug - // build. Measured with the crash file in place: one panic left TWO - // records, the real message and then "reached unreachable code" from - // this line under it. if (!recovering.swap(true, .seq_cst)) @import("vaxis").recover(); @import("crash.zig").record(msg); std.debug.defaultPanic(msg, ret_addr); } }.call); -/// Whether this process has already restored its terminal — see `panic` above, -/// and note that `debug.handleSegfault` below shares it: a SIGSEGV raised while -/// the panic handler runs must not double-close either. var recovering: std.atomic.Value(bool) = .init(false); -// Fatal signals (SIGSEGV/SIGILL/SIGBUS/SIGFPE) bypass the panic handler and -// no defer/errdefer ever runs — hook std.debug's segfault path the same way -// so the terminal is restored before the trace prints. pub const debug = if (is_emscripten) struct {} else struct { pub fn handleSegfault(addr: ?usize, name: []const u8, opt_ctx: anytype) noreturn { if (!recovering.swap(true, .seq_cst)) @import("vaxis").recover(); @@ -101,23 +62,18 @@ const help_text = \\ Without it, a pardes started inside a pardes \\ hands its FILE argument to the outer one. This \\ session will not serve its own children either. - \\ --fs serve acme's control filesystem for this session - \\ under $XDG_RUNTIME_DIR/pardes/, and export - \\ PARDES_FS and PARDES_PANE into every pane shell - \\ --fs= ...at instead. Must be absolute; pardes - \\ unmounts it on exit but leaves the directory - \\ --fs9 serve that same tree over 9P2000 on a unix socket - \\ at $XDG_RUNTIME_DIR/pardes-9p-.sock, where - \\ is the session name or this pid. Dial it - \\ with `9p -a ` or mount with `9pfuse`. - \\ Independent of --fs: either, both or neither - \\ --fs9= ...named rather than derived. One path - \\ component: no '/' and nothing empty. Served by - \\ --detach sessions today; the tty and GUI shells - \\ still take --fs only + \\ Explicit session configuration starts a new one. + \\ --9p= name the default 9P socket. Without this flag, + \\ the name is the detached session name or pid. + \\ The socket is under $XDG_RUNTIME_DIR, falling + \\ back to ~/.local/state/pardes. + \\ --9p-tcp= also serve 9P at tcp!!. + \\ --9p-quic= also serve at quic!! (-Dquic=true). + \\ --mount== mount a 9P session or Unix/TCP/QUIC endpoint under /n/. + \\ Repeat for more mounts; os and self are reserved. \\ --detach run this session with NO terminal of its own, - \\ serving frontends over a unix socket beside the - \\ nested-instance one. The core, the panes and the + \\ serving frontends over a unix socket beside its + \\ 9P socket. The core, the panes and the \\ undo history outlive every frontend that attaches \\ --detach= ...named rather than this process's pid, so \\ a frontend can say which session it wants. One @@ -136,18 +92,6 @@ const help_text = \\ ; -/// A MISTAKE AT THE SHELL PROMPT, said in words and nothing else. -/// -/// Every one of these used to be `return error.BadArgs` out of `main`, which -/// std prints as `error: BadArgs` with a RETURN TRACE under it. That reads as a -/// crash — it is the same shape a real panic has — for the most ordinary thing -/// a person can do, which is mistype a flag. It also said `BadArgs` and nothing -/// about WHICH argument, when the site that returned it knew exactly. -/// -/// stderr and not an `+Errors` pane, which is the answer one line down in -/// `Pardes.init`: argv is read before any core exists, and a person who typed -/// a bad flag is looking at the prompt they typed it into rather than at an -/// editor. `--attach`'s refusal three functions down already answers this way. fn badArgs(io: std.Io, comptime fmt: []const u8, args: anytype) noreturn { var buf: [1024]u8 = undefined; const line = std.fmt.bufPrint(&buf, "pardes: " ++ fmt ++ "\nTry 'pardes --help'.\n", args) catch @@ -156,12 +100,6 @@ fn badArgs(io: std.Io, comptime fmt: []const u8, args: anytype) noreturn { std.process.exit(1); } -/// Built at COMPTIME, because both halves are: `version` comes out of -/// `build.zig.zon` through the options module and `commit` out of `git` at -/// configure time, so there is nothing here to format at runtime and no buffer -/// to size. The commit is in parentheses when there is one and absent -/// otherwise — a build from a tarball says `pardes 0.0.1` and is not lying -/// about a revision it never had. See `pardes.version`/`pardes.commit`. const version_text = if (pardes.commit) |c| "pardes " ++ pardes.version ++ " (" ++ c ++ ")\n" else @@ -201,116 +139,108 @@ fn webMain() !void { } fn nativeMain(init: std.process.Init) !void { - // FIRST, before anything can log or panic. std's start code hands the - // debug/log stderr writer the process environ exactly as the kernel laid - // it out beside argv, and `std.debug.lockStderr` scans that block once, - // lazily, for NO_COLOR and CLICOLOR_FORCE. Lazily is the problem: libc - // may have rewritten the block by then. SDL_CreateWindow does, every - // time — `Wayland_ShowWindow` unsets XDG_ACTIVATION_TOKEN, and glibc's - // unsetenv compacts `environ` in place and leaves a null in the slot the - // captured length still counts. `Environ.scan` unwraps that null, so the - // FIRST log line or panic in the gui shell panicked inside the scan, and - // then the panic handler took the same path and deadlocked on the stderr - // lock it was already holding: "attempt to use null value" and a hang, - // with no trace and no message of its own. Every SDL_GetError report in - // gui.zig was that, which is to say unreachable. - // - // Locking and immediately unlocking here does the scan while the block is - // still the one std was given, and memoizes it. Nothing is written. var stderr_probe: [64]u8 = undefined; _ = std.debug.lockStderr(&stderr_probe); std.debug.unlockStderr(); var opts: pardes.Options = .{}; + var mounts: [pardes.filesystem.max_mounts]pardes.filesystem.Mount = undefined; + var mounts_len: usize = 0; const arena = init.arena.allocator(); const args = try init.minimal.args.toSlice(arena); - // bare `pardes` boots straight into tty mode — and so does a `pardes` - // carrying nothing but flags that say something about the SESSION rather - // than about its layout: --nested is about this session's relationship to - // its parent, --fs is about who may script it, --detach is about who may - // WATCH it, --attach is about whose screen this one is showing, and none of - // the four says anything about what should be on screen. Anything else (a - // FILE, -n, --tty) is layout, and answers this question itself further - // down. - opts.tty_only = for (args[1..]) |a| { - if (!std.mem.eql(u8, a, "--nested") and - !std.mem.eql(u8, a, "--fs") and - !std.mem.startsWith(u8, a, "--fs=") and - !std.mem.eql(u8, a, "--fs9") and - !std.mem.startsWith(u8, a, "--fs9=") and - !std.mem.eql(u8, a, "--detach") and - !std.mem.startsWith(u8, a, "--detach=") and - !std.mem.eql(u8, a, "--attach") and - !std.mem.startsWith(u8, a, "--attach=")) break false; - } else true; - // `--detach[=]`: null when it was not given, so the empty string is - // free to mean "the default name" the way opts.fs uses it for a directory. + var session_only = true; + var new_session = false; + var explicit_tty = false; var detach: ?[]const u8 = null; - // ...and `--attach[=]`, the same shape: null when it was not given, - // and the empty string means "the one session there is" - // (detached_client.resolve) rather than a session with no name. var attach: ?[]const u8 = null; - // Kept RAW until every flag is parsed: classifying it means chdir'ing into - // a directory and recording nothing, and the nested client below still - // needs the word itself to resolve. var positional: ?[:0]const u8 = null; var i: usize = 1; while (i < args.len) : (i += 1) { const a = args[i]; if (std.mem.eql(u8, a, "--tty")) { - opts.tty_only = true; + explicit_tty = true; } else if (std.mem.startsWith(u8, a, "--tty-toggle=")) { + session_only = false; + new_session = true; opts.tty_toggle = parseCtrlKey(a["--tty-toggle=".len..]) orelse badArgs(init.io, "--tty-toggle wants one letter, not '{s}'", .{a["--tty-toggle=".len..]}); } else if (std.mem.eql(u8, a, "--tty-toggle")) { + session_only = false; + new_session = true; i += 1; if (i >= args.len) badArgs(init.io, "--tty-toggle needs a letter after it", .{}); opts.tty_toggle = parseCtrlKey(args[i]) orelse badArgs(init.io, "--tty-toggle wants one letter, not '{s}'", .{args[i]}); } else if (std.mem.eql(u8, a, "-n")) { + session_only = false; + new_session = true; i += 1; if (i >= args.len) badArgs(init.io, "-n needs a count after it: 1 or 3", .{}); opts.shells = std.fmt.parseInt(u8, args[i], 10) catch badArgs(init.io, "-n takes 1 or 3, not '{s}'", .{args[i]}); } else if (std.mem.eql(u8, a, "-l")) { + session_only = false; + new_session = true; i += 1; if (i >= args.len) badArgs(init.io, "-l needs the path of a dump to load", .{}); opts.load_path = args[i]; - } else if (std.mem.eql(u8, a, "--fs")) { - opts.fs = ""; - } else if (std.mem.startsWith(u8, a, "--fs=")) { - // `--fs=` and NEVER `--fs `, which is the one place this - // parser cannot follow --tty-toggle: --tty-toggle's argument is - // mandatory, so consuming the next word is unambiguous. `--fs` is - // useful bare, so a two-word form would make `pardes --fs README` - // mount at ./README and open no file — the flag would silently eat - // the FILE argument. One spelling, and it carries its own value. - opts.fs = a["--fs=".len..]; - } else if (std.mem.eql(u8, a, "--fs9")) { - opts.fs9 = ""; - } else if (std.mem.startsWith(u8, a, "--fs9=")) { - // One spelling that carries its own value, for the reason `--fs` - // gives directly above: the flag is useful bare, so a two-word - // form would make `pardes --fs9 README` a socket called README - // that opens no file. - opts.fs9 = a["--fs9=".len..]; + } else if (std.mem.eql(u8, a, "--9p")) { + badArgs(init.io, "--9p needs a socket name: --9p=", .{}); + } else if (std.mem.startsWith(u8, a, "--9p=")) { + new_session = true; + const name = a["--9p=".len..]; + if (name.len == 0 or std.mem.indexOfAny(u8, name, "/\x00") != null) + badArgs(init.io, "invalid 9P socket name: '{s}'", .{name}); + opts.ninep_name = name; + } else if (std.mem.startsWith(u8, a, "--9p-tcp=")) { + new_session = true; + const dial = a["--9p-tcp=".len..]; + if (!std.mem.startsWith(u8, dial, "tcp!")) + badArgs(init.io, "--9p-tcp needs tcp!!", .{}); + _ = @import("9p_io.zig").networkAddress(dial, true) catch + badArgs(init.io, "--9p-tcp needs tcp!!", .{}); + if (opts.ninep_tcp != null) badArgs(init.io, "--9p-tcp was specified twice", .{}); + opts.ninep_tcp = dial; + } else if (std.mem.eql(u8, a, "--9p-tcp")) { + badArgs(init.io, "--9p-tcp needs tcp!!", .{}); + } else if (std.mem.startsWith(u8, a, "--9p-quic=")) { + new_session = true; + const dial = a["--9p-quic=".len..]; + if (!std.mem.startsWith(u8, dial, "quic!")) + badArgs(init.io, "--9p-quic needs quic!!", .{}); + _ = @import("9p_io.zig").networkAddress(dial, true) catch + badArgs(init.io, "--9p-quic needs quic!!", .{}); + if (opts.ninep_quic != null) badArgs(init.io, "--9p-quic was specified twice", .{}); + if (!@import("9p_io.zig").quic_enabled) + badArgs(init.io, "QUIC is not included; rebuild with -Dquic=true", .{}); + opts.ninep_quic = dial; + } else if (std.mem.eql(u8, a, "--9p-quic")) { + badArgs(init.io, "--9p-quic needs quic!!", .{}); + } else if (std.mem.startsWith(u8, a, "--mount=")) { + new_session = true; + const mount = a["--mount=".len..]; + const split = std.mem.indexOfScalar(u8, mount, '=') orelse + badArgs(init.io, "--mount needs name=dial", .{}); + const name = mount[0..split]; + const dial = mount[split + 1 ..]; + if (dial.len == 0 or !@import("fs.zig").validMountName(name)) + badArgs(init.io, "invalid mount name or dial: '{s}'", .{mount}); + @import("9p_io.zig").Client.validateDial(dial) catch + badArgs(init.io, "invalid mount dial: '{s}'", .{dial}); + for (mounts[0..mounts_len]) |existing| if (std.mem.eql(u8, existing.name, name)) + badArgs(init.io, "duplicate mount name: '{s}'", .{name}); + if (mounts_len == mounts.len) badArgs(init.io, "too many mounts (maximum {d})", .{mounts.len}); + mounts[mounts_len] = .{ .name = name, .dial = dial }; + mounts_len += 1; } else if (std.mem.eql(u8, a, "--nested")) { opts.nested = true; } else if (std.mem.eql(u8, a, "--detach")) { detach = ""; } else if (std.mem.startsWith(u8, a, "--detach=")) { - // `--detach=` and never `--detach `, for exactly the - // reason --fs gives above: the flag is useful bare, so a two-word - // form would make `pardes --detach README` a session called README - // that opens no file. detach = a["--detach=".len..]; } else if (std.mem.eql(u8, a, "--attach")) { attach = ""; } else if (std.mem.startsWith(u8, a, "--attach=")) { - // `--attach=` and never `--attach `, for the reason - // --fs states above and --detach repeats: the flag is useful bare, - // so a two-word form would make `pardes --attach README` an attach - // to a session called README that opens no file. attach = a["--attach=".len..]; } else if (std.mem.eql(u8, a, "-h") or std.mem.eql(u8, a, "--help")) { try std.Io.File.stdout().writeStreamingAll(init.io, help_text); @@ -319,6 +249,7 @@ fn nativeMain(init: std.process.Init) !void { try std.Io.File.stdout().writeStreamingAll(init.io, version_text); return; } else if (a.len > 0 and a[0] != '-' and positional == null) { + session_only = false; positional = a; } else if (a.len == 0) { badArgs(init.io, "an empty argument names nothing", .{}); @@ -328,170 +259,86 @@ fn nativeMain(init: std.process.Init) !void { badArgs(init.io, "one file or directory at a time, and '{s}' is the second", .{a}); } } - // Started INSIDE another pardes: hand it the file and get out of the way - // rather than stacking a second full-screen UI inside one of its panes. - // The word is resolved here rather than sent raw because the outer - // instance resolves against ITS panes' directories, which are not ours. - // A word naming nothing on disk is REFUSED HERE, in this shell, and does - // not fall through: the classification below used to refuse it too, and - // once it started booting an `+Errors` pane instead, a typo became the one - // input that stacked the second full-screen UI this whole block exists to - // prevent — and one with no shell pane in it, so the only way out is `Del`. - // The outer instance is not told either: `Look` on a word naming nothing - // is not something to do to somebody else's session. - // - // `--detach` is exempt for the same reason `--nested` is, arrived at from - // the other side: it stacks no UI at all. A detached session started from a - // pane is a session, not a request that the outer instance open something, - // and handing it our positional would leave the caller with no session. - // - // `--attach` is exempt for the mirror of that: it stacks a UI, but the UI - // is a session that already exists somewhere else, and handing our word to - // the outer instance would open the file in the WRONG session and leave - // the caller with no frontend. - if (!opts.nested and detach == null and attach == null) if (nested.outer()) |outer_pid| { + opts.tty_only = explicit_tty or session_only; + opts.mounts = mounts[0..mounts_len]; + if (detach != null and attach != null) + badArgs(init.io, "--detach and --attach are opposites: one runs the session, the other joins one", .{}); + if (opts.ninep_name.len != 0 and attach != null) + badArgs(init.io, "--9p names a session's own socket, and --attach has none of its own", .{}); + if (opts.ninep_tcp != null and attach != null) + badArgs(init.io, "--9p-tcp opens a session's own listener, and --attach has none of its own", .{}); + if (opts.ninep_quic != null and attach != null) + badArgs(init.io, "--9p-quic opens a session's own listener, and --attach has none of its own", .{}); + if (opts.mounts.len != 0 and attach != null) + badArgs(init.io, "--mount configures a session's own core, and --attach has none of its own", .{}); + if (!new_session and !opts.nested and detach == null and attach == null) forwarding: { + const enabled = std.c.getenv("PARDES_FORWARD_LOOK") orelse break :forwarding; + if (!std.mem.eql(u8, std.mem.span(enabled), "1")) break :forwarding; + const dial = std.mem.span(std.c.getenv("PARDES_9P") orelse break :forwarding); + ninep_io.Client.validateDial(dial) catch break :forwarding; + const pane_text = std.mem.span(std.c.getenv("PARDES_PANE") orelse break :forwarding); + for (pane_text) |byte| if (!std.ascii.isDigit(byte)) break :forwarding; + const serial = std.fmt.parseInt(u32, pane_text, 10) catch break :forwarding; + if (serial == 0) break :forwarding; + var ctl_buf: [64]u8 = undefined; + const ctl = try std.fmt.bufPrint(&ctl_buf, "/self/pane/{d}/ctl", .{serial}); const word = positional orelse { + var tag_buf: [64]u8 = undefined; + const tag = try std.fmt.bufPrint(&tag_buf, "/self/pane/{d}/tag", .{serial}); + const contents = ninep_io.Client.read(arena, dial, tag, tag) catch break :forwarding; + arena.free(contents); try std.Io.File.stderr().writeStreamingAll(init.io, nested_text); std.process.exit(1); }; - var cwdbuf: [4096]u8 = undefined; - const cwd = std.c.getcwd(&cwdbuf, cwdbuf.len) orelse - badArgs(init.io, "this shell's working directory is gone; cd somewhere that exists", .{}); + if (std.mem.indexOfAny(u8, word, "\r\n") != null) break :forwarding; + const target = @import("look.zig").parsePathLine(word); var realbuf: [4096]u8 = undefined; - const sent = switch (@import("look.zig").resolve(word, std.mem.span(@as([*:0]u8, @ptrCast(cwd))), &realbuf)) { - .dir => |d| nested.sendLook(outer_pid, d, 0), - .file => |t| nested.sendLook(outer_pid, t.path, t.at.line), - .image => |t| nested.sendLook(outer_pid, t.path, 0), - // Nothing of that name. One line on this shell's stderr and out, - // which is what the paragraph above promises: the outer session is - // not disturbed and no UI is stacked. Said in words rather than - // returned as an error, because an error out of `main` is the - // stack trace this release stopped showing people for a typo. - .none => { - var buf: [4096]u8 = undefined; - const line = std.fmt.bufPrint(&buf, "pardes: file or directory not found: {s}\n", .{word}) catch "pardes: file or directory not found\n"; - try std.Io.File.stderr().writeStreamingAll(init.io, line); - std.process.exit(1); - }, - // an unreachable outer instance (an older build, a stale socket - // path) is not worth failing a launch over: run normally instead - else => false, - }; - if (sent) return; - }; + const path = if (pardes.filesystem.isVirtual(target.path)) target.path else (pardes.filesystem.resolveOs(target.path, &realbuf) orelse break :forwarding).path; + var command_buf: [8192]u8 = undefined; + const command = std.fmt.bufPrint(&command_buf, "look {s}{s}\n", .{ path, word[target.path.len..] }) catch break :forwarding; + ninep_io.Client.write(arena, dial, ctl, command) catch break :forwarding; + return; + } if (positional) |a| { - // a directory becomes the cwd shells spawn in (chdir succeeds only on - // dirs); anything else resolves as a file - if (std.c.chdir(a.ptr) != 0) { + const target = @import("look.zig").parsePathLine(a); + if (std.mem.eql(u8, target.path, "/n") or std.mem.startsWith(u8, target.path, "/n/") or + std.mem.eql(u8, target.path, "/virtual") or std.mem.startsWith(u8, target.path, "/virtual/")) + { + opts.file = try arena.dupe(u8, target.path); + opts.file_line = target.at.line; + } else if (std.c.chdir(a.ptr) != 0) { var cwdbuf: [4096]u8 = undefined; const cwd = std.c.getcwd(&cwdbuf, cwdbuf.len) orelse - badArgs(init.io, "this shell's working directory is gone; cd somewhere that exists", .{}); + badArgs(init.io, "this shell's working directory is gone; cd somewhere that exists", .{}); var realbuf: [4096]u8 = undefined; - switch (@import("look.zig").resolve(a, std.mem.span(@as([*:0]u8, @ptrCast(cwd))), &realbuf)) { + switch (@import("look.zig").resolve(null, a, std.mem.span(@as([*:0]u8, @ptrCast(cwd))), &realbuf)) { .file => |t| { opts.file = try arena.dupe(u8, t.path); opts.file_line = t.at.line; }, .image => |t| opts.file = try arena.dupe(u8, t.path), - // Nothing of that name is there. A typo is not a reason to - // refuse to start: the session boots with one `+Errors` pane - // naming what was asked for (pardes.zig `missing`). - // - // The LAUNCH DIRECTORY goes with it, and it is not decoration: - // an output pane's directory is where a `Grep` from it walks, - // where a `Newtty` spawns its shell and what a `Save` prefills. - // The first draft passed "" — copied from the board's boot - // buffer, which can afford it because that platform has no - // filesystem — and the pane came out at `/+Errors`, so `Grep` - // on the boot screen walked from the root of the filesystem. - // - // The other arms stay `BadArgs`. `.url` and `.pane` are targets - // no LAUNCH can act on, and `.dir` here is a directory that - // resolves but `chdir` refused, which is a permission problem - // rather than a typo. NOTE that the commonest permission case - // does not arrive here at all: `look.isDir` probes with - // `O_DIRECTORY|O_RDONLY`, so a directory you cannot read (say - // `/root`) fails that probe, resolves as `.file`, and dies in - // `file_pane.open` with `error.OpenFailed` out of `main` — - // still a stack trace at a human. Left as it was, because it is - // a different fault than the one this arm fixes. .none => opts.missing = .{ .word = try arena.dupe(u8, a), .dir = try arena.dupe(u8, std.mem.span(@as([*:0]u8, @ptrCast(cwd)))), }, - // A URL, an `@pN` pane address, or a directory that resolves - // and `chdir` refused. None is a typo, and none is something a - // LAUNCH can act on — the first two are words to click once - // pardes is open, and the third is a permission problem. .url => badArgs(init.io, "a URL is not something a launch can open; start pardes and click it", .{}), .pane => badArgs(init.io, "@pN addresses a pane of a running pardes, so there is none yet", .{}), else => badArgs(init.io, "cannot enter that directory: {s}", .{a}), } } } - // Native shells opt into the user config; direct core callers and web keep - // Options' null default. Read it before entering either frontend so every - // builtin has run before that frontend can render its first frame. - const found = @import("user_config.zig").load(init.io, arena, init.environ_map); + const found = pardes.config.User.load(init.io, arena, init.environ_map); opts.startup_config = found.bytes; opts.startup_config_path = found.path; opts.config_dir = found.dir; - // The panic handler above writes beside that init file, and this is the - // only place it can learn where that is — it runs with no `Options` in - // reach. Set for every native entry through this file, including the - // `--detach` daemon below, whose stderr nobody is reading. if (found.dir) |d| @import("crash.zig").setDir(d); - // `--detach` is the core with no terminal and `--attach` is a terminal - // with no core, so the two together are a contradiction with no useful - // reading. Refused rather than resolved by declaration order, which would - // silently drop whichever flag lost. - if (detach != null and attach != null) - badArgs(init.io, "--detach and --attach are opposites: one runs the session, the other joins one", .{}); - // `--fs` mounts the acme control filesystem, and it needs a CORE to serve. - // `--attach` has none — it is a terminal whose state lives in another - // process — so the flag there would be parsed, stored, and served by - // nobody. Refused rather than dropped, and it is the stronger case of the - // line above: a contradiction is at least visible, whereas a silently - // dropped mount is invisible until someone waits for a directory that will - // never appear. - // - // `--detach` used to be refused here too, and is not any more. The reason - // given was that `push_fs_reply` was one of the host methods the detached - // core deliberately left null; it no longer is. A daemon mounts its own - // /dev/fuse and polls it in the same `poll(2)` as its frontends and its - // pane shells, which costs it one descriptor and no thread — strictly less - // than the desktop shells pay. `--detach --fs` is now the configuration - // that most wants a control filesystem, because it is the one whose panes - // outlive every terminal that could otherwise have scripted them. - if (opts.fs != null and attach != null) - badArgs(init.io, "--fs serves a session's own core, and --attach has none of its own", .{}); - // ...and `--fs9` for exactly that reason and no other: it is the same tree - // over a different transport, and an `--attach` has no core to serve it - // from either. Checked separately rather than folded into the line above - // so that neither flag's refusal is a side effect of the other's — they - // are independent everywhere else. - if (opts.fs9 != null and attach != null) - badArgs(init.io, "--fs9 serves a session's own core, and --attach has none of its own", .{}); - // `--detach` replaces the frontend rather than choosing among them: the - // core runs here, with no terminal, and the frontends are elsewhere on a - // socket (src/detached/). It is checked before `platform` because it is not - // a shell — the tty and gui builds can both be asked for one. if (detach) |name| { - // Bare `--detach` is named by this process's pid, which is the one name - // nobody has to be told and no two sessions can share. Unsigned: `{d}` - // prints a leading '+' for a positive SIGNED int, which is nested.zig's - // note about the same cast. const named = if (name.len != 0) name else try std.fmt.allocPrint(arena, "{d}", .{@as(u32, @intCast(std.c.getpid()))}); return @import("detached/server.zig").run(init, opts, named); } - // A frontend is a SHELL, and the two native ones — a terminal and an SDL - // window — both know how to be one. The browser has no unix socket to - // reach a session over and the AppKit shell is entered by its own host - // rather than through this file, so neither is wired for it; the check is - // comptime-folded, so a tty or gui build carries none of it. if (attach != null and pardes.platform != .tty and pardes.platform != .gui) { try std.Io.File.stderr().writeStreamingAll(init.io, "pardes: --attach needs the tty or gui shell\n"); std.process.exit(1); @@ -499,11 +346,6 @@ fn nativeMain(init: std.process.Init) !void { switch (pardes.platform) { .tty => try @import("tty/tty.zig").run(init, opts, attach), .gui => try @import("gui/gui.zig").run(init, opts, attach), - // Every other shell is entered by its host and never links this file - // at all: the browser through src/web.zig, the macOS app through - // src/macos.zig, and the ESP32-P4 firmware through src/esp32p4/app.zig, - // which is a root of its own in this repository and links the - // `pardes-esp32p4` object over the C ABI in src/esp32p4.zig. .web, .macos, .esp32p4 => unreachable, } } @@ -523,65 +365,22 @@ fn parseCtrlKey(raw: []const u8) ?u21 { return c; } -// The shells are imported inside main(), which a test build never analyses — -// so their inline tests need naming here to exist at all. Each shell only -// compiles when selected (GUI @cImports SDL; TTY imports vaxis), hence the -// comptime gates. Naming a file gets THAT file's tests and no further: -// fonts.zig is imported by gui.zig, builtins.zig and the macOS host, and still -// needs its own line here. test { - _ = @import("user_config.zig"); - // Reached only from the panic handler and from `nativeMain`, neither of - // which a test build analyses — so without this line the crash file has no - // test at all. + _ = pardes.config.User; _ = @import("crash.zig"); - _ = @import("allocators.zig"); - _ = @import("fs_service.zig"); - // acme's control filesystem, both halves, and NOT their own b.addTest - // modules in build.zig the way temp_file/nested/fonts are: both reach - // src/pardes.zig (acmefs takes a *Pardes, fuse.zig speaks its Req/Reply), - // so a standalone module would have to re-wire ghostty-vt, tree-sitter, the - // themes and every option the core imports. This module already has them. - // - // fuse.zig genuinely needs its name here (nothing the core analyses reaches - // it — only the shells import it). acmefs.zig does not today, because - // pardes.zig re-exports it unconditionally; it is named anyway, because the - // day that re-export grows a comptime gate is the day 23 tests disappear in - // silence. That is the fonts.zig story above, told once already. - _ = @import("acmefs.zig"); - _ = @import("fuse.zig"); - // The detached-session transport (src/detached/), same story as fuse.zig - // above: it speaks the core's Event/Surface, so it belongs in THIS module - // rather than a standalone b.addTest, and nothing the core analyses reaches - // it. A TTY build does — tty.zig imports client.zig for `--attach` — but - // these names are what makes the transport's tests exist in every other - // build too, and `--detach` is not a tty-only feature. - // client.zig's own tests drive a real `Session` over a real socket, so - // naming it reaches server.zig too — but server.zig is named anyway, for - // the acmefs.zig reason: the day client.zig stops importing it is the day - // those tests vanish in silence. + _ = @import("memory.zig"); + _ = @import("fs.zig"); _ = @import("detached/wire.zig"); _ = @import("detached/server.zig"); _ = @import("detached/client.zig"); - // The 9P listener, and it needs its name here for the reason the - // panel_compositor line below states rather than the fuse.zig one above: - // detached/server.zig imports it, but naming a file does not make Zig - // analyse the tests of what IT imports — measured, by three tests that - // compiled and never ran. A standalone b.addTest is not an option either, - // because this file reaches pardes.zig (`Server(acmefs)`); src/9p.zig, the - // half that does NOT, has one in build.zig. - _ = @import("fs9_service.zig"); - // Its client twin, and it needs its name here for the same reason with the - // same measurement behind it: builtins.zig imports it for the `9p` word, - // and naming a file does not make Zig analyse the tests of what IT - // imports. The protocol half's tests are in src/9p.zig's own b.addTest; - // these are the host's — the argument split, the two dial spellings, and - // the immediate refusal when nothing is listening. - _ = @import("fs9_client.zig"); + _ = @import("9p_io.zig"); + _ = @import("9p_io.zig").Client; + _ = @import("host_io.zig"); + _ = @import("host_io.zig").Shell; + _ = @import("host_io.zig").Lsp; + _ = @import("lsp/lsp_client.zig"); if (comptime pardes.platform == .tty) { _ = @import("tty/tty.zig"); - // tty.zig calls the compositor only from its runtime loop, so merely - // naming the shell does not make Zig analyse the compositor's tests. _ = @import("tty/panel_compositor.zig"); } if (comptime pardes.platform == .gui) _ = @import("gui/gui.zig"); -- cgit v1.3