summaryrefslogtreecommitdiff
path: root/src/main.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-06 18:11:36 -0300
committerGabriel Schneider <[email protected]>2026-09-07 13:59:12 -0300
commit60367d8fe23f6af98ec28e3cf6c2094dfe332df0 (patch)
tree310fc734173cf771881f4691c71909135fadde97 /src/main.zig
parentfa82cac885cb4738fe36d1e49b4749b5a3e31a4a (diff)
downloadpardes-60367d8fe23f6af98ec28e3cf6c2094dfe332df0.tar.gz
pardes-60367d8fe23f6af98ec28e3cf6c2094dfe332df0.zip
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.
Diffstat (limited to 'src/main.zig')
-rw-r--r--src/main.zig459
1 files changed, 129 insertions, 330 deletions
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 `<config dir>/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/<pid>, and export
- \\ PARDES_FS and PARDES_PANE into every pane shell
- \\ --fs=<dir> ...at <dir> 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-<name>.sock, where
- \\ <name> is the session name or this pid. Dial it
- \\ with `9p -a <socket>` or mount with `9pfuse`.
- \\ Independent of --fs: either, both or neither
- \\ --fs9=<name> ...named <name> 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> 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=<dial> also serve 9P at tcp!<numeric-ip>!<port>.
+ \\ --9p-quic=<dial> also serve at quic!<numeric-ip>!<port> (-Dquic=true).
+ \\ --mount=<name>=<dial> mount a 9P session or Unix/TCP/QUIC endpoint under /n/<name>.
+ \\ 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=<name> ...named <name> 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[=<name>]`: 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[=<name>]`, 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=<dir>` and NEVER `--fs <dir>`, 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=<name>", .{});
+ } 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!<numeric-ip>!<port>", .{});
+ _ = @import("9p_io.zig").networkAddress(dial, true) catch
+ badArgs(init.io, "--9p-tcp needs tcp!<numeric-ip>!<port>", .{});
+ 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!<numeric-ip>!<port>", .{});
+ } 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!<numeric-ip>!<port>", .{});
+ _ = @import("9p_io.zig").networkAddress(dial, true) catch
+ badArgs(init.io, "--9p-quic needs quic!<numeric-ip>!<port>", .{});
+ 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!<numeric-ip>!<port>", .{});
+ } 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=<name>` and never `--detach <name>`, 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=<name>` and never `--attach <name>`, 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");