summaryrefslogtreecommitdiff
path: root/src/main.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-26 18:58:37 -0300
committerGabriel Schneider <[email protected]>2026-08-27 09:47:39 -0300
commit29ac9be75fdcafbd7d05c15aa9eb8490d74caa98 (patch)
tree6629cc215d6953090f6b29a7414b28cb9990e105 /src/main.zig
parent11f380f6d7222f2cad93c2cdf13701ea1f903d47 (diff)
downloadpardes-29ac9be75fdcafbd7d05c15aa9eb8490d74caa98.tar.gz
pardes-29ac9be75fdcafbd7d05c15aa9eb8490d74caa98.zip
An edited row keeps its colours, four copies of forkShell become one, and Esc stops recentring
## A terminal row's ANSI colours survive being edited The loudest colour bug this editor had: one keystroke anywhere in a coloured shell row turned EVERY column of it grey. `EditAnchors` anchored a buffer line only when it was BYTE-IDENTICAL to the shell row it stood over, so a single differing byte dropped the whole row's colour projection. Worst shape is invisible: append past the pane's right edge, where the text is clipped, and the row looks the same and only its colour goes. Anchoring is byte-level now. An edit leaves the row's own bytes at both ends, and being the same bytes they keep the same colours; only what was typed has no cell under it, so only that takes none. Live, on real `fastfetch`: a 32-column blue run split into 6 + 26 around one typed character. Three defects underneath it, all found by machinery rather than by reading: * A JOIN removes a buffer line while the buffer's covered span grows, so `lines == covered` and both aligned guesses — Nth line over the Nth covered row, and the same counted from the bottom — resolved to the SAME wrong row. Every untouched row below a join went plain. Anchoring is now a streaming monotone matching: one shell-row cursor that only ever moves forward, advanced once per buffer line, linear in the buffer where the version before it was quadratic. * An EMPTY line is not evidence. Splitting a row makes one, it equals every blank row in the span, and left free to look ahead it claimed the blank row below the last output and took every coloured row in between out of reach of the lines that owned them. * Reflow under a scrolled viewport. `PageList.getTopLeft(.viewport)` returns the viewport pin verbatim, x and all, while `PageList.pin` forces x to 0 — so after a reflow remapped a tracked pin into the middle of a row, the text pass dumped row 0 from that column while the colour pass paired the fragment with the row's FIRST cells. Row 0 wore its left half's colours until the pane snapped back to live output. `bodyText` dumps from column zero now, which is also what ghostty's own renderer draws. Also here: DECSCNM (reverse video) was silently dropped whenever `tty_filter` was off, because the raw path resolved a `.none` colour by role and never consulted the mode. The test that found the first two is the one worth keeping: random editing against an ABSOLUTE oracle — every row's own text names the colour it must have — because the differential oracle it replaced was blind by construction. It skipped the edited row, which is the row the user is complaining about. ## Esc returns to a pane without moving its view Esc in body normal mode runs `Last`, "the pane you were in before this one", and that went through `focusPaneLine`, which recentred a file on the target line unconditionally. So returning to a buffer repainted the whole screen to show a line that was already on it. `focusPaneLine` takes a landing now: `.center` for the three callers going somewhere you have not been (a look target, a path a pane already holds, `@pN:LINE:COL`), `.keep` for Esc. `.keep` leaves the view alone and lets `ensureCursorVisible` — which already existed and already scrolls by the minimum into the `scroll_off` band — be the only thing that may move anything. Not `line = 0`, which `focusPaneLine` already understands as "focus and touch nothing": a background pane's view can move while you are away, because the wheel scrolls the pane under the POINTER and a resize reveals no cursor, so the recorded cursor plus a minimal nudge is what actually gets you back. Ctrl-o and Ctrl-i keep centring, and the asymmetry is structural rather than arbitrary: `Last` only ever CROSSES panes, so the pane it lands on already holds the view you left it with, while `jumpBy` can land in the SAME pane, where a long in-file jump would arrive on the very top or bottom row with `scroll_off` lines of context on one side. Helix splits the same pair the same way — its jumplist centres, its buffer switch does not. One deliberate consequence: under `.keep` a PDF's page is not restored AT ALL, because a page reveal IS that pane's view and a reveal of the page you are already on still snaps `document_scroll_y` to that page's start, discarding where you had read to. When something moved the pane while you were away — the wheel again — Esc leaves it where the wheel left it, and Ctrl-o is how you reach the recorded page. ## host_io.zig: the machine-local half of a host, once `host.zig` is the seam. The part of the answer that is identical on every host with an operating system under it — fork a pane's shell, put bytes on a disk — was written FOUR times: in tty.zig, gui.zig, macos.zig and detached/server.zig. What those copies had in common says what they were for: all four were missing FD_CLOEXEC on the pty master, so in every shell pardes has shipped, a program in one pane could read another pane's terminal. One copy now, and the wire got smaller for it: `ServerMsg.spawn` is gone. A frontend never asked the server to fork anything — the server has an operating system under it and forks through `host_io` like every other host — and `decodeClient` lost the scratch buffer that message needed.
Diffstat (limited to 'src/main.zig')
-rw-r--r--src/main.zig59
1 files changed, 47 insertions, 12 deletions
diff --git a/src/main.zig b/src/main.zig
index 08beae86..b8cb6b2d 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -93,15 +93,30 @@ const help_text =
\\ a frontend can say which session it wants. One
\\ path component: no '/' and nothing empty
\\ --attach become a frontend of the one detached session
- \\ that is running: draw its screen, send it input,
- \\ fork its pane shells. Several frontends may be
- \\ attached at once and all see the same screen
+ \\ that is running: draw its screen and send it
+ \\ input, and nothing else — the session owns its
+ \\ pane shells, its files and its watches. Works in
+ \\ both the terminal and the SDL window build.
+ \\ Several frontends may be attached at once and all
+ \\ see the same screen
\\ --attach=<name> ...of the session called <name>, which is what to
\\ use when more than one is running
\\ -h, --help show this help and exit
+ \\ --version print the version and exit
\\
;
+/// 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
+ "pardes " ++ pardes.version ++ "\n";
+
const nested_text =
\\pardes: this shell is already inside pardes, and a pardes inside a pardes
\\is spicy. Name a file or a directory and the outer session opens it, or
@@ -129,9 +144,10 @@ fn emscriptenMain(argc: c_int, argv: [*]?[*:0]u8) callconv(.c) c_int {
return 0;
}
-// no argv in the browser: options stay default, state comes from the dump
+// no argv in the browser: options stay default, state comes from the dump, and
+// there is no session to attach to — a browser tab has no unix socket.
fn webMain() !void {
- try @import("gui/gui.zig").run(.{});
+ try @import("gui/gui.zig").run(.{}, .{}, null);
}
fn nativeMain(init: std.process.Init) !void {
@@ -159,8 +175,8 @@ fn nativeMain(init: std.process.Init) !void {
// free to mean "the default name" the way opts.fs uses it for a directory.
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" (tty.zig
- // `sessionName`) rather than a session with no name.
+ // 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
@@ -216,6 +232,9 @@ fn nativeMain(init: std.process.Init) !void {
} 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);
return;
+ } else if (std.mem.eql(u8, a, "--version")) {
+ try std.Io.File.stdout().writeStreamingAll(init.io, version_text);
+ return;
} else if (a.len > 0 and a[0] != '-' and positional == null) {
positional = a;
} else {
@@ -285,6 +304,19 @@ fn nativeMain(init: std.process.Init) !void {
// reading. Refused rather than resolved by declaration order, which would
// silently drop whichever flag lost.
if (detach != null and attach != null) return error.BadArgs;
+ // `--fs` mounts the acme control filesystem, and only a LOCAL session has
+ // one: `fs_service.start` is called inside tty.zig's `localSession` and
+ // gui.zig's equivalent, both of which an `--attach` skips entirely, and
+ // `detached/server.zig` never reads `opts.fs` at all. So `--fs` with either
+ // of these was parsed, stored, and then served by nobody.
+ //
+ // Refused for the same reason as the line above, and it is the stronger
+ // case: a contradiction is at least visible, whereas a silently dropped
+ // mount is invisible until someone waits for a directory that will never
+ // appear. Serving it instead would mean mounting FUSE in the detached core,
+ // which is a feature rather than a fix — `push_fs_reply` is one of the five
+ // host methods that core deliberately leaves null (docs/detached.md).
+ if (opts.fs != null and (detach != null or attach != null)) return error.BadArgs;
// `--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
@@ -300,15 +332,18 @@ fn nativeMain(init: std.process.Init) !void {
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 only the tty one knows how to be one today.
- // Comptime-folded, so a tty build carries none of this.
- if (attach != null and pardes.platform != .tty) {
- try std.Io.File.stderr().writeStreamingAll(init.io, "pardes: --attach needs the tty shell\n");
+ // 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);
}
switch (pardes.platform) {
.tty => try @import("tty/tty.zig").run(init, opts, attach),
- .gui => try @import("gui/gui.zig").run(init, opts),
+ .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,