summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-20 00:36:50 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:14 -0300
commitc1990b3e6e196ad41379aa432bf3ccca8a65d9f0 (patch)
treed7ad5afe559f7f56464b14c3122242fa3f16399b
parent717afaf3177a2e0925b18ae3445119efe808b129 (diff)
downloadpardes-c1990b3e6e196ad41379aa432bf3ccca8a65d9f0.tar.gz
pardes-c1990b3e6e196ad41379aa432bf3ccca8a65d9f0.zip
Flatten the 9P control tree and move it out of fs.zig
The served tree loses the self/ level: /index /ctl /new /log /screen /listeners /pane/<n>/... /os, with /src only in -Dembed-sources=true builds (default off, on for esp32p4). ctl speaks the editor's own language with two lowercase verbs, look TEXT and exec TEXT, plus acme's addr verbs; the new/ factory directory becomes one clone file; cons is gone (exec Msg); name and sel are files; stats report real lengths, modes and mtimes; /log streams pane new/del/rename/save events. The tree code lives in src/ninep/ (tree, pane, ctl, addr, pty, events, screen, sources); fs.zig keeps host access, mounts, resolution and find/grep. Same engine and transports. README (fs-help.txt) and docs rewritten; tests updated and extended. Co-Authored-By: Claude Fable 5.1 <[email protected]>
-rw-r--r--build.zig6
-rw-r--r--docs/config.md3
-rw-r--r--docs/fs.md118
-rw-r--r--docs/v9fs.md12
-rw-r--r--src/9p.zig2
-rw-r--r--src/9p_io.zig39
-rw-r--r--src/CHANGELOG.md10
-rw-r--r--src/effect_sources.zig1
-rw-r--r--src/fs-help.txt97
-rw-r--r--src/fs.zig3594
-rw-r--r--src/main.zig4
-rw-r--r--src/memory.zig5
-rw-r--r--src/ninep/addr.zig276
-rw-r--r--src/ninep/ctl.zig503
-rw-r--r--src/ninep/events.zig558
-rw-r--r--src/ninep/pane.zig840
-rw-r--r--src/ninep/pty.zig388
-rw-r--r--src/ninep/screen.zig351
-rw-r--r--src/ninep/sources.zig152
-rw-r--r--src/ninep/testing.zig135
-rw-r--r--src/ninep/tree.zig850
-rw-r--r--src/panes.zig7
-rw-r--r--src/pardes.zig34
-rw-r--r--src/tutor.txt25
-rw-r--r--test/agent_session.py14
-rw-r--r--test/appearance_gallery.py26
-rw-r--r--test/collapse_layout.py8
-rw-r--r--test/column_tags.py32
-rw-r--r--test/font_size.py2
-rw-r--r--test/fs.py367
-rw-r--r--test/fs_bench.zig43
-rw-r--r--test/fs_namespace.zig12
-rw-r--r--test/fs_soak.py29
-rw-r--r--test/look_placement.py8
-rw-r--r--test/ninep.py39
-rw-r--r--test/output.zig12
-rw-r--r--test/panes.zig8
-rw-r--r--test/pdf_scroll_bench.zig18
-rw-r--r--test/snapshot.zig2
-rw-r--r--test/tag_cleanup.py10
-rw-r--r--test/ui_review.py50
-rw-r--r--test/v9fs.py33
-rw-r--r--test/v9fs_terminal.py6
-rw-r--r--test/window_opacity.py12
44 files changed, 4772 insertions, 3969 deletions
diff --git a/build.zig b/build.zig
index aeae7443..6734d9b0 100644
--- a/build.zig
+++ b/build.zig
@@ -104,6 +104,7 @@ pub fn build(b: *std.Build) void {
ninep_options.addOption(bool, "quic", enable_quic);
const enable_mupdf = b.option(bool, "mupdf", "native PDF rendering with MuPDF (AGPL/commercial; native default on, web/esp32p4 off; -Dmupdf=false disables)") orelse !freestanding_core;
const enable_jpx = b.option(bool, "jpx", "JPEG 2000 in PDFs, for scanned documents (default on; -Djpx=false drops openjpeg)") orelse true;
+ const embed_sources = b.option(bool, "embed-sources", "serve the editor's own sources under /src of its 9P tree (default off; on for -Dplatform=esp32p4, whose only source is the device)") orelse is_esp32p4;
const is_web_target = target.result.cpu.arch == .wasm32 and target.result.os.tag == .freestanding;
const is_esp32p4_target = target.result.cpu.arch == .riscv32 and target.result.os.tag == .freestanding;
const optimize = if (is_web)
@@ -309,6 +310,7 @@ pub fn build(b: *std.Build) void {
.esp32p4_rows = esp32p4_rows,
.theme_animation = theme_animation,
.prebuilt_shaders = prebuilt_shaders,
+ .embed_sources = embed_sources,
.zig_lib_dir = b.graph.zig_lib_directory.path orelse "",
.commit = gitCommit(b),
};
@@ -600,8 +602,6 @@ pub fn build(b: *std.Build) void {
if (shell == .macos) mod.addAnonymousImport("effect-source-crt.ci.metal", .{
.root_source_file = b.path("shaders/crt.ci.metal"),
});
- mod.addAnonymousImport("root-build.zig", .{ .root_source_file = b.path("build.zig") });
- mod.addAnonymousImport("root-build.zig.zon", .{ .root_source_file = b.path("build.zig.zon") });
}
const gui_shell_mod: ?*std.Build.Module = gui_mod orelse
@@ -1435,6 +1435,7 @@ const ShellConfig = struct {
/// the default is off for the board and on everywhere else.
theme_animation: ?bool,
prebuilt_shaders: bool,
+ embed_sources: bool,
zig_lib_dir: []const u8,
commit: ?[]const u8,
};
@@ -1456,6 +1457,7 @@ fn shellOptions(b: *std.Build, cfg: ShellConfig, platform: Platform) *std.Build.
// prevents Config/EffectCode from describing tty/macOS/web as "prebuilt".
o.addOption(bool, "gui_shader_sources_prebuilt", platform == .gui and cfg.prebuilt_shaders);
o.addOption([]const u8, "zls_version", if (cfg.zls_backend) zls_version else "none");
+ o.addOption(bool, "embed_sources", cfg.embed_sources);
o.addOption([]const u8, "version", zon.version);
// ...and the commit it was built from, when there is one. See `gitCommit`
// for why this is optional and why it is read at configure time.
diff --git a/docs/config.md b/docs/config.md
index 972f9403..a55aef1c 100644
--- a/docs/config.md
+++ b/docs/config.md
@@ -496,7 +496,8 @@ palette; Ripple and Glitch primarily perturb sample coordinates.
`EffectCode PanelAscii` or `EffectCode Crt` lists the current backend's
build-embedded source paths under `/virtual`. Look opens each full file;
-no checkout is needed. TTY exposes grid transitions, native GUI builds also
+no checkout is needed, but the build must carry them (`-Dembed-sources=true`,
+the default only for esp32p4); otherwise the command reports them unavailable. TTY exposes grid transitions, native GUI builds also
expose scene shaders, and web has neither. Shared implementations share paths.
SDL reports whether GLSL was compiled during this build or came from the
`-Dprebuilt-shaders` snapshot paired with the committed SPIR-V.
diff --git a/docs/fs.md b/docs/fs.md
index 942a26a3..c16f810b 100644
--- a/docs/fs.md
+++ b/docs/fs.md
@@ -16,9 +16,13 @@ Explicit paths bypass that search:
| Editor path | Meaning | 9P server path |
|---|---|---|
| `/n/os/proc/self` | OS filesystem | `/os/proc/self` |
-| `/n/self/pane/2/body` | pane 2's text | `/self/pane/2/body` |
-| `/virtual/src/pardes.zig` | source embedded in this build | `/self/src/pardes.zig` |
-| `/n/peer/self/pane/2/body` | another session's text | peer's `/self/pane/2/body` |
+| `/n/self/pane/2/body` | pane 2's text | `/pane/2/body` |
+| `/virtual/pane/2/body` | the same, in the editor's own spelling | `/pane/2/body` |
+| `/virtual/src/pardes.zig` | source embedded in this build | `/src/pardes.zig` |
+| `/n/peer/pane/2/body` | another session's text | peer's `/pane/2/body` |
+
+The mount name `self` is reserved and maps to the server root, so `/n/self/X`
+and `/virtual/X` both name the served `/X`.
`--mount=peer=work` mounts the named session `work`; the dial can also be an
absolute socket path, `unix!/path`, `tcp!IP!port`, or `quic!IP!port`.
@@ -31,7 +35,7 @@ pending Save. Mounts are saved in dumps. Save uses the file's original mount.
socket. Build with `-Dquic=true` and system OpenSSL 3.6+ to enable QUIC;
`--9p-quic='quic!127.0.0.1!5641'` adds its listener. Both accept numeric
IPv4/IPv6 addresses, not DNS names. Listener port zero chooses a free port;
-`/self/listeners` reports all active dial addresses.
+`/listeners` reports all active dial addresses.
All connections have session access, including `os`. TCP is unencrypted.
QUIC uses an ephemeral TLS identity without peer verification or login.
@@ -40,56 +44,108 @@ The four application connection slots are shared across transports;
OpenSSL's internal buffers are separate, dynamically allocated memory.
[Plan9port's client](https://9fans.github.io/plan9port/man/man1/9p.html) can
-drive Unix or TCP without a kernel mount:
+drive Unix or TCP without a kernel mount, and `9ns` mounts the tree in a
+private namespace:
```sh
-9p -n -a "unix!$PARDES_9P" read self/index
-9p -n -a 'tcp!127.0.0.1!5640' read self/index
+9p -n -a "unix!$PARDES_9P" read index
+9p -n -a 'tcp!127.0.0.1!5640' ls pane/1
+9ns --unix "$PARDES_9P" -- sh -c 'cat "$NINE_MOUNT/index"'
```
For [Linux v9fs](https://www.kernel.org/doc/html/latest/filesystems/9p.html),
use `version=9p2000,cache=none,access=any` and `trans=unix`, or `trans=tcp`
with `port=5640`. Set `uname`, `dfltuid`, and `dfltgid` for the local user.
-Leave `aname` empty: the mount root contains `os` and `self`. The opt-in
+Leave `aname` empty. The opt-in
[Linux v9fs experiment](v9fs.md) tests a kernel mount in a separate subprocess
namespace (`zig build v9fs-test`, requiring explicit mount authorization).
Neither 9P2000.u nor 9P2000.L is implemented.
Existing Plan9port/v9fs clients need a userspace bridge for QUIC.
-The server root contains `os` and `self`. Under `self`, `index` lists panes,
-`README` explains the interface. `new/` lists its creation endpoints and another
-`README`; listing, walking and statting entries do not create panes. Opening
-`new/ctl` creates a pane and returns its serial, and `pane/<serial>` contains
-`body`, `tag`, `ctl`, `addr`, `data`, `event`, and selection files. Terminal
-panes additionally have `pty/{ctl,status,data}`.
+## The served tree
+
+```
+/README this guide, also src/fs-help.txt
+/index one line per pane: serial, kind (text|term|pdf|image), dirty flag, name
+/ctl write: one command per line; read: the serials the last command made or touched
+/new reading it creates one empty pane and answers "<serial>\n" (the /net/tcp/clone idiom)
+/log one line per editor event: new|del|rename|save <serial> <name>; reads park
+/screen rendered screen JSON; frozen per open handle
+/listeners the session's dial addresses
+/pane/<n>/ name body tag ctl addr data xdata sel errors event, plus pty/{ctl,status,data}
+/os/ the host filesystem
+/src/ the editor's embedded sources, only when built with -Dembed-sources=true
+```
+
+Nothing in the tree is created by list, stat, walk or read, except that
+reading `/new` makes a pane; that is its whole purpose, so a recursive read
+of the tree makes one pane per open of it.
-`EffectCode <effect>` lists the current backend's embedded implementation
-files under `/virtual`; Look opens their full source.
+`/ctl` and `/pane/<n>/ctl` take the editor's own command language, two verbs:
-`self/screen` returns JSON with `cols`, `rows`, `cursor`, a `styles` table,
-and row-major `cells` of `[grapheme, style_index]`. Each open freezes one
-frame until close, including across multiple reads. The independent client
-can inspect it directly with `Client(socket_path).screen()`.
+- `look TEXT` is a right click on `TEXT`: a path opens a file, `file:12` jumps
+ to a line, a directory opens a shell there, a URL opens in the browser. From
+ `/ctl` the look happens at the active pane; from a pane's `ctl` at that pane.
+- `exec TEXT` is a middle click: a command word from `src/builtins.zig`
+ (`Save`, `Del`, `New`, `Newcol`, `Mount NAME DIAL`, `Unmount NAME`, `Dump`,
+ `Restore`, `Msg TEXT`, `Find`, `Grep`, `Tty`, ...), or anything else, which
+ runs in the pane's terminal.
-A terminal `body` freezes its history on the first read of each open handle;
-later reads use the same bytes while output continues. Close and reopen for
-newer history, or read `pty/data` for the live stream. Screen and terminal-body
-snapshots share 32 handle slots, released on close or disconnect.
+Both verbs are lowercase; the words after `exec` are the editor's capitalized
+commands. A pane's `ctl` also keeps acme's addr verbs (`addr=dot`, `dot=addr`,
+`limit=addr`, `clean`, `dirty`, `cleartag`, `get`, `mark`, `nomark`,
+`noscroll`, `scroll`, `show`). Every line of a write is checked before any
+line runs; a malformed line fails the write with EINVAL. A command that fails
+inside the editor is reported on the message row, not as a write error.
-Writing `body` appends; opening it with truncation replaces its contents.
-`addr` selects a range and `data` replaces that range. `ctl` accepts `name`,
-`look <word>`, `get`, `put`, `del`, and `delete`. Look resolves from that pane
-without editing its body or tag. Holding `event` open redirects the pane's Look
+After a write to either `ctl`, reading `/ctl` answers the serials of the panes
+the command created, or, when it created none, the pane a look focused or the
+pane an exec acted on (even one it closed), one per line. Before any command it answers `pid`, `version` and `panes` lines.
+
+`/pane/<n>/name` reads the pane's file name (a terminal's directory) and
+writing it renames the buffer; a relative name resolves against the pane's
+directory. `sel` reads the editor selection and writing it replaces the
+selection. `body` appends on write and replaces on truncating open. `addr`
+selects a range and `data` or `xdata` read or replace it. `errors` appends to
+the directory's `+Errors` pane. Holding `event` open redirects the pane's Look
and Exec clicks to that client; writing a record back performs the action.
+Stats report real lengths for `index`, `ctl`, `name`, `body`, `tag` and `sel`,
+modes 0644/0666 (0444 for read-only files, 0222 for write-only), the pane's
+last edit time or the process start as mtime, and stable qids. Directory
+entries carry no sizes; stat the entry.
+
+`/log` records `new`, `del`, `rename` and `save` while at least one client
+holds it open; a read parks until a record arrives. `/screen` returns JSON with
+`cols`, `rows`, `cursor`, a `styles` table, and row-major `cells` of
+`[grapheme, style_index]`. Each open freezes one frame until close. A
+terminal `body` freezes its history on the first read of each open handle;
+`pty/data` streams live output. Screen and terminal-body snapshots share 32
+handle slots, released on close or disconnect.
+
+`-Dembed-sources=true` embeds the editor's sources and serves them under
+`/src` (and `/shaders` on GUI builds). `EffectCode <effect>` lists the current
+backend's implementation files under `/virtual`, which Look opens; without the
+option the command reports the sources as unavailable. The esp32p4 build
+enables the option by default, so the device can serve its own source.
+
This is a control filesystem, not a complete POSIX export. Native filenames
may contain up to 255 bytes. Existing regular OS files support read, write,
and truncation to zero; protocol create, remove, rename, and other metadata
-changes are refused. Ownership, permissions, and timestamps are synthetic.
+changes are refused. Ownership and permissions under `/os` are synthetic.
Zero-length truncation accepts the accompanying `mtime` hint sent by Linux
v9fs; the hint is not stored. Standalone timestamp changes remain refused.
+The tree lives in `src/ninep/`: `tree.zig` (nodes, lookup, readdir, dispatch,
+the backend contract), `pane.zig` (pane files), `ctl.zig`, `addr.zig`,
+`pty.zig`, `events.zig` (event and log streams), `screen.zig` and
+`sources.zig`. The protocol engine is `src/9p.zig`, the transports
+`src/9p_io.zig`; `src/fs.zig` keeps host access, mounts, resolution, find and
+grep.
+
`zig build fs-test` drives real sessions using the independent Python client
-in `test/ninep.py`. `zig build 9p-test` checks the freestanding wire protocol;
-`zig build fs-bench` measures filesystem transactions in the core.
+in `test/ninep.py`; `zig build fs-discovery-test` checks that browsing creates
+nothing and that `new`, `ctl`, `name`, `sel` and `log` behave. `zig build
+9p-test` checks the freestanding wire protocol; `zig build fs-bench` measures
+filesystem transactions in the core.
`zig build fs-test quic-test -Dquic=true` also exercises QUIC mounts and I/O.
diff --git a/docs/v9fs.md b/docs/v9fs.md
index 51b35a46..6cb4e998 100644
--- a/docs/v9fs.md
+++ b/docs/v9fs.md
@@ -8,11 +8,11 @@ your configured shell as your normal user, with your account's supplementary
groups. The shell receives `PARDES_MOUNT`, the absolute mountpoint:
```sh
-ls "$PARDES_MOUNT/self/pane"
-cat "$PARDES_MOUNT/self/index"
-cat "$PARDES_MOUNT/self/README"
-ls -l "$PARDES_MOUNT/self/new"
-cat "$PARDES_MOUNT/self/pane/$PARDES_PANE/body"
+ls "$PARDES_MOUNT/pane"
+cat "$PARDES_MOUNT/index"
+cat "$PARDES_MOUNT/README"
+cat "$PARDES_MOUNT/pane/$PARDES_PANE/body"
+echo 'exec Msg hello' > "$PARDES_MOUNT/ctl"
```
The mount belongs to that pane's subprocess tree. Other panes and the editor
@@ -96,7 +96,7 @@ unprivileged and uses `sudo -n`, retaining the calling terminal's authorization.
Missing authorization or kernel support fails the test instead of skipping it.
It checks mount isolation, privilege dropping, inherited access, directory
refresh, body reads and truncation, independent wire updates, addressed edits,
-shell redirection to ctl, Exec dispatch, rendered screen JSON, and OS-file reads.
+shell redirection to name, Exec dispatch, rendered screen JSON, and OS-file reads.
Linux follows `O_TRUNC` with a `Twstat` carrying zero length and an `mtime` hint.
Pardes accepts this truncation without storing caller-selected timestamps;
diff --git a/src/9p.zig b/src/9p.zig
index 2948b9ed..3cffbffc 100644
--- a/src/9p.zig
+++ b/src/9p.zig
@@ -894,7 +894,7 @@ pub fn Server(comptime fs: type, comptime fid_capacity: usize) type {
.qid = qidOf(if (a.node != 0) a.node else f.node, a.dir),
.mode = (if (a.dir) dmdir else 0) | @as(u32, a.mode),
.atime = 0,
- .mtime = 0,
+ .mtime = if (@hasField(@TypeOf(a), "mtime")) a.mtime else 0,
.length = a.size,
.name = f.name[0..f.name_len],
.uid = who,
diff --git a/src/9p_io.zig b/src/9p_io.zig
index 943561eb..d0db1c86 100644
--- a/src/9p_io.zig
+++ b/src/9p_io.zig
@@ -138,7 +138,7 @@ fn localIp(address: std.Io.net.IpAddress) bool {
return false;
}
-const Srv = ninep.Server(pardes.filesystem, ninep.max_fids);
+const Srv = ninep.Server(pardes.ctlfs, ninep.max_fids);
const Conn = struct {
fd: c_int = -1,
@@ -149,7 +149,7 @@ const Conn = struct {
in: [msize]u8 = undefined,
out: [2 * msize]u8 = undefined,
- fn step(c: *Conn, core: *pardes.Pardes, req: pardes.filesystem.Req) void {
+ fn step(c: *Conn, core: *pardes.Pardes, req: pardes.ctlfs.Req) void {
core.update(.{ .fs_req = req });
var answered = false;
while (core.nextEffect()) |effect| {
@@ -160,7 +160,7 @@ const Conn = struct {
} else core.perform(effect);
}
if (!answered) {
- const reply = pardes.filesystem.Reply.fail(req.tag, pardes.filesystem.E.IO);
+ const reply = pardes.ctlfs.Reply.fail(req.tag, pardes.ctlfs.E.IO);
c.srv.reply(&reply, "");
}
}
@@ -226,7 +226,7 @@ pub const Listener = struct {
c.srv = .init(.{
.in = &c.in,
.out = &c.out,
- .root = pardes.filesystem.namespace_root,
+ .root = pardes.ctlfs.root,
});
}
}
@@ -246,7 +246,7 @@ pub const Listener = struct {
c.quic = connection;
c.draining = false;
c.accepted_ms = nowMs();
- c.srv = .init(.{ .in = &c.in, .out = &c.out, .root = pardes.filesystem.namespace_root });
+ c.srv = .init(.{ .in = &c.in, .out = &c.out, .root = pardes.ctlfs.root });
};
}
}
@@ -748,17 +748,17 @@ test "Unix TCP and QUIC share one listener through reads writes reconnects and r
defer testing.allocator.free(before);
try testing.expectEqualStrings(w.expected, before);
try testing.expectError(error.FileTooLarge, Client.readLimit(testing.allocator, w.dial, w.body_path, w.body_path, w.expected.len - 1));
- const listing = try Client.readLimit(testing.allocator, w.dial, "/self/pane", "/self/pane", 128);
+ const listing = try Client.readLimit(testing.allocator, w.dial, "/pane", "/pane", 128);
defer testing.allocator.free(listing);
- const exact_listing = try Client.readLimit(testing.allocator, w.dial, "/self/pane", "/self/pane", listing.len);
+ const exact_listing = try Client.readLimit(testing.allocator, w.dial, "/pane", "/pane", listing.len);
defer testing.allocator.free(exact_listing);
try testing.expectEqualStrings(listing, exact_listing);
- try testing.expectError(error.FileTooLarge, Client.readLimit(testing.allocator, w.dial, "/self/pane", "/self/pane", listing.len - 1));
+ try testing.expectError(error.FileTooLarge, Client.readLimit(testing.allocator, w.dial, "/pane", "/pane", listing.len - 1));
try Client.write(testing.allocator, w.dial, w.body_path, w.replacement);
const after = try Client.read(testing.allocator, w.dial, w.body_path, w.body_path);
defer testing.allocator.free(after);
try testing.expectEqualStrings(w.replacement, after);
- const screen = try Client.read(testing.allocator, w.dial, "/self/screen", "/self/screen");
+ const screen = try Client.read(testing.allocator, w.dial, "/screen", "/screen");
defer testing.allocator.free(screen);
const parsed = try std.json.parseFromSlice(struct { cols: u16, rows: u16 }, testing.allocator, screen, .{ .ignore_unknown_fields = true });
defer parsed.deinit();
@@ -790,7 +790,7 @@ test "Unix TCP and QUIC share one listener through reads writes reconnects and r
var dial_buf: [64]u8 = undefined;
const network_dial = try std.fmt.bufPrint(&dial_buf, "{s}!{s}!{d}", .{ protocol, host, port });
var body_buf: [64]u8 = undefined;
- const body = try std.fmt.bufPrint(&body_buf, "/self/pane/{d}/body", .{pane.serial});
+ const body = try std.fmt.bufPrint(&body_buf, "/pane/{d}/body", .{pane.serial});
for ([_][]const u8{ network_dial, l.path(), network_dial }, 0..) |dial, attempt| {
var worker: Worker = .{ .dial = dial, .body_path = body, .expected = if (attempt == 0) "initial\n" else replacement, .replacement = replacement };
const thread = try std.Thread.spawn(.{}, Worker.run, .{&worker});
@@ -1245,15 +1245,14 @@ pub const Client = struct {
test "a path becomes walk elements, normalised the way a shell would" {
var out: [max_depth][]const u8 = undefined;
- try testing.expectEqual(@as(usize, 4), try elements("/self/pane/1/body", &out));
- try testing.expectEqualStrings("self", out[0]);
- try testing.expectEqualStrings("pane", out[1]);
- try testing.expectEqualStrings("1", out[2]);
- try testing.expectEqualStrings("body", out[3]);
+ try testing.expectEqual(@as(usize, 3), try elements("/pane/1/body", &out));
+ try testing.expectEqualStrings("pane", out[0]);
+ try testing.expectEqualStrings("1", out[1]);
+ try testing.expectEqualStrings("body", out[2]);
- try testing.expectEqual(@as(usize, 4), try elements("self/pane/1/body", &out));
- try testing.expectEqual(@as(usize, 4), try elements("//self//pane//1//body//", &out));
- try testing.expectEqual(@as(usize, 2), try elements("/self/index", &out));
+ try testing.expectEqual(@as(usize, 3), try elements("pane/1/body", &out));
+ try testing.expectEqual(@as(usize, 3), try elements("//pane//1//body//", &out));
+ try testing.expectEqual(@as(usize, 1), try elements("/index", &out));
try testing.expectEqual(@as(usize, 0), try elements("/", &out));
@@ -1284,12 +1283,12 @@ pub const Client = struct {
test "a dial with nothing listening is one error and not a wait" {
if (comptime !supported) return error.SkipZigTest;
var names: [max_depth][]const u8 = undefined;
- const n = try elements("/self/pane/1/body", &names);
+ const n = try elements("/pane/1/body", &names);
var remote: RemoteError = .{};
const before = nowMs();
try testing.expectError(
Error.Dial,
- fetchBytes(testing.allocator, .{ .unix = "/tmp/pardes-9p-no-such-socket.sock" }, names[0..n], &remote, null, "/self/pane/1/body", limits.max_file_bytes),
+ fetchBytes(testing.allocator, .{ .unix = "/tmp/pardes-9p-no-such-socket.sock" }, names[0..n], &remote, null, "/pane/1/body", limits.max_file_bytes),
);
try testing.expect(nowMs() - before < budget_ms);
}
diff --git a/src/CHANGELOG.md b/src/CHANGELOG.md
index 015f726b..18bcceb9 100644
--- a/src/CHANGELOG.md
+++ b/src/CHANGELOG.md
@@ -2,6 +2,16 @@
## 0.0.3
+- Flatten the 9P control tree: `self/` is gone, so `/index`, `/pane/<n>/`,
+ `/screen` and `/os` sit at the root. `/new` is one clone file (reading it
+ makes a pane and answers its serial), `/ctl` takes `look TEXT` and
+ `exec TEXT` in the editor's own command language and answers the serials a
+ command touched, `/log` streams pane events, and each pane gains `name` and
+ `sel` (replacing `rdsel`/`wrsel`); `cons`, `new/` and the `name`, `put`,
+ `del` and `delete` ctl verbs are gone in favour of `name` and `exec`. Stats
+ carry real lengths, modes and mtimes. Sources are served under `/src` only
+ with `-Dembed-sources=true`. The tree code moved from `src/fs.zig` to
+ `src/ninep/`.
- Add pinned tree-sitter declaration context, with optional compact tagline
styling and separators between discontinuous source lines.
- Configure search and LSP result context with `LocationsConfig`. Results use
diff --git a/src/effect_sources.zig b/src/effect_sources.zig
index 3ca15c3f..08680860 100644
--- a/src/effect_sources.zig
+++ b/src/effect_sources.zig
@@ -79,6 +79,7 @@ pub fn forSetting(setting: config.Runtime.Setting) ?[]const []const u8 {
}
test "EffectCode paths resolve uniquely to this backend's archived inputs" {
+ if (!@import("memory.zig").limits.embedded_sources) return error.SkipZigTest;
for (config.Runtime.settings) |setting| {
const available = switch (setting.action) {
.transition => backend == .tty or backend == .gui or backend == .macos,
diff --git a/src/fs-help.txt b/src/fs-help.txt
index eeb000cc..a0a84b28 100644
--- a/src/fs-help.txt
+++ b/src/fs-help.txt
@@ -1,65 +1,42 @@
Pardes control filesystem
=========================
-The mount root contains self/ (this editor) and os/ (host files).
-This guide is available as self/README and self/new/README.
-Examples below run inside a Tty9p shell, where PARDES_MOUNT is set.
+README this guide
+index one line per pane: serial, kind (text|term|pdf|image), dirty flag, name
+ctl write commands; read the serials the last command made or touched
+new reading it creates one empty pane and answers its serial
+log one line per editor event (new/del/rename/save <serial> <name>); reads wait
+screen rendered screen as JSON, frozen from open to close
+listeners the session's dial addresses
+pane/<n>/ name body tag ctl addr data xdata sel errors event, and pty/ for terminals
+os/ the host filesystem
+src/ the editor's own sources, only in a -Dembed-sources=true build
-Explore
--------
- ls "$PARDES_MOUNT/self"
- cat "$PARDES_MOUNT/self/index"
- ls -l "$PARDES_MOUNT/self/new"
- ls "$PARDES_MOUNT/self/pane"
+Commands, on /ctl (applied at the active pane) or on pane/<n>/ctl (at that pane):
+ look TEXT a right click: open a file, a file:12 location, a directory, a URL
+ exec TEXT a middle click: an editor command word (Save Del New Newcol Mount Unmount
+ Dump Restore Msg Find Grep Tty ...), or a shell command for the pane's terminal
+Below, $m is the mount point (PARDES_MOUNT in a Tty9p shell; 9ns and 9p work too):
-index lists panes: serial, tag length, body length, reserved zero,
-modified flag, then tag text. Use the serial in self/pane/<serial>/.
-screen is rendered screen JSON; listeners lists the session addresses.
-cons accepts text to display in the editor.
+ cat $m/index which panes exist
+ n=$(cat $m/new) make a pane, remember its serial
+ echo 'look /etc/hosts:3' > $m/ctl; cat $m/ctl open a file, see the pane it went to
+ printf 'text\n' > $m/pane/$n/body append to a pane (>| truncates first)
+ cat $m/pane/$n/name; echo notes.txt > $m/pane/$n/name read, then rename
+ echo 'exec Save' > $m/pane/$n/ctl save it (exec Del closes it)
+ echo 'exec Msg hello' > $m/ctl show text in the editor
+ echo '#0,#5' > $m/pane/$n/addr; echo NEW > $m/pane/$n/data replace bytes 0..5
+ echo 'dot=addr' > $m/pane/$n/ctl; cat $m/pane/$n/sel select the range, read it
+ cat $m/log block until a pane is made, renamed, saved or closed
+ cat $m/pane/$n/event hold it open to own that pane's clicks
+ echo 'exec' > $m/pane/$n/pty/ctl restart a shell; also winsize C R, sig INT
-Create a pane
--------------
-new/ lists the same files as a scratch pane, plus this README.
-Listing, walking and statting these paths do not create anything.
-OPENING any file except README creates one scratch pane below the active pane.
-Each open creates a separate pane, even if you open the same path again.
-
- cat "$PARDES_MOUNT/self/new/ctl"
-
-This creates a pane and prints its control information, beginning with its
-serial. Use self/pane/<serial>/ for all subsequent operations on that pane.
-To create a pane and write its initial text in one operation:
-
- printf 'Hello from the shell\n' > "$PARDES_MOUNT/self/new/body"
-
-Reading all files recursively would open factories and create panes.
-Use ls or stat to inspect them; read README for this guide.
-
-Work with an existing pane
---------------------------
-Replace <serial> with a number from index or new/ctl:
-
- cat "$PARDES_MOUNT/self/pane/<serial>/body"
- printf 'Replacement text\n' > "$PARDES_MOUNT/self/pane/<serial>/body"
- printf 'More text\n' >> "$PARDES_MOUNT/self/pane/<serial>/body"
- printf 'name notes.txt\n' > "$PARDES_MOUNT/self/pane/<serial>/ctl"
-
-body buffer text; for terminals, reads history and writes child input
- tag editable pane tag
- ctl control information on read; newline-separated commands on write
- addr address range; write #0,#5 to select the first five bytes
- data read from addr onward, or replace the range selected by addr
- xdata read only the range selected by addr; writes replace that range
- rdsel read the editor selection
- wrsel replace the editor selection
- errors write diagnostics
- event pane interaction events; opening intercepts Look/Exec events
-
-Common ctl commands: name PATH, put (save), get (reload), dot=addr,
-addr=dot, look PATH, del (close, refusing dirty text), delete (force close).
-Reload and force close can discard unsaved changes.
-
-Terminal panes also expose pty/ctl, pty/status and pty/data.
-Writing pty/data sends terminal input; use carriage return to submit a command.
-Reading pty/data streams new output; read body for a history snapshot.
-
-Embedded source files under self/src/ document the complete interface.
+Pitfalls, one each:
+ Every open of new makes another pane, so `cat $m/new` twice makes two.
+ ls, stat and find create nothing; reading log, event or pty/data blocks until something happens.
+ exec Del closes a pane even when it is dirty; index shows the dirty flag first.
+ look and exec are lowercase verbs; the words after exec are the editor's capitalized commands.
+ ctl also takes acme's addr verbs (addr=dot dot=addr limit=addr clean dirty get show ...), one per line.
+ Writing body appends; open it with truncation to replace; writes are whole UTF-8 sequences.
+ A terminal's body is a history snapshot frozen per open; pty/data is the live stream.
+ screen is frozen per open handle as well; reopen it for a fresh frame.
+ A command that fails is reported in the editor, not as a write error; a malformed line fails the write.
diff --git a/src/fs.zig b/src/fs.zig
index 4fe32347..7f416440 100644
--- a/src/fs.zig
+++ b/src/fs.zig
@@ -9,6 +9,22 @@ const panes = @import("panes.zig");
const mvzr = @import("mvzr");
const modal = @import("modal.zig");
const look = @import("look.zig");
+pub const tree = @import("ninep/tree.zig");
+const sources_fs = @import("ninep/sources.zig");
+const th = @import("ninep/testing.zig");
+
+const Pardes = pardes.Pardes;
+const MAX_PANES = pardes.MAX_PANES;
+const Req = tree.Req;
+const Reply = tree.Reply;
+const E = tree.E;
+
+/// The served README; tree.zig answers it.
+pub const help = @embedFile("fs-help.txt");
+
+/// Host filesystem nodes: the root, and one hashed node per path a client walked to.
+pub const os_root: u64 = (1 << 63) + 2;
+pub const os_node: u64 = 1 << 62;
pub const name_capacity: usize = 255;
@@ -18,8 +34,6 @@ pub const sources: []const Source = if (limits.embedded_sources)
else
&.{};
const source_files = [_]Source{
- .{ .path = "build.zig", .contents = @embedFile("root-build.zig") },
- .{ .path = "build.zig.zon", .contents = @embedFile("root-build.zig.zon") },
.{ .path = "src/pardes.zig", .contents = @embedFile("pardes.zig") },
.{ .path = "src/panes.zig", .contents = @embedFile("panes.zig") },
.{ .path = "src/layout.zig", .contents = @embedFile("layout.zig") },
@@ -86,101 +100,6 @@ pub const Mount = struct { name: []const u8, dial: []const u8 };
pub const max_mounts = 8;
pub const Resolved = struct { path: []const u8, dir: bool = false };
pub const OsPath = struct { node: u64, path: []const u8 };
-pub const archive_node: u64 = 1 << 61;
-
-pub fn archiveNode(path: []const u8) ?u64 {
- if (path.len == 0) return archive_node;
- if (path.len >= 4096) return null;
- for (sources, 0..) |source, i| {
- if (std.mem.eql(u8, source.path, path) or
- (source.path.len > path.len and source.path[path.len] == '/' and std.mem.startsWith(u8, source.path, path)))
- return archive_node | (@as(u64, i) << 12) | path.len;
- }
- return null;
-}
-
-fn archiveInfo(node: u64) ?struct { path: []const u8, contents: []const u8, dir: bool } {
- if (node == archive_node) return .{ .path = "", .contents = "", .dir = true };
- const index = (node & ~archive_node) >> 12;
- if (index >= sources.len) return null;
- const source = sources[@intCast(index)];
- const len: usize = @intCast(node & 4095);
- if (len > source.path.len) return null;
- return .{ .path = source.path[0..len], .contents = source.contents, .dir = len < source.path.len };
-}
-
-pub fn stageArchive(p: *pardes.Pardes, out: *std.ArrayList(u8), path: []const u8, skip: *u64) void {
- var seen: [sources.len][]const u8 = undefined;
- var count: usize = 0;
- for (sources, 0..) |source, i| {
- if (path.len != 0 and
- (source.path.len <= path.len or source.path[path.len] != '/' or !std.mem.startsWith(u8, source.path, path))) continue;
- const start = if (path.len == 0) 0 else path.len + 1;
- const rest = source.path[start..];
- const len = std.mem.indexOfScalar(u8, rest, '/') orelse rest.len;
- const name = rest[0..len];
- var duplicate = false;
- for (seen[0..count]) |previous| if (std.mem.eql(u8, name, previous)) {
- duplicate = true;
- break;
- };
- if (duplicate) continue;
- seen[count] = name;
- count += 1;
- if (skip.* > 0) {
- skip.* -= 1;
- continue;
- }
- const node = archive_node | (@as(u64, i) << 12) | (start + len);
- stageDirent(out, p.gpa, node, len < rest.len, name);
- }
-}
-
-pub fn archiveHandle(p: *pardes.Pardes, req: Req) Reply {
- const info = archiveInfo(req.node) orelse return Reply.fail(req.tag, E.NOENT);
- const attr: Reply.Attr = .{
- .node = req.node,
- .name = std.fs.path.basename(info.path),
- .dir = info.dir,
- .size = if (info.dir) 0 else info.contents.len,
- .mode = if (info.dir) 0o500 else 0o400,
- };
- switch (req.op) {
- .getattr => return .{ .tag = req.tag, .attr = attr },
- .open => return .{ .tag = req.tag, .handle = 1 },
- .release => return .{ .tag = req.tag },
- .lookup => {
- if (!info.dir) return Reply.fail(req.tag, E.NOTDIR);
- if (std.mem.eql(u8, req.data, "..")) {
- const parent = std.fs.path.dirname(info.path) orelse "";
- if (parent.len == 0) return handle(p, .{ .tag = req.tag, .op = .getattr, .node = @intFromEnum(SelfFile.root) });
- const node = archiveNode(parent) orelse return Reply.fail(req.tag, E.NOENT);
- return archiveHandle(p, .{ .tag = req.tag, .op = .getattr, .node = node });
- }
- if (req.data.len == 0 or std.mem.indexOfAny(u8, req.data, "/\x00") != null) return Reply.fail(req.tag, E.NOENT);
- var buf: [4096]u8 = undefined;
- const path = if (info.path.len == 0) req.data else std.fmt.bufPrint(&buf, "{s}/{s}", .{ info.path, req.data }) catch return Reply.fail(req.tag, E.NOENT);
- const node = archiveNode(path) orelse return Reply.fail(req.tag, E.NOENT);
- return archiveHandle(p, .{ .tag = req.tag, .op = .getattr, .node = node });
- },
- .readdir => {
- if (!info.dir) return Reply.fail(req.tag, E.NOTDIR);
- const out = p.fs.stage(p.gpa);
- var skip = req.off;
- stageArchive(p, out, info.path, &skip);
- return .{ .tag = req.tag, .payload = .{ .staged = @intCast(out.items.len) } };
- },
- .read => {
- if (info.dir) return Reply.fail(req.tag, E.PERM);
- const off: usize = @intCast(@min(req.off, info.contents.len));
- const bytes = info.contents[off..][0..@min(req.size, info.contents.len - off)];
- const out = p.fs.stage(p.gpa);
- out.appendSlice(p.gpa, bytes) catch return Reply.fail(req.tag, E.NOMEM);
- return .{ .tag = req.tag, .payload = .{ .staged = @intCast(bytes.len) } };
- },
- else => return Reply.fail(req.tag, E.PERM),
- }
-}
fn osPath(p: *pardes.Pardes, node: u64) ?[]const u8 {
if (node == os_root) return "/";
@@ -208,7 +127,7 @@ pub fn osHandle(p: *pardes.Pardes, req: Req) Reply {
if (req.op == .release) return .{ .tag = req.tag };
const io = std.Io.Threaded.global_single_threaded.io();
const stat = std.Io.Dir.cwd().statFile(io, path, .{}) catch return Reply.fail(req.tag, E.NOENT);
- const attr: Reply.Attr = .{ .name = if (req.node == os_root) "os" else std.fs.path.basename(path), .node = req.node, .dir = stat.kind == .directory, .size = stat.size, .mode = if (stat.kind == .directory) 0o500 else 0o600 };
+ const attr: Reply.Attr = .{ .name = if (req.node == os_root) "os" else std.fs.path.basename(path), .node = req.node, .dir = stat.kind == .directory, .size = stat.size, .mode = if (stat.kind == .directory) 0o755 else 0o644, .mtime = std.math.cast(u32, stat.mtime.toSeconds()) orelse 0 };
switch (req.op) {
.getattr => return .{ .tag = req.tag, .attr = attr },
.open => {
@@ -218,7 +137,7 @@ pub fn osHandle(p: *pardes.Pardes, req: Req) Reply {
.lookup => {
if (stat.kind != .directory) return Reply.fail(req.tag, E.NOTDIR);
if (req.node == os_root and std.mem.eql(u8, req.data, ".."))
- return handle(p, .{ .tag = req.tag, .op = .getattr, .node = namespace_root });
+ return tree.handle(p, .{ .tag = req.tag, .op = .getattr, .node = tree.root });
if (req.data.len == 0 or std.mem.indexOfAny(u8, req.data, "/\x00") != null) return Reply.fail(req.tag, E.NOENT);
var buf: [4096]u8 = undefined;
const joined = std.fmt.bufPrint(&buf, "{s}/{s}", .{ std.mem.trimEnd(u8, path, "/"), req.data }) catch return Reply.fail(req.tag, E.NOENT);
@@ -244,7 +163,7 @@ pub fn osHandle(p: *pardes.Pardes, req: Req) Reply {
continue;
}
const node = if (std.mem.eql(u8, normalized.path, "/")) os_root else os_node | (std.hash.Wyhash.hash(0, normalized.path) & (os_node - 1));
- stageDirent(out, p.gpa, node, child.kind == .directory, entry.name);
+ tree.stageDirent(out, p.gpa, node, child.kind == .directory, entry.name);
if (out.items.len >= @max(req.size, 512)) break;
}
return .{ .tag = req.tag, .payload = .{ .staged = @intCast(out.items.len) } };
@@ -362,10 +281,16 @@ test "virtual writes enforce permissions even when contents are empty" {
const p = try pardes.Pardes.init(std.testing.allocator, .{ .tty_only = true });
defer p.deinit();
for ([_][]const u8{ "", "bytes" }) |bytes| {
- for ([_][]const u8{ "/virtual/index", "/virtual/screen", "/virtual/src/fs.zig", "/n/self/src/fs.zig" }) |path|
+ for ([_][]const u8{ "/virtual/index", "/virtual/screen", "/virtual/README", "/n/self/log" }) |path|
try std.testing.expectError(error.ReadOnlyFilesystem, write(p, path, bytes));
- try std.testing.expectError(error.IsDirectory, write(p, "/virtual/src", bytes));
+ try std.testing.expectError(error.IsDirectory, write(p, "/virtual/pane", bytes));
try std.testing.expectError(error.FileNotFound, write(p, "/virtual/missing", bytes));
+ try std.testing.expectError(error.FileNotFound, write(p, "/virtual/cons", bytes));
+ if (limits.embedded_sources) {
+ for ([_][]const u8{ "/virtual/src/fs.zig", "/n/self/src/fs.zig" }) |path|
+ try std.testing.expectError(error.ReadOnlyFilesystem, write(p, path, bytes));
+ try std.testing.expectError(error.IsDirectory, write(p, "/virtual/src", bytes));
+ } else try std.testing.expectError(error.FileNotFound, write(p, "/virtual/src/fs.zig", bytes));
}
for (p.fs.snapshots) |snapshot| try std.testing.expect(snapshot.node == 0);
}
@@ -385,8 +310,8 @@ test "direct self reads and writes use the same dot paths as Look" {
try std.testing.expectEqualStrings(pane.file.?.content, bytes);
var resolved: [4096]u8 = undefined;
const canonical = resolve(p, path, "/", &resolved).?;
- try std.testing.expectEqual(resolveSelf(p, canonical.path[9..]), resolveSelf(p, path[root.len..]));
- try std.testing.expectEqual(@as(?u64, @intFromEnum(SelfFile.root)), resolveSelf(p, "./pane/../../"));
+ try std.testing.expectEqual(tree.resolveSelf(p, canonical.path[9..]), tree.resolveSelf(p, path[root.len..]));
+ try std.testing.expectEqual(@as(?u64, tree.root), tree.resolveSelf(p, "./pane/../../"));
const index_path = try std.fmt.bufPrint(&path_buf, "{s}/./pane/../index", .{root});
try std.testing.expectError(error.ReadOnlyFilesystem, write(p, index_path, ""));
const index = try read(p, index_path);
@@ -397,7 +322,7 @@ test "direct self reads and writes use the same dot paths as Look" {
@memcpy(overlong[0..9], "/virtual/");
var resolved: [4096]u8 = undefined;
try std.testing.expect(resolve(p, &overlong, "/", &resolved) == null);
- try std.testing.expect(resolveSelf(p, overlong[9..]) == null);
+ try std.testing.expect(tree.resolveSelf(p, overlong[9..]) == null);
try std.testing.expectError(error.FileNotFound, write(p, &overlong, ""));
}
@@ -462,8 +387,8 @@ test "bounded reads apply the same limits to self bodies and embedded sources" {
contents[contents.len - 1] = '\n';
for ([_][]const u8{ &contents, "" }) |expected| {
const pane = try p.setTestFile(expected);
- for ([_][]const u8{ "/virtual", "/n/self", "/n/own/self" }) |prefix| {
- if (!platform_has_fs and std.mem.eql(u8, prefix, "/n/own/self")) continue;
+ for ([_][]const u8{ "/virtual", "/n/self", "/n/own" }) |prefix| {
+ if (!platform_has_fs and std.mem.eql(u8, prefix, "/n/own")) continue;
var path_buf: [128]u8 = undefined;
const path = try std.fmt.bufPrint(&path_buf, "{s}/pane/{d}/body", .{ prefix, pane.serial });
const bytes = try readLimit(p, path, expected.len);
@@ -478,7 +403,7 @@ test "bounded reads apply the same limits to self bodies and embedded sources" {
}
if (limits.embedded_sources) {
const source = findEmbeddedSource("src/look.zig", false).?;
- for ([_][]const u8{ "/virtual/src/look.zig", "/n/self/src/look.zig", "/n/own/self/src/look.zig" }) |path| {
+ for ([_][]const u8{ "/virtual/src/look.zig", "/n/self/src/look.zig", "/n/own/src/look.zig" }) |path| {
if (!platform_has_fs and std.mem.startsWith(u8, path, "/n/own/")) continue;
const bytes = try readLimit(p, path, source.contents.len);
defer gpa.free(bytes);
@@ -498,7 +423,7 @@ test "bounded reads count rendered directory paths and unknown self lengths" {
defer p.deinit();
p.fs.socket_path = "/tmp/pardes-limited-in-process.sock";
try p.fs.mount(gpa, "own", p.fs.socket_path);
- for ([_][]const u8{ "/n", "/virtual", "/n/self", "/n/own", "/n/own/self", "/virtual/listeners" }) |path| {
+ for ([_][]const u8{ "/n", "/virtual", "/n/self", "/n/own", "/n/own/pane", "/virtual/listeners", "/virtual/README" }) |path| {
if (!platform_has_fs and std.mem.startsWith(u8, path, "/n/own")) continue;
const expected = try read(p, path);
defer gpa.free(expected);
@@ -515,10 +440,10 @@ test "bounded reads count rendered directory paths and unknown self lengths" {
test "bounded reads release terminal snapshots after success and size refusal" {
if (!pardes.terminal_panes) return error.SkipZigTest;
const gpa = std.testing.allocator;
- const p = try withTerm(gpa);
+ const p = try th.withTerm(gpa);
defer p.deinit();
var path_buf: [128]u8 = undefined;
- const path = try std.fmt.bufPrint(&path_buf, "/virtual/pane/{d}/body", .{serialOf(p)});
+ const path = try std.fmt.bufPrint(&path_buf, "/virtual/pane/{d}/body", .{th.serialOf(p)});
const empty = try readLimit(p, path, 0);
defer gpa.free(empty);
try std.testing.expectEqual(@as(usize, 0), empty.len);
@@ -626,9 +551,9 @@ test "virtual body writes can read their input from the same pane" {
const p = try pardes.Pardes.init(std.testing.allocator, .{ .tty_only = true });
defer p.deinit();
const pane = try p.setTestFile("same pane contents\n");
- const event_node = Node.of(pane.serial, .event);
- _ = handle(p, .{ .tag = 0, .op = .open, .node = event_node });
- defer _ = handle(p, .{ .tag = 0, .op = .release, .node = event_node });
+ const event_node = tree.Node.of(pane.serial, .event);
+ _ = tree.handle(p, .{ .tag = 0, .op = .open, .node = event_node });
+ defer _ = tree.handle(p, .{ .tag = 0, .op = .release, .node = event_node });
var path_buffer: [64]u8 = undefined;
const path = try std.fmt.bufPrint(&path_buffer, "/virtual/pane/{d}/body", .{pane.serial});
try write(p, path, pane.file.?.content);
@@ -654,11 +579,10 @@ test "same-core mount directories preserve their mount prefix across reads" {
try p.fs.mount(gpa, "own", socket_path);
const roots = try read(p, "/n/own");
defer gpa.free(roots);
- try std.testing.expectEqualStrings("/n/own/os/\n/n/own/self/\n", roots);
- const self = try read(p, "/n/own/self");
- defer gpa.free(self);
- try std.testing.expect(std.mem.indexOf(u8, self, "/n/own/self/index\n") != null);
- try std.testing.expectError(error.NotADirectory, read(p, "/n/own/self/index/.."));
+ try std.testing.expect(std.mem.startsWith(u8, roots, "/n/own/README\n/n/own/index\n"));
+ try std.testing.expect(std.mem.indexOf(u8, roots, "/n/own/pane/\n/n/own/os/\n") != null);
+ try std.testing.expectError(error.NotADirectory, read(p, "/n/own/index/.."));
+ try std.testing.expectError(error.FileNotFound, read(p, "/n/own/self/index"));
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
@@ -767,11 +691,11 @@ pub fn resolveOs(path: []const u8, out: *[4096]u8) ?Resolved {
fn resolveVirtual(p: ?*pardes.Pardes, path: []const u8, out: *[4096]u8) ?Resolved {
var normalized_buf: [4096]u8 = undefined;
const normalized = normalizeVirtualPath(path, &normalized_buf) orelse return null;
- if (p) |core| if (resolveSelf(core, normalized) != null)
+ if (p) |core| if (tree.resolveSelf(core, normalized) != null)
return .{ .path = std.fmt.bufPrint(out, "/virtual/{s}", .{normalized}) catch return null };
if (findEmbeddedSource(normalized, false)) |source|
return .{ .path = std.fmt.bufPrint(out, "/virtual/{s}", .{source.path}) catch return null };
- if (archiveNode(normalized) != null)
+ if (sources_fs.node(normalized) != null)
return .{ .path = std.fmt.bufPrint(out, "/virtual/{s}", .{normalized}) catch return null };
return null;
}
@@ -793,7 +717,7 @@ pub fn readLimit(p: *pardes.Pardes, path: []const u8, max_bytes: usize) ![]u8 {
}
return out.toOwnedSlice();
}
- if (std.mem.eql(u8, path, "/virtual")) return readNode(p, @intFromEnum(SelfFile.root), path, limit);
+ if (std.mem.eql(u8, path, "/virtual")) return readNode(p, tree.root, path, limit);
if (std.mem.startsWith(u8, path, "/n/")) {
const explicit = path[3..];
const cut = std.mem.indexOfScalar(u8, explicit, '/') orelse explicit.len;
@@ -812,7 +736,7 @@ pub fn readLimit(p: *pardes.Pardes, path: []const u8, max_bytes: usize) ![]u8 {
return readNode(p, node, path, limit);
}
if (std.mem.eql(u8, name, "self")) {
- const node = resolveSelf(p, remote_path) orelse return error.FileNotFound;
+ const node = tree.resolveSelf(p, remote_path) orelse return error.FileNotFound;
return readNode(p, node, path, limit);
}
for (p.fs.mounts.items) |mount| {
@@ -823,13 +747,13 @@ pub fn readLimit(p: *pardes.Pardes, path: []const u8, max_bytes: usize) ![]u8 {
for (p.fs.os_paths.items[saved_paths..]) |temporary| p.gpa.free(temporary.path);
p.fs.os_paths.shrinkRetainingCapacity(saved_paths);
}
- var node = namespace_root;
+ var node = tree.root;
var directory = true;
var parts = std.mem.tokenizeScalar(u8, remote_path, '/');
while (parts.next()) |part| {
if (!directory) return error.NotADirectory;
if (std.mem.eql(u8, part, ".")) continue;
- const reply = handle(p, .{ .tag = 0, .op = .lookup, .node = node, .data = part });
+ const reply = tree.handle(p, .{ .tag = 0, .op = .lookup, .node = node, .data = part });
if (reply.status != .ok) return error.FileNotFound;
node = reply.attr.node;
directory = reply.attr.dir;
@@ -842,14 +766,14 @@ pub fn readLimit(p: *pardes.Pardes, path: []const u8, max_bytes: usize) ![]u8 {
}
if (std.mem.startsWith(u8, path, "/virtual/")) {
const name = path[9..];
- if (resolveSelf(p, name)) |node| return readNode(p, node, path, limit);
+ if (tree.resolveSelf(p, name)) |node| return readNode(p, node, path, limit);
}
return readFileLimit(p.gpa, path, limit);
}
fn readNode(p: *pardes.Pardes, initial_node: u64, path: []const u8, limit: usize) ![]u8 {
var node = initial_node;
- const attr = handle(p, .{ .tag = 0, .op = .getattr, .node = node });
+ const attr = tree.handle(p, .{ .tag = 0, .op = .getattr, .node = node });
if (attr.status != .ok) return error.FileNotFound;
if (attr.attr.dir) {
var out: std.Io.Writer.Allocating = .init(p.gpa);
@@ -858,7 +782,7 @@ fn readNode(p: *pardes.Pardes, initial_node: u64, path: []const u8, limit: usize
const max_bytes = @min(limit, limits.max_stream_bytes);
const prefix = std.mem.trimEnd(u8, path, "/");
while (true) {
- const reply = handle(p, .{ .tag = 0, .op = .readdir, .node = node, .off = index, .size = 8192 });
+ const reply = tree.handle(p, .{ .tag = 0, .op = .readdir, .node = node, .off = index, .size = 8192 });
if (reply.status != .ok) return error.ReadFailed;
const entries = p.fsPayload(reply);
if (entries.len == 0) return out.toOwnedSlice();
@@ -878,15 +802,15 @@ fn readNode(p: *pardes.Pardes, initial_node: u64, path: []const u8, limit: usize
}
}
if (attr.attr.size > limit) return error.FileTooLarge;
- const opened = handle(p, .{ .tag = 0, .op = .open, .node = node });
+ const opened = tree.handle(p, .{ .tag = 0, .op = .open, .node = node });
if (opened.status != .ok) return error.OpenFailed;
if (opened.attr.node != 0) node = opened.attr.node;
- defer _ = handle(p, .{ .tag = 0, .op = .release, .node = node, .handle = opened.handle });
+ defer _ = tree.handle(p, .{ .tag = 0, .op = .release, .node = node, .handle = opened.handle });
var out: std.Io.Writer.Allocating = .init(p.gpa);
errdefer out.deinit();
while (true) {
const want: u32 = @intCast(@min(8192, limit + 1 - out.written().len));
- const reply = handle(p, .{ .tag = 0, .op = .read, .node = node, .handle = opened.handle, .off = out.written().len, .size = want });
+ const reply = tree.handle(p, .{ .tag = 0, .op = .read, .node = node, .handle = opened.handle, .off = out.written().len, .size = want });
if (reply.status == .again) return error.NotAFile;
if (reply.status != .ok) return error.ReadFailed;
const bytes = p.fsPayload(reply);
@@ -923,27 +847,27 @@ pub fn write(p: *pardes.Pardes, path: []const u8, bytes: []const u8) !void {
}
}
if (self_path) |name| {
- var node = resolveSelf(p, name) orelse return error.FileNotFound;
- const attributes = handle(p, .{ .tag = 0, .op = .getattr, .node = node });
+ var node = tree.resolveSelf(p, name) orelse return error.FileNotFound;
+ const attributes = tree.handle(p, .{ .tag = 0, .op = .getattr, .node = node });
if (attributes.status != .ok) return error.FileNotFound;
if (attributes.attr.dir) return error.IsDirectory;
if (attributes.attr.mode & 0o200 == 0) return error.ReadOnlyFilesystem;
- const opened = handle(p, .{ .tag = 0, .op = .open, .node = node });
+ const opened = tree.handle(p, .{ .tag = 0, .op = .open, .node = node });
if (opened.status != .ok) return error.OpenFailed;
if (opened.attr.node != 0) node = opened.attr.node;
- defer _ = handle(p, .{ .tag = 0, .op = .release, .node = node, .handle = opened.handle });
+ defer _ = tree.handle(p, .{ .tag = 0, .op = .release, .node = node, .handle = opened.handle });
var preserved: ?[]u8 = null;
defer if (preserved) |copy| p.gpa.free(copy);
- const target = Node.target(node);
+ const target = tree.Node.target(node);
if (target != null and target.? == .pane and target.?.pane.file == .body) {
preserved = try p.gpa.dupe(u8, bytes);
- const trunc = handle(p, .{ .tag = 0, .op = .setattr, .node = node, .truncate = true });
+ const trunc = tree.handle(p, .{ .tag = 0, .op = .setattr, .node = node, .truncate = true });
if (trunc.status != .ok) return error.WriteFailed;
}
const contents = preserved orelse bytes;
var off: usize = 0;
while (off < contents.len) {
- const reply = handle(p, .{ .tag = 0, .op = .write, .node = node, .off = off, .data = contents[off..] });
+ const reply = tree.handle(p, .{ .tag = 0, .op = .write, .node = node, .off = off, .data = contents[off..] });
if (reply.status != .ok or reply.written == 0) return error.WriteFailed;
off += reply.written;
}
@@ -964,7 +888,7 @@ fn resolveEmbedded(word: []const u8, cwd: []const u8, scratch: *[4096]u8) ?Sourc
return findEmbeddedSource(normalized_word, false);
}
-fn normalizeVirtualPath(path: []const u8, out: *[4096]u8) ?[]const u8 {
+pub fn normalizeVirtualPath(path: []const u8, out: *[4096]u8) ?[]const u8 {
if (std.mem.indexOfScalar(u8, path, 0) != null) return null;
var len: usize = 0;
var parts = std.mem.tokenizeAny(u8, path, "/\\");
@@ -1322,361 +1246,6 @@ fn readFileLimit(gpa: std.mem.Allocator, path: []const u8, limit: usize) ![]u8 {
return buf;
}
-const Pardes = pardes.Pardes;
-const Pane = pardes.Pane;
-const MAX_PANES = pardes.MAX_PANES;
-
-pub const namespace_root: u64 = 1 << 63;
-pub const namespace_panes: u64 = namespace_root + 1;
-pub const os_root: u64 = namespace_root + 2;
-pub const os_node: u64 = 1 << 62;
-const factory_base: u64 = namespace_root + 256;
-const fs_help = @embedFile("fs-help.txt");
-
-pub fn resolveSelf(p: *Pardes, path: []const u8) ?u64 {
- var path_buf: [4096]u8 = undefined;
- const normalized = normalizeVirtualPath(path, &path_buf) orelse return null;
- var parts = std.mem.tokenizeScalar(u8, normalized, '/');
- const first = parts.next() orelse return @intFromEnum(SelfFile.root);
- if (!std.mem.eql(u8, first, "pane")) {
- const top = topFileNamed(first) orelse return archiveNode(normalized);
- if (parts.next()) |name| {
- if (top != .new or parts.next() != null) return null;
- if (std.mem.eql(u8, name, "README")) return @intFromEnum(SelfFile.README);
- const file = paneFileNamed(name) orelse return null;
- if (file.inPty()) return null;
- return factory_base + @intFromEnum(file);
- }
- return @intFromEnum(top);
- }
- const serial = serialNamed(parts.next() orelse return namespace_panes) orelse return null;
- const id = p.paneBySerial(serial) orelse return null;
- const file = paneFileNamed(parts.next() orelse return Node.of(serial, .dir)) orelse return null;
- if (file.inPty() and !p.panes[id].?.isTerminal()) return null;
- if (parts.next()) |name| {
- if (file != .pty) return null;
- const child = ptyFileNamed(name) orelse return null;
- if (parts.next() != null) return null;
- return Node.of(serial, child);
- }
- return Node.of(serial, file);
-}
-
-fn namespace(p: *Pardes, req: Req) Reply {
- switch (req.op) {
- .getattr => return .{ .tag = req.tag, .attr = .{ .node = req.node, .dir = true, .mode = 0o500 } },
- .open => return .{ .tag = req.tag, .handle = 1 },
- .release => return .{ .tag = req.tag },
- .lookup => {
- if (std.mem.eql(u8, req.data, "..")) return handle(p, .{
- .tag = req.tag,
- .op = .getattr,
- .node = if (req.node == namespace_root) namespace_root else @intFromEnum(SelfFile.root),
- });
- if (req.node == namespace_root) {
- if (std.mem.eql(u8, req.data, "self")) return handle(p, .{ .tag = req.tag, .op = .getattr, .node = @intFromEnum(SelfFile.root) });
- if (std.mem.eql(u8, req.data, "os")) return handle(p, .{ .tag = req.tag, .op = .getattr, .node = os_root });
- } else {
- const serial = serialNamed(req.data) orelse return Reply.fail(req.tag, E.NOENT);
- if (serial == 0) return Reply.fail(req.tag, E.NOENT);
- return handle(p, .{ .tag = req.tag, .op = .getattr, .node = Node.of(serial, .dir) });
- }
- return Reply.fail(req.tag, E.NOENT);
- },
- .readdir => {
- const out = p.fs.stage(p.gpa);
- var skip = req.off;
- if (req.node == namespace_root) {
- if (skip == 0) stageDirent(out, p.gpa, os_root, true, "os") else skip -= 1;
- if (skip == 0) stageDirent(out, p.gpa, @intFromEnum(SelfFile.root), true, "self");
- } else {
- var last: u32 = 0;
- while (nextSerialAfter(p, last)) |serial| {
- last = serial;
- if (skip > 0) {
- skip -= 1;
- continue;
- }
- var buf: [16]u8 = undefined;
- const name = std.fmt.bufPrint(&buf, "{d}", .{serial}) catch unreachable;
- stageDirent(out, p.gpa, Node.of(serial, .dir), true, name);
- }
- }
- return .{ .tag = req.tag, .payload = .{ .staged = @intCast(out.items.len) } };
- },
- else => return Reply.fail(req.tag, E.PERM),
- }
-}
-
-pub const Op = enum(u8) {
- lookup,
- getattr,
- setattr,
- open,
- read,
- write,
- release,
- readdir,
-};
-
-pub const Status = enum(u8) {
- ok,
- again,
- err,
-};
-
-pub const Req = struct {
- tag: u64,
- op: Op,
- node: u64,
- handle: u32 = 0,
- off: u64 = 0,
- size: u32 = 0,
- data: []const u8 = &.{},
- truncate: bool = false,
-
- pub fn changesPane(req: Req) bool {
- return switch (req.op) {
- .write, .setattr => true,
- .lookup => false,
- .open => req.node >= factory_base and req.node < factory_base + 16,
- .getattr, .read, .release, .readdir => false,
- };
- }
-};
-
-pub const Reply = struct {
- tag: u64,
- status: Status = .ok,
- errno: u16 = 0,
- attr: Attr = .{},
- handle: u32 = 0,
- payload: Payload = .none,
- written: u32 = 0,
-
- pub const Attr = struct {
- name: []const u8 = "",
- node: u64 = 0,
- dir: bool = false,
- size: u64 = 0,
- mode: u16 = 0o600,
- };
-
- pub const Payload = union(enum) {
- none,
- staged: u32,
- region: struct { pane: u8, serial: u32, off: u32, len: u32 },
- };
-
- pub fn fail(tag: u64, e: u16) Reply {
- return .{ .tag = tag, .status = .err, .errno = e };
- }
-};
-
-pub const E = struct {
- pub const PERM: u16 = 1;
- pub const NOENT: u16 = 2;
- pub const IO: u16 = 5;
- pub const NOMEM: u16 = 12;
- pub const NOTDIR: u16 = 20;
- pub const INVAL: u16 = 22;
- pub const NFILE: u16 = 23;
- pub const NOSPC: u16 = 28;
- pub const NOSYS: u16 = 38;
-};
-
-pub const PaneFile = enum(u4) {
- dir = 0,
- addr,
- body,
- ctl,
- data,
- errors,
- event,
- tag,
- xdata,
- rdsel,
- wrsel,
- pty,
- pty_ctl,
- pty_status,
- pty_data,
-
- pub fn name(f: PaneFile) []const u8 {
- return switch (f) {
- .dir => ".",
- .pty_ctl => "ctl",
- .pty_status => "status",
- .pty_data => "data",
- else => @tagName(f),
- };
- }
-
- pub fn mode(f: PaneFile) u16 {
- return switch (f) {
- .dir, .pty => 0o500,
- .errors, .wrsel, .pty_ctl => 0o200,
- .rdsel, .pty_status => 0o400,
- else => 0o600,
- };
- }
-
- pub fn isDir(f: PaneFile) bool {
- return f == .dir or f == .pty;
- }
-
- pub fn inPty(f: PaneFile) bool {
- return switch (f) {
- .pty, .pty_ctl, .pty_status, .pty_data => true,
- else => false,
- };
- }
-};
-
-pub const SelfFile = enum(u4) {
- root = 1,
- index = 2,
- cons = 3,
- new = 4,
- screen = 5,
- listeners = 6,
- README = 7,
-
- pub fn name(f: SelfFile) []const u8 {
- return if (f == .root) "." else @tagName(f);
- }
-
- pub fn mode(f: SelfFile) u16 {
- return switch (f) {
- .root, .new => 0o500,
- .index, .screen, .listeners, .README => 0o400,
- .cons => 0o200,
- };
- }
-
- pub fn dir(f: SelfFile) bool {
- return f == .root or f == .new;
- }
-};
-
-pub const Node = packed struct(u64) {
- file: u4 = 0,
- serial: u60 = 0,
-
- pub fn of(serial: u32, file: PaneFile) u64 {
- std.debug.assert(serial != 0);
- return @bitCast(Node{ .file = @intFromEnum(file), .serial = serial });
- }
-
- pub fn target(node: u64) ?Target {
- const n: Node = @bitCast(node);
- if (n.serial == 0) {
- return .{ .top = std.enums.fromInt(SelfFile, n.file) orelse return null };
- }
- return .{ .pane = .{
- .serial = std.math.cast(u32, n.serial) orelse return null,
- .file = std.enums.fromInt(PaneFile, n.file) orelse return null,
- } };
- }
-};
-
-pub const Target = union(enum) {
- top: SelfFile,
- pane: struct { serial: u32, file: PaneFile },
-};
-
-pub const out_reserve = 4 * 1024;
-
-pub const queue_cap = 64 * 1024;
-
-pub const Queue = struct {
- buf: std.ArrayList(u8) = .empty,
- head: usize = 0,
-
- pub fn deinit(q: *Queue, gpa: std.mem.Allocator) void {
- q.buf.deinit(gpa);
- q.head = 0;
- }
-
- pub fn push(q: *Queue, gpa: std.mem.Allocator, record: []const u8) void {
- if (record.len > std.math.maxInt(u32)) return;
- while (q.buf.items.len - q.head + record.len + 4 > queue_cap) {
- if (q.peek() == null) return;
- q.pop();
- }
- q.compact();
- var head: [4]u8 = undefined;
- std.mem.writeInt(u32, &head, @intCast(record.len), .little);
- q.buf.appendSlice(gpa, &head) catch return;
- q.buf.appendSlice(gpa, record) catch {
- q.buf.shrinkRetainingCapacity(q.buf.items.len - 4);
- return;
- };
- }
-
- pub fn peek(q: *const Queue) ?[]const u8 {
- const rest = q.buf.items[@min(q.head, q.buf.items.len)..];
- if (rest.len < 4) return null;
- const len = std.mem.readInt(u32, rest[0..4], .little);
- if (rest.len < 4 + len) return null;
- return rest[4 .. 4 + len];
- }
-
- pub fn pop(q: *Queue) void {
- const record = q.peek() orelse return;
- q.head += 4 + record.len;
- if (q.head == q.buf.items.len) {
- q.buf.clearRetainingCapacity();
- q.head = 0;
- }
- }
-
- pub fn popFront(q: *Queue, n: usize) void {
- const record = q.peek() orelse return;
- if (n >= record.len) return q.pop();
- q.head += n;
- std.mem.writeInt(u32, q.buf.items[q.head..][0..4], @intCast(record.len - n), .little);
- }
-
- fn compact(q: *Queue) void {
- if (q.head == 0 or q.head * 2 < q.buf.items.len) return;
- const rest = q.buf.items.len - q.head;
- std.mem.copyForwards(u8, q.buf.items[0..rest], q.buf.items[q.head..]);
- q.buf.shrinkRetainingCapacity(rest);
- q.head = 0;
- }
-
- pub fn empty(q: *const Queue) bool {
- return q.peek() == null;
- }
-
- pub fn clearAndFree(q: *Queue, gpa: std.mem.Allocator) void {
- q.buf.clearAndFree(gpa);
- q.head = 0;
- }
-};
-
-pub const PaneState = struct {
- addr: Range = .{},
- limit: ?Range = null,
- readers: u16 = 0,
- events: Queue = .{},
- nomark: bool = false,
- noscroll: bool = false,
- tag_snap: std.ArrayList(u8) = .empty,
- pty_readers: u16 = 0,
- pty_out: Queue = .{},
-
- pub const Range = struct { q0: u32 = 0, q1: u32 = 0 };
-
- fn deinit(pf: *PaneState, gpa: std.mem.Allocator) void {
- pf.events.deinit(gpa);
- pf.pty_out.deinit(gpa);
- pf.tag_snap.deinit(gpa);
- pf.* = .{};
- }
-};
-
pub const Namespace = struct {
socket_path: []const u8 = "",
tcp_address: ?std.Io.net.IpAddress = null,
@@ -1684,11 +1253,20 @@ pub const Namespace = struct {
mounts: std.ArrayList(Mount) = .empty,
node_name: [16]u8 = undefined,
os_paths: std.ArrayList(OsPath) = .empty,
- snapshots: [32]struct { node: u64 = 0, bytes: ?[]const u8 = null } = @splat(.{}),
+ snapshots: [tree.screen.snapshot_slots]tree.screen.Snapshot = @splat(.{}),
out: std.ArrayList(u8) = .empty,
- panes: [MAX_PANES]PaneState = @splat(.{}),
+ panes: [MAX_PANES]tree.pane.State = @splat(.{}),
listeners: u16 = 0,
origin: u8 = 'K',
+ /// The editor-wide event stream, recorded only while /log is held open.
+ log: tree.events.Queue = .{},
+ log_readers: u16 = 0,
+ /// What the last ctl command created or touched, answered by reading /ctl.
+ results: [MAX_PANES]u32 = undefined,
+ results_len: u8 = 0,
+ results_valid: bool = false,
+ /// Process start, the mtime of everything that has no edit time of its own.
+ started: u32 = 0,
pub fn deinit(st: *Namespace, gpa: std.mem.Allocator) void {
for (st.mounts.items) |entry| {
@@ -1700,6 +1278,7 @@ pub const Namespace = struct {
st.os_paths.deinit(gpa);
for (st.snapshots) |snapshot| if (snapshot.bytes) |bytes| gpa.free(bytes);
for (&st.panes) |*pf| pf.deinit(gpa);
+ st.log.deinit(gpa);
st.out.deinit(gpa);
}
@@ -1718,7 +1297,7 @@ pub const Namespace = struct {
pub fn stage(st: *Namespace, gpa: std.mem.Allocator) *std.ArrayList(u8) {
st.out.clearRetainingCapacity();
- st.out.ensureTotalCapacity(gpa, out_reserve) catch {};
+ st.out.ensureTotalCapacity(gpa, tree.out_reserve) catch {};
return &st.out;
}
@@ -1764,3023 +1343,6 @@ pub fn unmount(p: *Pardes, name: []const u8) !void {
return error.NotMounted;
}
-pub const max_record_text = 256;
-
-pub const Action = enum(u8) {
- body_delete = 'D',
- tag_delete = 'd',
- body_insert = 'I',
- tag_insert = 'i',
- body_look = 'L',
- tag_look = 'l',
- body_exec = 'X',
- tag_exec = 'x',
-
- pub fn char(a: Action) u8 {
- return @intFromEnum(a);
- }
-
- pub fn fromChar(c: u8) ?Action {
- return std.enums.fromInt(Action, c);
- }
-
- pub fn onTag(a: Action) bool {
- return @intFromEnum(a) >= 'a';
- }
-};
-
-pub const flag_builtin: u32 = 1;
-pub const flag_expansion: u32 = 2;
-pub const flag_filename: u32 = 4;
-pub const flag_chorded: u32 = 8;
-
-pub fn formatRecord(
- buf: []u8,
- origin: u8,
- action: Action,
- q0: u32,
- q1: u32,
- flag: u32,
- text: []const u8,
-) []const u8 {
- const sent = if (text.len >= max_record_text) text[0..0] else text;
- return std.fmt.bufPrint(buf, "{c}{c}{d} {d} {d} {d} {s}\n", .{
- origin,
- action.char(),
- q0,
- q1,
- flag,
- sent.len,
- sent,
- }) catch buf[0..0];
-}
-
-pub const Span = struct { at: u32, removed: u32, inserted: u32 };
-
-pub fn diffSpan(old: []const u8, new: []const u8) Span {
- const both = @min(old.len, new.len);
- const stride = 64;
- var head: usize = 0;
- while (head + stride <= both and
- std.mem.eql(u8, old[head..][0..stride], new[head..][0..stride])) head += stride;
- while (head < both and old[head] == new[head]) head += 1;
- var tail: usize = 0;
- const rest = both - head;
- while (tail + stride <= rest and std.mem.eql(
- u8,
- old[old.len - tail - stride ..][0..stride],
- new[new.len - tail - stride ..][0..stride],
- )) tail += stride;
- while (tail < rest and old[old.len - 1 - tail] == new[new.len - 1 - tail]) tail += 1;
- return .{
- .at = @intCast(head),
- .removed = @intCast(old.len - tail - head),
- .inserted = @intCast(new.len - tail - head),
- };
-}
-
-pub fn noteReplace(p: *Pardes, id: usize, on_tag: bool, old: []const u8, new: []const u8) void {
- if (!p.fs.scripted(id)) return;
- const span = diffSpan(old, new);
- if (span.removed == 0 and span.inserted == 0) return;
- if (span.removed > 0) _ = noteAction(
- p,
- id,
- if (on_tag) .tag_delete else .body_delete,
- span.at,
- span.at + span.removed,
- 0,
- "",
- );
- if (span.inserted > 0) _ = noteAction(
- p,
- id,
- if (on_tag) .tag_insert else .body_insert,
- span.at,
- span.at + span.inserted,
- 0,
- new[span.at..][0..span.inserted],
- );
-}
-
-pub fn noteAction(
- p: *Pardes,
- id: usize,
- action: Action,
- q0: u32,
- q1: u32,
- flag: u32,
- text: []const u8,
-) bool {
- if (!p.fs.scripted(id)) return false;
- var buf: [max_record_text + 64]u8 = undefined;
- const record = formatRecord(&buf, p.fs.origin, action, q0, q1, flag, text);
- p.fs.panes[id].events.push(p.gpa, record);
- return true;
-}
-
-pub fn notePtyOutput(p: *Pardes, id: usize, bytes: []const u8) void {
- if (id >= MAX_PANES or bytes.len == 0) return;
- const pf = &p.fs.panes[id];
- if (pf.pty_readers == 0) return;
- var off: usize = 0;
- while (off < bytes.len) {
- const n = @min(bytes.len - off, queue_cap / 2);
- pf.pty_out.push(p.gpa, bytes[off..][0..n]);
- off += n;
- }
-}
-
-pub fn handle(p: *Pardes, req: Req) Reply {
- if (req.node >= factory_base and req.node < factory_base + 16) {
- const file = std.enums.fromInt(PaneFile, req.node - factory_base) orelse return Reply.fail(req.tag, E.NOENT);
- if (file == .dir or file.inPty()) return Reply.fail(req.tag, E.NOENT);
- // Truncating a factory is harmless: its next open creates an empty pane.
- if (req.op == .getattr or (req.op == .setattr and req.truncate))
- return .{ .tag = req.tag, .attr = .{ .node = req.node, .name = file.name(), .mode = file.mode() } };
- if (req.op != .open) return Reply.fail(req.tag, E.PERM);
- const serial = newPane(p) orelse return Reply.fail(req.tag, E.NFILE);
- const node = Node.of(serial, file);
- var reply = handle(p, .{ .tag = req.tag, .op = .open, .node = node });
- reply.attr.node = node;
- return reply;
- }
- if (req.node == namespace_root or req.node == namespace_panes) return namespace(p, req);
- if (req.node == os_root or req.node & os_node != 0) return osHandle(p, req);
- if (req.node & archive_node != 0) return archiveHandle(p, req);
- const target = Node.target(req.node) orelse return Reply.fail(req.tag, E.NOENT);
- if (req.op == .write or req.op == .setattr) p.fs.origin = switch (target) {
- .pane => |t| @as(u8, if (t.file == .body or t.file == .tag) 'E' else 'F'),
- .top => 'F',
- };
- return switch (req.op) {
- .lookup => lookup(p, req, target),
- .getattr => switch (attrOf(p, target)) {
- .ok => |a| .{ .tag = req.tag, .attr = a },
- .missing => Reply.fail(req.tag, E.NOENT),
- },
- .setattr => setattr(p, req, target),
- .open => open(p, req, target),
- .release => release(p, req),
- .readdir => readdir(p, req, target),
- .read => handleRead(p, req, target),
- .write => handleWrite(p, req, target),
- };
-}
-
-const AttrResult = union(enum) { ok: Reply.Attr, missing };
-
-fn attrOf(p: *Pardes, target: Target) AttrResult {
- switch (target) {
- .top => |f| return .{ .ok = .{
- .name = if (f == .root) "self" else f.name(),
- .node = @intFromEnum(f),
- .dir = f.dir(),
- .mode = f.mode(),
- .size = topSize(p, f),
- } },
- .pane => |t| {
- const id = p.paneBySerial(t.serial) orelse return .missing;
- if (t.file.inPty() and !p.panes[id].?.isTerminal()) return .missing;
- return .{ .ok = .{
- .name = if (t.file == .dir) (std.fmt.bufPrint(&p.fs.node_name, "{d}", .{t.serial}) catch unreachable) else t.file.name(),
- .node = Node.of(t.serial, t.file),
- .dir = t.file.isDir(),
- .mode = t.file.mode(),
- .size = paneFileSize(p, id, t.file),
- } };
- },
- }
-}
-
-fn topSize(p: *Pardes, f: SelfFile) u64 {
- return switch (f) {
- .root, .new, .cons, .screen, .listeners => 0,
- .index => indexLen(p),
- .README => fs_help.len,
- };
-}
-
-fn paneFileSize(p: *Pardes, id: usize, f: PaneFile) u64 {
- const pane = p.panes[id] orelse return 0;
- return switch (f) {
- .body, .data, .xdata => bodyLen(p, pane),
- .tag => tagLen(p, pane),
- .dir, .addr, .ctl, .errors, .event, .rdsel, .wrsel => 0,
- .pty, .pty_ctl, .pty_status, .pty_data => 0,
- };
-}
-
-fn bodyOf(pane: *const Pane) []const u8 {
- if (pane.file) |*f| return f.content;
- return "";
-}
-
-fn fileOf(pane: *Pane) ?*panes.File.State {
- return if (pane.file) |*f| f else null;
-}
-
-fn tagOf(p: *Pardes, pane: *Pane) []const u8 {
- return p.tagText(p.scratch.allocator(), pane) catch "";
-}
-
-fn dirOf(pane: *Pane) []const u8 {
- if (pane.file) |*f| return std.fs.path.dirname(f.path) orelse "/";
- const cwd = pane.cwdSlice();
- return if (cwd.len > 0) cwd else "/";
-}
-
-fn dirtyOf(pane: *const Pane) bool {
- const f = if (pane.file) |*x| x else return false;
- if (!panes.Output.fileTraits(f.output).saves) return false;
- return f.revision != f.saved_revision;
-}
-
-fn clip(n: usize) u32 {
- return std.math.cast(u32, n) orelse std.math.maxInt(u32);
-}
-
-fn cellOf(row: i32, col: i32) modal.Cursor {
- return .{ .row = @intCast(@max(0, row)), .col = @intCast(@max(0, col)) };
-}
-
-fn firstLine(s: []const u8) []const u8 {
- return s[0 .. std.mem.indexOfScalar(u8, s, '\n') orelse s.len];
-}
-
-fn dotOf(pane: *Pane) PaneState.Range {
- const text = bodyOf(pane);
- const head = modal.offsetAt(text, cellOf(pane.cur_row, pane.cur_col));
- if (!pane.vsel.active) return .{ .q0 = clip(head), .q1 = clip(head) };
- const anchor = modal.offsetAt(text, cellOf(pane.vsel.row, pane.vsel.col));
- var hi = @max(head, anchor);
- if (hi < text.len) hi = modal.nextGrapheme(text, hi);
- return .{ .q0 = clip(@min(head, anchor)), .q1 = clip(hi) };
-}
-
-fn setDot(pane: *Pane, r: PaneState.Range) void {
- const text = bodyOf(pane);
- const q0 = @min(@as(usize, r.q0), text.len);
- const q1 = @max(q0, @min(@as(usize, r.q1), text.len));
- const a = modal.positionAt(text, q0);
- pane.vsel = .{ .active = q1 > q0, .row = @intCast(a.row), .col = @intCast(a.col), .explicit = true };
- const h = modal.positionAt(text, if (q1 > q0) modal.prevGrapheme(text, q1) else q0);
- pane.cur_row = @intCast(h.row);
- pane.cur_col = @intCast(h.col);
- pane.cur_pinned = true;
- pane.sticky_col = -1;
- pane.msel.active = false;
- pane.ensureCursorVisible();
-}
-
-fn showOffset(pane: *Pane, off: usize) void {
- const text = bodyOf(pane);
- const c = modal.positionAt(text, @min(off, text.len));
- pane.cur_row = @intCast(c.row);
- pane.cur_col = @intCast(c.col);
- pane.cur_pinned = true;
- pane.sticky_col = -1;
- pane.ensureCursorVisible();
-}
-
-fn clampAddr(pf: *PaneState, len: usize) void {
- const n = clip(len);
- pf.addr.q0 = @min(pf.addr.q0, n);
- pf.addr.q1 = @min(pf.addr.q1, n);
- if (pf.limit) |*l| {
- l.q0 = @min(l.q0, n);
- l.q1 = @min(l.q1, n);
- }
-}
-
-fn shiftBy(r: PaneState.Range, at: u32, removed: u32, inserted: u32) PaneState.Range {
- return .{ .q0 = shiftOne(r.q0, at, removed, inserted), .q1 = shiftOne(r.q1, at, removed, inserted) };
-}
-
-fn shiftOne(v: u32, at: u32, removed: u32, inserted: u32) u32 {
- if (v <= at) return v;
- if (v <= at +| removed) return at +| inserted;
- return v - removed +| inserted;
-}
-
-fn wholeUtf8(data: []const u8) usize {
- var i = data.len;
- var back: usize = 0;
- while (i > 0 and back < 4) : (back += 1) {
- i -= 1;
- const c = data[i];
- if (c < 0x80) return data.len;
- if (c & 0xC0 == 0xC0) {
- const need = std.unicode.utf8ByteSequenceLength(c) catch return data.len;
- if (i + need <= data.len or i == 0) return data.len;
- return i;
- }
- }
- return data.len;
-}
-
-fn paneFileNamed(name: []const u8) ?PaneFile {
- const f = std.meta.stringToEnum(PaneFile, name) orelse return null;
- if (f == .dir) return null;
- return if (f.inPty() and f != .pty) null else f;
-}
-
-fn ptyFileNamed(name: []const u8) ?PaneFile {
- if (std.mem.eql(u8, name, "ctl")) return .pty_ctl;
- if (std.mem.eql(u8, name, "status")) return .pty_status;
- if (std.mem.eql(u8, name, "data")) return .pty_data;
- return null;
-}
-
-fn topFileNamed(name: []const u8) ?SelfFile {
- const f = std.meta.stringToEnum(SelfFile, name) orelse return null;
- return if (f == .root) null else f;
-}
-
-fn serialNamed(name: []const u8) ?u32 {
- if (name.len == 0 or name.len > 10) return null;
- for (name) |c| if (c < '0' or c > '9') return null;
- return std.fmt.parseInt(u32, name, 10) catch null;
-}
-
-fn nextSerialAfter(p: *Pardes, after: u32) ?u32 {
- var best: ?u32 = null;
- for (p.panes) |slot| {
- const pane = slot orelse continue;
- if (pane.serial <= after) continue;
- if (best == null or pane.serial < best.?) best = pane.serial;
- }
- return best;
-}
-
-fn newPane(p: *Pardes) ?u32 {
- const slot = p.freeSlot() orelse return null;
- p.newScratchBelow(p.active);
- const pane = p.panes[slot] orelse return null;
- return pane.serial;
-}
-
-fn lookup(p: *Pardes, req: Req, target: Target) Reply {
- const name = req.data;
- if (std.mem.eql(u8, name, "..")) {
- const parent: u64 = switch (target) {
- .top => |top| if (top == .root) namespace_root else @intFromEnum(SelfFile.root),
- .pane => |t| if (t.file == .pty) Node.of(t.serial, .dir) else namespace_panes,
- };
- return handle(p, .{ .tag = req.tag, .op = .getattr, .node = parent });
- }
- if (name.len == 0 or std.mem.indexOfScalar(u8, name, '/') != null) return Reply.fail(req.tag, E.NOENT);
- const node: u64 = switch (target) {
- .top => |f| switch (f) {
- .root => root: {
- if (std.mem.eql(u8, name, "pane")) return .{ .tag = req.tag, .attr = .{ .node = namespace_panes, .dir = true, .mode = 0o500 } };
- if (topFileNamed(name)) |t| break :root @intFromEnum(t);
- if (archiveNode(name)) |node| return archiveHandle(p, .{ .tag = req.tag, .op = .getattr, .node = node });
- return Reply.fail(req.tag, E.NOENT);
- },
- .new => new: {
- if (std.mem.eql(u8, name, "README")) break :new @intFromEnum(SelfFile.README);
- const want = paneFileNamed(name) orelse return Reply.fail(req.tag, E.NOENT);
- if (want.inPty()) return Reply.fail(req.tag, E.NOENT);
- return handle(p, .{ .tag = req.tag, .op = .getattr, .node = factory_base + @intFromEnum(want) });
- },
- else => return Reply.fail(req.tag, E.NOTDIR),
- },
- .pane => |t| pane: {
- _ = p.paneBySerial(t.serial) orelse return Reply.fail(req.tag, E.NOENT);
- const f = switch (t.file) {
- .dir => paneFileNamed(name),
- .pty => ptyFileNamed(name),
- else => return Reply.fail(req.tag, E.NOTDIR),
- } orelse return Reply.fail(req.tag, E.NOENT);
- break :pane Node.of(t.serial, f);
- },
- };
- return switch (attrOf(p, Node.target(node) orelse return Reply.fail(req.tag, E.NOENT))) {
- .ok => |a| .{ .tag = req.tag, .attr = a },
- .missing => Reply.fail(req.tag, E.NOENT),
- };
-}
-
-pub fn stageDirent(out: *std.ArrayList(u8), gpa: std.mem.Allocator, node: u64, dir: bool, name: []const u8) void {
- if (name.len == 0 or name.len > 255) return;
- var head: [10]u8 = undefined;
- std.mem.writeInt(u64, head[0..8], node, .little);
- head[8] = @intFromBool(dir);
- head[9] = @intCast(name.len);
- out.appendSlice(gpa, &head) catch return;
- out.appendSlice(gpa, name) catch return;
-}
-
-fn stagePaneFiles(p: *Pardes, out: *std.ArrayList(u8), serial: u32, terminal: bool, skip: *u64) void {
- inline for (comptime std.enums.values(PaneFile)) |f| {
- if (comptime f == .dir or (f.inPty() and f != .pty)) continue;
- const present = f != .pty or terminal;
- if (present) {
- if (skip.* > 0) skip.* -= 1 else stageDirent(out, p.gpa, Node.of(serial, f), f.isDir(), f.name());
- }
- }
-}
-
-fn stagePtyFiles(p: *Pardes, out: *std.ArrayList(u8), serial: u32, skip: *u64) void {
- inline for (comptime std.enums.values(PaneFile)) |f| {
- if (comptime !f.inPty() or f == .pty) continue;
- if (skip.* > 0) skip.* -= 1 else stageDirent(out, p.gpa, Node.of(serial, f), false, f.name());
- }
-}
-
-fn readdir(p: *Pardes, req: Req, target: Target) Reply {
- const out = p.fs.stage(p.gpa);
- var skip = req.off;
- switch (target) {
- .top => |f| switch (f) {
- .root => {
- inline for (.{ SelfFile.index, SelfFile.cons, SelfFile.new }) |t| {
- if (skip > 0) skip -= 1 else stageDirent(out, p.gpa, @intFromEnum(t), t.dir(), t.name());
- }
- if (skip > 0) skip -= 1 else stageDirent(out, p.gpa, namespace_panes, true, "pane");
- if (skip > 0) skip -= 1 else stageDirent(out, p.gpa, @intFromEnum(SelfFile.screen), false, "screen");
- if (skip > 0) skip -= 1 else stageDirent(out, p.gpa, @intFromEnum(SelfFile.listeners), false, "listeners");
- if (skip > 0) skip -= 1 else stageDirent(out, p.gpa, @intFromEnum(SelfFile.README), false, "README");
- stageArchive(p, out, "", &skip);
- },
- .new => {
- if (skip > 0) skip -= 1 else stageDirent(out, p.gpa, @intFromEnum(SelfFile.README), false, "README");
- inline for (comptime std.enums.values(PaneFile)) |file| {
- if (comptime file == .dir or file.inPty()) continue;
- if (skip > 0) skip -= 1 else stageDirent(out, p.gpa, factory_base + @intFromEnum(file), false, file.name());
- }
- },
- else => return Reply.fail(req.tag, E.NOTDIR),
- },
- .pane => |t| {
- const id = p.paneBySerial(t.serial) orelse return Reply.fail(req.tag, E.NOENT);
- const terminal = p.panes[id].?.isTerminal();
- switch (t.file) {
- .dir => stagePaneFiles(p, out, t.serial, terminal, &skip),
- .pty => {
- if (!terminal) return Reply.fail(req.tag, E.NOENT);
- stagePtyFiles(p, out, t.serial, &skip);
- },
- else => return Reply.fail(req.tag, E.NOTDIR),
- }
- },
- }
- return .{ .tag = req.tag, .payload = .{ .staged = @intCast(out.items.len) } };
-}
-
-fn open(p: *Pardes, req: Req, target: Target) Reply {
- const snapshot = switch (target) {
- .top => |f| f == .screen,
- .pane => |t| t.file == .body and if (p.paneBySerial(t.serial)) |id| p.panes[id].?.isTerminal() else false,
- };
- if (snapshot) {
- for (&p.fs.snapshots, 0..) |*slot, i| {
- if (slot.node != 0) continue;
- const bytes = if (target == .top) screenSnapshot(p) catch return Reply.fail(req.tag, E.NOMEM) else null;
- slot.* = .{ .node = req.node, .bytes = bytes };
- return .{ .tag = req.tag, .handle = @intCast(i + 1) };
- }
- return Reply.fail(req.tag, E.NFILE);
- }
- switch (target) {
- .top => {},
- .pane => |t| {
- const id = p.paneBySerial(t.serial) orelse return Reply.fail(req.tag, E.NOENT);
- const pf = &p.fs.panes[id];
- if (t.file.inPty() and !p.panes[id].?.isTerminal()) return Reply.fail(req.tag, E.NOENT);
- switch (t.file) {
- .ctl => pf.limit = null,
- .addr => {
- pf.addr = .{};
- pf.limit = null;
- },
- .event => {
- pf.readers +|= 1;
- p.fs.listeners +|= 1;
- },
- .pty_data => pf.pty_readers +|= 1,
- else => {},
- }
- },
- }
- return .{ .tag = req.tag, .handle = 1 };
-}
-
-fn release(p: *Pardes, req: Req) Reply {
- if (req.handle > 0 and req.handle <= p.fs.snapshots.len) {
- const snapshot = &p.fs.snapshots[req.handle - 1];
- if (snapshot.node == req.node) {
- if (snapshot.bytes) |bytes| p.gpa.free(bytes);
- snapshot.* = .{};
- return .{ .tag = req.tag };
- }
- }
- const target = Node.target(req.node) orelse return .{ .tag = req.tag };
- switch (target) {
- .top => {},
- .pane => |t| {
- if (t.file != .event and t.file != .pty_data) return .{ .tag = req.tag };
- const id = p.paneBySerial(t.serial) orelse return .{ .tag = req.tag };
- const pf = &p.fs.panes[id];
- if (t.file == .pty_data) {
- if (pf.pty_readers == 0) return .{ .tag = req.tag };
- pf.pty_readers -= 1;
- if (pf.pty_readers == 0) pf.pty_out.clearAndFree(p.gpa);
- return .{ .tag = req.tag };
- }
- if (pf.readers == 0) return .{ .tag = req.tag };
- pf.readers -= 1;
- p.fs.listeners -|= 1;
- if (pf.readers == 0) pf.tag_snap.clearAndFree(p.gpa);
- },
- }
- return .{ .tag = req.tag };
-}
-
-fn setattr(p: *Pardes, req: Req, target: Target) Reply {
- if (req.truncate) switch (target) {
- .pane => |t| switch (t.file) {
- .body, .data, .xdata => {
- const id = p.paneBySerial(t.serial) orelse return Reply.fail(req.tag, E.NOENT);
- const pane = p.panes[id].?;
- if (fileOf(pane) != null) {
- _ = spliceBody(p, id, pane, 0, bodyOf(pane).len, "") orelse
- return Reply.fail(req.tag, E.NOMEM);
- p.fs.panes[id].addr = .{};
- setDot(pane, .{});
- }
- },
- else => {},
- },
- else => {},
- };
- return switch (attrOf(p, target)) {
- .ok => |a| .{ .tag = req.tag, .attr = a },
- .missing => Reply.fail(req.tag, E.NOENT),
- };
-}
-
-fn staged(p: *Pardes, req: Req) Reply {
- const out = &p.fs.out;
- const off = @min(req.off, out.items.len);
- const n = @min(out.items.len - off, req.size);
- if (off > 0) std.mem.copyForwards(u8, out.items[0..n], out.items[off..][0..n]);
- out.shrinkRetainingCapacity(n);
- return .{ .tag = req.tag, .payload = .{ .staged = @intCast(n) } };
-}
-
-fn handleRead(p: *Pardes, req: Req, target: Target) Reply {
- switch (target) {
- .top => |f| return switch (f) {
- .index => readIndex(p, req),
- .README => help: {
- const off = @min(req.off, fs_help.len);
- const bytes = fs_help[off..][0..@min(req.size, fs_help.len - off)];
- p.fs.stage(p.gpa).appendSlice(p.gpa, bytes) catch break :help Reply.fail(req.tag, E.NOMEM);
- break :help .{ .tag = req.tag, .payload = .{ .staged = @intCast(bytes.len) } };
- },
- .listeners => listeners: {
- var buf: [512]u8 = undefined;
- var text = std.Io.Writer.fixed(&buf);
- if (p.fs.socket_path.len != 0)
- text.print("unix!{s}\n", .{p.fs.socket_path}) catch break :listeners Reply.fail(req.tag, E.IO);
- for ([_]?std.Io.net.IpAddress{ p.fs.tcp_address, p.fs.quic_address }, [_][]const u8{ "tcp", "quic" }) |maybe, transport| {
- const address = maybe orelse continue;
- switch (address) {
- .ip4 => |ip| text.print("{s}!{d}.{d}.{d}.{d}!{d}\n", .{ transport, ip.bytes[0], ip.bytes[1], ip.bytes[2], ip.bytes[3], ip.port }) catch
- break :listeners Reply.fail(req.tag, E.IO),
- .ip6 => |ip| text.print("{s}!{f}!{d}\n", .{ transport, std.Io.net.Ip6Address.Unresolved{ .bytes = ip.bytes, .interface_name = null }, ip.port }) catch
- break :listeners Reply.fail(req.tag, E.IO),
- }
- }
- const bytes = text.buffered();
- const off = @min(req.off, bytes.len);
- const len = @min(bytes.len - off, req.size);
- p.fs.stage(p.gpa).appendSlice(p.gpa, bytes[off..][0..len]) catch break :listeners Reply.fail(req.tag, E.NOMEM);
- break :listeners .{ .tag = req.tag, .payload = .{ .staged = @intCast(len) } };
- },
- .screen => readSnapshot(p, req, null),
- .cons, .root, .new => Reply.fail(req.tag, E.PERM),
- },
- .pane => |t| {
- const id = p.paneBySerial(t.serial) orelse return Reply.fail(req.tag, E.NOENT);
- const pane = p.panes[id].?;
- const pf = &p.fs.panes[id];
- if (t.file.inPty() and !pane.isTerminal()) return Reply.fail(req.tag, E.NOENT);
- return switch (t.file) {
- .addr => readAddr(p, req, pf, pane),
- .body => readBody(p, req, id, pane),
- .ctl => readCtl(p, req, pane),
- .data => readData(req, id, pane, pf, false),
- .xdata => readData(req, id, pane, pf, true),
- .tag => readTag(p, req, pane),
- .event => readQueue(p, req, &pf.events),
- .rdsel => readRdsel(req, id, pane),
- .pty_status => readPtyStatus(p, req, id, pane),
- .pty_data => readPtyData(p, req, pf),
- .dir, .errors, .wrsel, .pty, .pty_ctl => Reply.fail(req.tag, E.PERM),
- };
- },
- }
-}
-
-fn screenSnapshot(p: *Pardes) ![]u8 {
- var arena: std.heap.ArenaAllocator = .init(p.gpa);
- defer arena.deinit();
- const surface = try p.render(arena.allocator());
- var styles: std.ArrayList(pardes.CellStyle) = .empty;
- var indices: std.ArrayList(usize) = .empty;
- for (surface.cells) |cell| {
- const style: pardes.CellStyle = if (cell.default) .{} else cell.style;
- const index = for (styles.items, 0..) |previous, i| {
- if (std.meta.eql(style, previous)) break i;
- } else new: {
- try styles.append(arena.allocator(), style);
- break :new styles.items.len - 1;
- };
- try indices.append(arena.allocator(), index);
- }
- var out: std.Io.Writer.Allocating = .init(p.gpa);
- errdefer out.deinit();
- const writer = &out.writer;
- try writer.print("{{\"cols\":{d},\"rows\":{d},\"cursor\":", .{ surface.cols, surface.rows });
- try std.json.Stringify.value(surface.cursor, .{}, writer);
- try writer.writeAll(",\"styles\":");
- try std.json.Stringify.value(styles.items, .{ .emit_strings_as_arrays = true }, writer);
- try writer.writeAll(",\"cells\":[");
- for (surface.cells, indices.items, 0..) |cell, index, i| {
- if (i != 0) try writer.writeByte(',');
- try std.json.Stringify.value(.{ if (cell.default) " " else cell.grapheme(), index }, .{}, writer);
- }
- try writer.writeAll("]}\n");
- return out.toOwnedSlice();
-}
-
-const ctl_fields = 5 * 12;
-
-fn stageCtlNumbers(p: *Pardes, out: *std.ArrayList(u8), pane: *Pane) void {
- out.print(p.gpa, "{d:>11} {d:>11} {d:>11} {d:>11} {d:>11} ", .{
- pane.serial,
- tagOf(p, pane).len,
- bodyOf(pane).len,
- @as(u32, 0),
- @intFromBool(dirtyOf(pane)),
- }) catch {};
-}
-
-fn readIndex(p: *Pardes, req: Req) Reply {
- const out = p.fs.stage(p.gpa);
- var last: u32 = 0;
- while (nextSerialAfter(p, last)) |s| {
- last = s;
- const pane = p.panes[p.paneBySerial(s).?].?;
- stageCtlNumbers(p, out, pane);
- out.appendSlice(p.gpa, firstLine(tagOf(p, pane))) catch {};
- out.append(p.gpa, '\n') catch {};
- }
- return staged(p, req);
-}
-
-fn readAddr(p: *Pardes, req: Req, pf: *PaneState, pane: *Pane) Reply {
- clampAddr(pf, bodyOf(pane).len);
- const out = p.fs.stage(p.gpa);
- out.print(p.gpa, "{d:>11} {d:>11} ", .{ pf.addr.q0, pf.addr.q1 }) catch {};
- return staged(p, req);
-}
-
-fn readBody(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply {
- if (pane.file != null) {
- const text = bodyOf(pane);
- const off = @min(req.off, text.len);
- const n = @min(text.len - off, req.size);
- return .{ .tag = req.tag, .payload = .{ .region = .{
- .pane = @intCast(id),
- .serial = pane.serial,
- .off = clip(off),
- .len = clip(n),
- } } };
- }
- if (req.handle != 0 and pane.isTerminal()) return readSnapshot(p, req, pane);
- const text = panes.Terminal.screenTextAlloc(pane, p.gpa) catch
- return Reply.fail(req.tag, E.NOMEM);
- defer p.gpa.free(text);
- const out = p.fs.stage(p.gpa);
- out.appendSlice(p.gpa, text) catch return Reply.fail(req.tag, E.NOMEM);
- return staged(p, req);
-}
-
-fn readSnapshot(p: *Pardes, req: Req, pane: ?*Pane) Reply {
- if (req.handle == 0 or req.handle > p.fs.snapshots.len) return Reply.fail(req.tag, E.INVAL);
- const snapshot = &p.fs.snapshots[req.handle - 1];
- if (snapshot.node == 0 or snapshot.node != req.node) return Reply.fail(req.tag, E.INVAL);
- if (snapshot.bytes == null) {
- const terminal = pane orelse return Reply.fail(req.tag, E.INVAL);
- snapshot.bytes = panes.Terminal.screenTextAlloc(terminal, p.gpa) catch return Reply.fail(req.tag, E.NOMEM);
- }
- const bytes = snapshot.bytes.?;
- const off = @min(req.off, bytes.len);
- const len = @min(bytes.len - off, req.size);
- const out = p.fs.stage(p.gpa);
- out.appendSlice(p.gpa, bytes[off..][0..len]) catch return Reply.fail(req.tag, E.NOMEM);
- return .{ .tag = req.tag, .payload = .{ .staged = @intCast(len) } };
-}
-
-fn fontName(p: *Pardes) []const u8 {
- const name = p.settings.font.effective_name.get();
- return if (name.len == 0) "default" else name;
-}
-
-fn stageQuoted(out: *std.ArrayList(u8), gpa: std.mem.Allocator, s: []const u8) void {
- const plain = s.len > 0 and for (s) |c| {
- if (c <= ' ' or c == '\'') break false;
- } else true;
- if (plain) {
- out.appendSlice(gpa, s) catch {};
- return;
- }
- out.append(gpa, '\'') catch {};
- for (s) |c| {
- if (c == '\'') out.append(gpa, '\'') catch {};
- out.append(gpa, c) catch {};
- }
- out.append(gpa, '\'') catch {};
-}
-
-fn readCtl(p: *Pardes, req: Req, pane: *Pane) Reply {
- const out = p.fs.stage(p.gpa);
- stageCtlNumbers(p, out, pane);
- out.print(p.gpa, "{d:>11} ", .{pane.cols}) catch {};
- stageQuoted(out, p.gpa, fontName(p));
- out.print(p.gpa, " {d:>11} ", .{config.tab_width}) catch {};
- return staged(p, req);
-}
-
-fn readTag(p: *Pardes, req: Req, pane: *Pane) Reply {
- const out = p.fs.stage(p.gpa);
- out.appendSlice(p.gpa, tagOf(p, pane)) catch {};
- return staged(p, req);
-}
-
-fn readData(req: Req, id: usize, pane: *Pane, pf: *PaneState, stop_at_end: bool) Reply {
- const text = bodyOf(pane);
- clampAddr(pf, text.len);
- const q0: usize = pf.addr.q0;
- const hi: usize = if (stop_at_end) @max(q0, @as(usize, pf.addr.q1)) else text.len;
- var end = @min(hi, q0 +| req.size);
- end = @max(q0, modal.graphemeStart(text, end));
- pf.addr.q0 = clip(end);
- if (!stop_at_end) pf.addr.q1 = clip(end);
- if (pane.file == null) return .{ .tag = req.tag };
- return .{ .tag = req.tag, .payload = .{ .region = .{
- .pane = @intCast(id),
- .serial = pane.serial,
- .off = clip(q0),
- .len = clip(end - q0),
- } } };
-}
-
-fn readRdsel(req: Req, id: usize, pane: *Pane) Reply {
- if (pane.file == null) return .{ .tag = req.tag };
- const text = bodyOf(pane);
- const d = dotOf(pane);
- const lo = @min(@as(usize, d.q0), text.len);
- const hi = @max(lo, @min(@as(usize, d.q1), text.len));
- const off = @min(req.off, hi - lo);
- const n = @min(hi - lo - off, req.size);
- return .{ .tag = req.tag, .payload = .{ .region = .{
- .pane = @intCast(id),
- .serial = pane.serial,
- .off = clip(lo + off),
- .len = clip(n),
- } } };
-}
-
-fn readQueue(p: *Pardes, req: Req, q: *Queue) Reply {
- const record = q.peek() orelse return .{ .tag = req.tag, .status = .again };
- if (req.size < record.len) return Reply.fail(req.tag, E.INVAL);
- const out = p.fs.stage(p.gpa);
- out.appendSlice(p.gpa, record) catch return Reply.fail(req.tag, E.NOMEM);
- q.pop();
- return .{ .tag = req.tag, .payload = .{ .staged = @intCast(out.items.len) } };
-}
-
-fn readPtyStatus(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply {
- const out = p.fs.stage(p.gpa);
- out.print(p.gpa, "{d:>11} {d:>11} {d:>11} ", .{
- pane.cols,
- pane.rows,
- @intFromBool(p.hostTtyTaken(id)),
- }) catch {};
- return staged(p, req);
-}
-
-fn readPtyData(p: *Pardes, req: Req, pf: *PaneState) Reply {
- if (pf.pty_out.empty()) return .{ .tag = req.tag, .status = .again };
- const out = p.fs.stage(p.gpa);
- while (out.items.len < req.size) {
- const chunk = pf.pty_out.peek() orelse break;
- const n = @min(chunk.len, req.size - out.items.len);
- out.appendSlice(p.gpa, chunk[0..n]) catch break;
- pf.pty_out.popFront(n);
- }
- return .{ .tag = req.tag, .payload = .{ .staged = @intCast(out.items.len) } };
-}
-
-fn handleWrite(p: *Pardes, req: Req, target: Target) Reply {
- switch (target) {
- .top => |f| return switch (f) {
- .cons => if (appendErrors(p, p.active, req.data)) |took|
- .{ .tag = req.tag, .written = @intCast(took) }
- else
- Reply.fail(req.tag, E.IO),
- else => Reply.fail(req.tag, E.PERM),
- },
- .pane => |t| {
- const id = p.paneBySerial(t.serial) orelse return Reply.fail(req.tag, E.NOENT);
- const pane = p.panes[id].?;
- if (t.file.inPty() and !pane.isTerminal()) return Reply.fail(req.tag, E.NOENT);
- return switch (t.file) {
- .addr => writeAddr(p, req, id, pane),
- .body => writeBody(p, req, id, pane),
- .ctl => writeCtl(p, req, t.serial),
- .data, .xdata => writeData(p, req, id, pane),
- .tag => writeTag(p, req, pane),
- .event => writeEvent(p, req, id),
- .wrsel => writeWrsel(p, req, id, pane),
- .errors => if (appendErrors(p, id, req.data)) |took|
- .{ .tag = req.tag, .written = @intCast(took) }
- else
- Reply.fail(req.tag, E.IO),
- .pty_ctl => writePtyCtl(p, req, id),
- .pty_data => writePtyData(p, req, id),
- .dir, .rdsel, .pty, .pty_status => Reply.fail(req.tag, E.PERM),
- };
- },
- }
-}
-
-fn spliceBody(p: *Pardes, id: usize, pane: *Pane, q0: usize, q1: usize, bytes: []const u8) ?usize {
- const f = fileOf(pane) orelse return null;
- const take = if (bytes.len == 0) 0 else wholeUtf8(bytes);
- const lo = @min(q0, f.content.len);
- const hi = @max(lo, @min(q1, f.content.len));
- const new = p.gpa.alloc(u8, f.content.len - (hi - lo) + take) catch return null;
- @memcpy(new[0..lo], f.content[0..lo]);
- @memcpy(new[lo..][0..take], bytes[0..take]);
- @memcpy(new[lo + take ..], f.content[hi..]);
- if (!p.fs.panes[id].nomark) panes.File.pushUndo(p, pane);
- panes.File.setContent(p, f, new);
- return take;
-}
-
-fn writeBody(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply {
- if (req.data.len == 0) return .{ .tag = req.tag, .written = 0 };
- if (pane.file == null) {
- const take = wholeUtf8(req.data);
- p.emitWrite(id, req.data[0..take]);
- return .{ .tag = req.tag, .written = @intCast(take) };
- }
- const at = bodyOf(pane).len;
- const take = spliceBody(p, id, pane, at, at, req.data) orelse
- return Reply.fail(req.tag, E.NOMEM);
- if (!p.fs.panes[id].noscroll) showOffset(pane, at + take);
- return .{ .tag = req.tag, .written = @intCast(take) };
-}
-
-fn writeTag(p: *Pardes, req: Req, pane: *Pane) Reply {
- if (req.data.len == 0) return .{ .tag = req.tag, .written = 0 };
- p.seedTail(pane);
- const room = pane.tag_tail.len - pane.tag_tail_len;
- if (room == 0) return Reply.fail(req.tag, E.NOSPC);
- const take = wholeUtf8(req.data[0..@min(req.data.len, room)]);
- @memcpy(pane.tag_tail[pane.tag_tail_len..][0..take], req.data[0..take]);
- pane.tag_tail_len += take;
- pane.tag_init = true;
- return .{ .tag = req.tag, .written = @intCast(take) };
-}
-
-fn writeData(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply {
- if (fileOf(pane) == null) return Reply.fail(req.tag, E.INVAL);
- const pf = &p.fs.panes[id];
- clampAddr(pf, bodyOf(pane).len);
- const q0: usize = pf.addr.q0;
- const q1: usize = @max(q0, @as(usize, pf.addr.q1));
- const before = dotOf(pane);
- const take = spliceBody(p, id, pane, q0, q1, req.data) orelse
- return Reply.fail(req.tag, E.NOMEM);
- setDot(pane, shiftBy(before, clip(q0), clip(q1 - q0), clip(take)));
- pf.addr = .{ .q0 = clip(q0 + take), .q1 = clip(q0 + take) };
- if (!pf.noscroll) showOffset(pane, q0 + take);
- return .{ .tag = req.tag, .written = @intCast(take) };
-}
-
-fn writeWrsel(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply {
- if (fileOf(pane) == null) return Reply.fail(req.tag, E.INVAL);
- const d = dotOf(pane);
- const q0: usize = d.q0;
- const q1: usize = @max(q0, @as(usize, d.q1));
- const take = spliceBody(p, id, pane, q0, q1, req.data) orelse
- return Reply.fail(req.tag, E.NOMEM);
- setDot(pane, .{ .q0 = clip(q0 + take), .q1 = clip(q0 + take) });
- return .{ .tag = req.tag, .written = @intCast(take) };
-}
-
-fn writeAddr(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply {
- const pf = &p.fs.panes[id];
- const text = bodyOf(pane);
- clampAddr(pf, text.len);
- const expr = std.mem.trimEnd(u8, req.data, "\n");
- var a: Addr = .{ .text = text, .lim = pf.limit, .expr = expr };
- const r = a.address(pf.addr) orelse return Reply.fail(req.tag, E.INVAL);
- if (a.i < expr.len) return Reply.fail(req.tag, E.INVAL);
- pf.addr = r;
- return .{ .tag = req.tag, .written = @intCast(req.data.len) };
-}
-
-fn safePattern(pat: []const u8) bool {
- var i: usize = 0;
- while (i < pat.len) : (i += 1) {
- if (pat[i] != '\\') continue;
- if (i + 1 >= pat.len) return false;
- i += 1;
- }
- return true;
-}
-
-const Addr = struct {
- text: []const u8,
- lim: ?PaneState.Range,
- expr: []const u8,
- i: usize = 0,
- depth: u8 = 0,
-
- const max_depth = 32;
- const Size = enum { char, line };
-
- fn address(a: *Addr, ar_in: PaneState.Range) ?PaneState.Range {
- const start = a.i;
- var ar = ar_in;
- var r = ar_in;
- var dir: u8 = 0;
- var size: Size = .line;
- var c: u8 = 0;
- while (a.i < a.expr.len) {
- const prevc = c;
- c = a.expr[a.i];
- a.i += 1;
- switch (c) {
- ',', ';' => {
- if (c == ';') ar = r;
- if (prevc == 0) r.q0 = 0; // lhs defaults to 0
- if (a.i >= a.expr.len) {
- r.q1 = clip(a.text.len); // rhs defaults to $
- } else {
- if (a.depth >= max_depth) return null;
- a.depth += 1;
- const nr = a.address(ar) orelse return null;
- a.depth -= 1;
- r.q1 = nr.q1;
- }
- return r;
- },
- '+', '-' => {
- if (prevc == '+' or prevc == '-') {
- const nc = if (a.i < a.expr.len) a.expr[a.i] else 0;
- if (nc != '#' and nc != '/' and nc != '?')
- r = a.number(r, 1, prevc, .line) orelse return null;
- }
- dir = c;
- },
- '.', '$' => {
- if (a.i != start + 1) {
- a.i -= 1;
- return r;
- }
- r = if (c == '.') ar else .{ .q0 = clip(a.text.len), .q1 = clip(a.text.len) };
- dir = if (a.i < a.expr.len) '+' else 0;
- },
- '#', '0'...'9' => {
- var digit = c;
- if (c == '#') {
- if (a.i >= a.expr.len or a.expr[a.i] < '0' or a.expr[a.i] > '9') {
- a.i -= 1;
- return r;
- }
- digit = a.expr[a.i];
- a.i += 1;
- size = .char;
- }
- var n: u64 = digit - '0';
- while (a.i < a.expr.len) : (a.i += 1) {
- const d = a.expr[a.i];
- if (d < '0' or d > '9') break;
- n = @min(n * 10 + (d - '0'), std.math.maxInt(u32));
- }
- r = a.number(r, @intCast(n), dir, size) orelse return null;
- dir = 0;
- size = .line;
- },
- '/', '?' => {
- const back = c == '?';
- r = a.regexp(r, a.pattern(c), back) orelse return null;
- dir = 0;
- size = .line;
- },
- else => {
- a.i -= 1;
- return r;
- },
- }
- }
- if (dir != 0) r = a.number(r, 1, dir, .line) orelse return null;
- return r;
- }
-
- fn pattern(a: *Addr, delim: u8) []const u8 {
- const s = a.i;
- while (a.i < a.expr.len) {
- const c = a.expr[a.i];
- if (c == '\n') break;
- a.i += 1;
- if (c == '\\') {
- if (a.i < a.expr.len) a.i += 1;
- continue;
- }
- if (c == delim) return a.expr[s .. a.i - 1];
- }
- return a.expr[s..a.i];
- }
-
- fn number(a: *Addr, r_in: PaneState.Range, n: u32, dir: u8, size: Size) ?PaneState.Range {
- var r = r_in;
- if (size == .char) {
- var off: i64 = n;
- if (dir == '+') {
- off = @as(i64, r.q1) + n;
- } else if (dir == '-') {
- if (r.q0 == 0 and n > 0) r.q0 = clip(a.text.len);
- off = @as(i64, r.q0) - n;
- }
- if (off < 0 or off > @as(i64, @intCast(a.text.len))) return null;
- const g = clip(modal.graphemeStart(a.text, @intCast(off)));
- return .{ .q0 = g, .q1 = g };
- }
- var line: i64 = n;
- var q0: usize = r.q0;
- var q1: usize = r.q1;
- switch (dir) {
- '-' => {
- if (q0 < a.text.len) while (q0 > 0 and a.text[q0 - 1] != '\n') {
- q0 -= 1;
- };
- q1 = q0;
- while (line > 0 and q0 > 0) {
- if (a.text[q0 - 1] == '\n') {
- line -= 1;
- q1 = q0;
- }
- q0 -= 1;
- }
- if (line > 1) return null;
- while (q0 > 0 and a.text[q0 - 1] != '\n') q0 -= 1;
- return .{ .q0 = clip(q0), .q1 = clip(q1) };
- },
- '+' => {
- if (q1 > 0) while (q1 < a.text.len and a.text[q1 - 1] != '\n') {
- q1 += 1;
- };
- q0 = q1;
- },
- else => {
- q0 = 0;
- q1 = 0;
- },
- }
- while (line > 0 and q1 < a.text.len) {
- const ch = a.text[q1];
- q1 += 1;
- if (ch == '\n' or q1 == a.text.len) {
- line -= 1;
- if (line > 0) q0 = q1;
- }
- }
- if (line > 0) return null;
- return .{ .q0 = clip(q0), .q1 = clip(q1) };
- }
-
- fn regexp(a: *Addr, r: PaneState.Range, pat: []const u8, back: bool) ?PaneState.Range {
- if (pat.len == 0 or !safePattern(pat)) return null;
- const re = mvzr.compile(pat) orelse return null;
- if (back) {
- const hi = @min(@as(usize, r.q0), a.text.len);
- var best: ?mvzr.Match = null;
- var at: usize = 0;
- while (at < hi) {
- const m = re.matchPos(at, a.text[0..hi]) orelse break;
- best = m;
- at = if (m.end > m.start) m.end else m.end + 1;
- }
- const m = best orelse return null;
- return .{ .q0 = clip(m.start), .q1 = clip(m.end) };
- }
- const hi = if (a.lim) |l| @min(@as(usize, l.q1), a.text.len) else a.text.len;
- const from = @min(@as(usize, r.q1), hi);
- const m = re.match(a.text[from..hi]) orelse return null;
- return .{ .q0 = clip(from + m.start), .q1 = clip(from + m.end) };
- }
-};
-
-const Verb = enum {
- @"addr=dot",
- clean,
- cleartag,
- del,
- delete,
- dirty,
- @"dot=addr",
- get,
- @"limit=addr",
- mark,
- nomark,
- noscroll,
- put,
- scroll,
- show,
-};
-
-const refused_verbs = [_][]const u8{ "dump", "dumpdir", "font", "lock", "menu", "nomenu", "unlock" };
-
-fn verbIs(line: []const u8, word: []const u8) bool {
- if (!std.mem.startsWith(u8, line, word)) return false;
- return line.len == word.len or line[word.len] == ' ';
-}
-
-fn writeCtl(p: *Pardes, req: Req, serial: u32) Reply {
- for ([2]bool{ false, true }) |apply| {
- var dirty = if (p.paneBySerial(serial)) |id| dirtyOf(p.panes[id].?) else false;
- var it = std.mem.splitScalar(u8, req.data, '\n');
- while (it.next()) |raw| {
- const line = std.mem.trim(u8, raw, " \t\r");
- if (line.len == 0) continue;
- const live = p.paneBySerial(serial) orelse if (apply) break else return Reply.fail(req.tag, E.NOENT);
- const errno = ctlVerb(p, live, line, apply, &dirty);
- if (errno != 0) return Reply.fail(req.tag, errno);
- }
- }
- return .{ .tag = req.tag, .written = @intCast(req.data.len) };
-}
-
-/// Change a buffer's absolute name without writing or renaming a disk file.
-/// Tag editing preserves user commands; the 9P name operation resets its tail.
-pub fn nameBuffer(p: *Pardes, id: usize, full: []const u8, preserve_tag: bool) !void {
- const pane = p.panes[id] orelse return error.NoPane;
- const f = fileOf(pane) orelse return error.NotFile;
- if (!std.fs.path.isAbsolute(full) or full.len == 0 or full.len >= 4096)
- return error.InvalidName;
- for (full) |c| if (c < ' ') return error.InvalidName;
- if (std.mem.eql(u8, f.path, full)) return;
- const copy = try p.gpa.dupe(u8, full);
- p.gpa.free(f.path);
- f.path = copy;
- if (panes.Output.fileTraits(f.output).saves) {
- f.output = null;
- pane.clearCwd();
- if (!preserve_tag) {
- pane.tag_init = false;
- pane.tag_tail_len = 0;
- }
- f.saved_revision = f.revision -% 1;
- f.watch_after_save = localPath(full) != null;
- }
- if (f.highlights.len > 0) p.tree_sitter_gpa.free(f.highlights);
- f.highlights = &.{};
- f.highlight_start = 0;
- f.syntax_dirty = true;
- f.context_revision = null;
- pane.context_rows = 0;
- pane.wrap_n = 0;
- p.invalidateLookHover(id);
- p.emit(.{ .watch = .{ .pane = @intCast(id), .on = false } });
-}
-
-fn ctlVerb(p: *Pardes, id: usize, line: []const u8, apply: bool, dirty: *bool) u16 {
- const pane = p.panes[id] orelse return E.INVAL;
- const pf = &p.fs.panes[id];
-
- if (verbIs(line, "look")) {
- if (line.len <= 5) return E.INVAL;
- const word = std.mem.trim(u8, line[5..], " \t");
- if (word.len == 0) return E.INVAL;
- for (word) |c| if (c < ' ') return E.INVAL;
- if (apply) p.lookAt(id, word);
- return 0;
- }
- if (verbIs(line, "name")) {
- if (line.len <= 5) return E.INVAL;
- const name = std.mem.trim(u8, line[5..], " \t");
- if (name.len == 0) return E.INVAL;
- for (name) |c| if (c <= ' ') return E.INVAL;
- const f = fileOf(pane) orelse return 0;
- const full = std.fs.path.resolvePosix(p.gpa, &.{ Pardes.paneDir(pane), name }) catch return E.NOMEM;
- defer p.gpa.free(full);
- if (!std.fs.path.isAbsolute(full) or full.len >= 4096) return E.INVAL;
- if (std.mem.eql(u8, f.path, full)) return 0;
- if (panes.Output.fileTraits(f.output).saves) dirty.* = true;
- if (!apply) return 0;
- nameBuffer(p, id, full, false) catch return E.NOMEM;
- return 0;
- }
- for (refused_verbs) |w| if (verbIs(line, w)) return E.INVAL;
-
- const v = std.meta.stringToEnum(Verb, line) orelse return E.INVAL;
- if (v == .del and dirty.*) return E.INVAL;
- switch (v) {
- .dirty => dirty.* = true,
- .clean, .get, .put => dirty.* = false,
- else => {},
- }
- if (!apply) return 0;
-
- switch (v) {
- .@"addr=dot" => pf.addr = dotOf(pane),
- .@"dot=addr" => {
- clampAddr(pf, bodyOf(pane).len);
- setDot(pane, pf.addr);
- },
- .@"limit=addr" => {
- clampAddr(pf, bodyOf(pane).len);
- pf.limit = pf.addr;
- },
- .clean => if (fileOf(pane)) |f| {
- f.saved_revision = f.revision;
- },
- .dirty => if (fileOf(pane)) |f| {
- f.saved_revision = f.revision -% 1;
- },
- .cleartag => {
- pane.tag_tail_len = 0;
- pane.tag_init = true;
- },
- .del, .delete => p.removePane(id) catch return E.NOMEM,
- .put => {
- const serial = pane.serial;
- if (fileOf(pane)) |f| {
- if (f.output == null) {
- p.perform(.{ .save_file = .{ .pane = @intCast(id) } });
- const current = p.paneBySerial(serial) orelse return E.NOENT;
- if (dirtyOf(p.panes[current].?)) return E.IO;
- } else _ = p.executeBuiltinLine(id, "Save");
- } else _ = p.executeBuiltinLine(id, "Save");
- },
- .get => if (fileOf(pane)) |f| {
- if (panes.Output.fileTraits(f.output).saves) {
- if (read(p, f.path)) |bytes| {
- panes.File.pushUndo(p, pane);
- panes.File.setContent(p, f, bytes);
- f.saved_revision = f.revision;
- } else |err| return switch (err) {
- error.FileNotFound => E.NOENT,
- else => E.IO,
- };
- }
- },
- .mark => {
- pf.nomark = false;
- panes.File.pushUndo(p, pane);
- },
- .nomark => pf.nomark = true,
- .noscroll => pf.noscroll = true,
- .scroll => pf.noscroll = false,
- .show => showOffset(pane, dotOf(pane).q0),
- }
- return 0;
-}
-
-const PtyVerb = enum { winsize, sig, exec };
-
-fn ptyDimension(word: []const u8) ?u16 {
- if (word.len == 0 or word.len > 5) return null;
- for (word) |c| if (c < '0' or c > '9') return null;
- const n = std.fmt.parseInt(u16, word, 10) catch return null;
- return if (n == 0) null else n;
-}
-
-fn ptySignalNamed(word: []const u8) ?pardes.PtySignal {
- if (std.mem.eql(u8, word, "INT")) return .int;
- if (std.mem.eql(u8, word, "TERM")) return .term;
- if (std.mem.eql(u8, word, "HUP")) return .hup;
- if (std.mem.eql(u8, word, "QUIT")) return .quit;
- if (std.mem.eql(u8, word, "KILL")) return .kill;
- return null;
-}
-
-fn writePtyCtl(p: *Pardes, req: Req, id: usize) Reply {
- for ([2]bool{ false, true }) |apply| {
- var it = std.mem.splitScalar(u8, req.data, '\n');
- while (it.next()) |raw| {
- const line = std.mem.trim(u8, raw, " \t\r");
- if (line.len == 0) continue;
- if (!ptyVerb(p, id, line, apply)) return Reply.fail(req.tag, E.INVAL);
- }
- }
- return .{ .tag = req.tag, .written = @intCast(req.data.len) };
-}
-
-fn ptyVerb(p: *Pardes, id: usize, line: []const u8, apply: bool) bool {
- const pane = p.panes[id] orelse return false;
- var words = std.mem.tokenizeAny(u8, line, " \t");
- const v = std.meta.stringToEnum(PtyVerb, words.next() orelse return false) orelse return false;
- switch (v) {
- .winsize => {
- const cols = ptyDimension(words.next() orelse return false) orelse return false;
- const rows = ptyDimension(words.next() orelse return false) orelse return false;
- if (words.next() != null) return false;
- if (!apply) return true;
- p.emit(.{ .resize_pty = .{ .pane = @intCast(id), .cols = cols, .rows = rows } });
- },
- .sig => {
- const which = ptySignalNamed(words.next() orelse return false) orelse return false;
- if (words.next() != null) return false;
- if (!apply) return true;
- p.emit(.{ .signal_pty = .{ .pane = @intCast(id), .sig = which } });
- },
- .exec => {
- if (words.next() != null) return false;
- if (pane.cwdSlice().len > pardes.effect_path_cap) return false;
- if (!apply) return true;
- p.emit(.{ .spawn = .{ .pane = @intCast(id), .cwd = .from(pane.cwdSlice()) } });
- },
- }
- return true;
-}
-
-fn writePtyData(p: *Pardes, req: Req, id: usize) Reply {
- if (req.data.len == 0) return .{ .tag = req.tag, .written = 0 };
- const take = wholeUtf8(req.data);
- p.emitWrite(id, req.data[0..take]);
- return .{ .tag = req.tag, .written = @intCast(take) };
-}
-
-const EventRecord = struct { action: Action, q0: u32, q1: u32 };
-
-const EventReader = struct {
- data: []const u8,
- i: usize = 0,
-
- fn next(er: *EventReader) ?EventRecord {
- if (er.i >= er.data.len) return null;
- var i = er.i;
- if (i + 2 > er.data.len) return null;
- i += 1;
- const action = Action.fromChar(er.data[i]) orelse return null;
- i += 1;
- const q0 = scanNumber(er.data, &i) orelse return null;
- const q1 = scanNumber(er.data, &i) orelse return null;
- while (i < er.data.len and er.data[i] == ' ') i += 1;
- if (i >= er.data.len or er.data[i] != '\n') return null;
- er.i = i + 1;
- return .{ .action = action, .q0 = q0, .q1 = q1 };
- }
-};
-
-fn scanNumber(data: []const u8, i: *usize) ?u32 {
- while (i.* < data.len and data[i.*] == ' ') i.* += 1;
- const s = i.*;
- var n: u64 = 0;
- while (i.* < data.len and data[i.*] >= '0' and data[i.*] <= '9') : (i.* += 1)
- n = @min(n * 10 + (data[i.*] - '0'), std.math.maxInt(u32));
- if (i.* == s) return null;
- return @intCast(n);
-}
-
-fn writeEvent(p: *Pardes, req: Req, id: usize) Reply {
- const pane0 = p.panes[id] orelse return Reply.fail(req.tag, E.NOENT);
- const serial = pane0.serial;
- {
- const body = bodyOf(pane0);
- const tag = tagOf(p, pane0);
- var check: EventReader = .{ .data = req.data };
- while (check.next()) |r| {
- switch (r.action) {
- .body_look, .tag_look, .body_exec, .tag_exec => {},
- else => return Reply.fail(req.tag, E.INVAL),
- }
- const n = if (r.action.onTag()) tag.len else body.len;
- if (r.q0 > r.q1 or r.q1 > n) return Reply.fail(req.tag, E.INVAL);
- }
- if (check.i != req.data.len) return Reply.fail(req.tag, E.INVAL);
- }
- var run: EventReader = .{ .data = req.data };
- while (run.next()) |r| {
- const now = p.paneBySerial(serial) orelse break;
- const pane = p.panes[now].?;
- const whole = if (r.action.onTag()) tagOf(p, pane) else bodyOf(pane);
- const lo = @min(@as(usize, r.q0), whole.len);
- const hi = @max(lo, @min(@as(usize, r.q1), whole.len));
- const text = p.scratch.allocator().dupe(u8, whole[lo..hi]) catch continue;
- switch (r.action) {
- .body_exec, .tag_exec => _ = p.execute(now, text),
- .body_look, .tag_look => p.lookAt(now, text),
- else => unreachable,
- }
- }
- return .{ .tag = req.tag, .written = @intCast(req.data.len) };
-}
-
-fn appendErrors(p: *Pardes, id: usize, text: []const u8) ?usize {
- if (text.len == 0) return 0;
- const pane = p.panes[id] orelse return null;
- const dir = dirOf(pane);
- for (p.panes, 0..) |slot, i| {
- const q = slot orelse continue;
- const qf = fileOf(q) orelse continue;
- const o = qf.output orelse continue;
- if (std.meta.activeTag(o.from) != .errors) continue;
- if (!std.mem.eql(u8, std.fs.path.dirname(qf.path) orelse "", dir)) continue;
- return spliceBody(p, i, q, qf.content.len, qf.content.len, text);
- }
- const free = p.freeSlot() orelse return null;
- const content = p.gpa.dupe(u8, text) catch return null;
- const np = panes.Output.open(p, free, dir, .errors, "", content) catch {
- p.gpa.free(content);
- return null;
- };
- p.placeDoc(id, free, np);
- return text.len;
-}
-
-fn indexLen(p: *Pardes) u64 {
- var n: u64 = 0;
- for (p.panes) |slot| {
- const pane = slot orelse continue;
- n += ctl_fields + firstLine(tagOf(p, pane)).len + 1;
- }
- return n;
-}
-
-fn bodyLen(p: *Pardes, pane: *Pane) u64 {
- _ = p;
- return bodyOf(pane).len;
-}
-
-fn tagLen(p: *Pardes, pane: *Pane) u64 {
- return tagOf(p, pane).len;
-}
-
-test "filesystem inspection preserves pending and displayed Look hover" {
- const delay = config.look_preview_delay_frames orelse return;
- const gpa = std.testing.allocator;
- const p = try withFile(gpa, "alpha beta gamma\n");
- defer p.deinit();
- var frame: std.heap.ArenaAllocator = .init(gpa);
- defer frame.deinit();
- _ = try p.render(frame.allocator());
- const rect = p.rects[0];
- p.update(.{ .mouse = .{
- .button = .none,
- .kind = .motion,
- .col = rect.x + config.GUTTER + config.PREFIX_W + 7,
- .row = if (p.settings.tag_bottom) rect.y else rect.y + pardes.BOX_H,
- } });
- try testing.expect(p.look_hover_wait != null);
- const body = Node.of(serialOf(p), .body);
- const screen = @intFromEnum(SelfFile.screen);
- for (0..2) |phase| {
- if (phase == 1) {
- for (0..delay) |_| p.update(.tick);
- try testing.expect(p.look_hover_preview != null);
- }
- const waiting = p.look_hover_wait;
- const preview = p.look_hover_preview;
- const requests = [_]Req{
- .{ .tag = 1, .op = .lookup, .node = @intFromEnum(SelfFile.root), .data = "screen" },
- .{ .tag = 1, .op = .lookup, .node = @intFromEnum(SelfFile.new), .data = ".." },
- .{ .tag = 1, .op = .lookup, .node = @intFromEnum(SelfFile.new), .data = "body" },
- .{ .tag = 2, .op = .getattr, .node = body },
- .{ .tag = 3, .op = .open, .node = body },
- .{ .tag = 4, .op = .read, .node = body, .size = 5 },
- .{ .tag = 5, .op = .release, .node = body, .handle = 1 },
- .{ .tag = 6, .op = .readdir, .node = @intFromEnum(SelfFile.root), .size = 4096 },
- .{ .tag = 7, .op = .open, .node = screen },
- .{ .tag = 8, .op = .read, .node = screen, .handle = 1, .size = 32 },
- .{ .tag = 9, .op = .release, .node = screen, .handle = 1 },
- };
- for (requests) |req| {
- const answer = call(p, req);
- try testing.expectEqual(Status.ok, answer.reply.status);
- try testing.expect(std.meta.eql(waiting, p.look_hover_wait));
- try testing.expect(std.meta.eql(preview, p.look_hover_preview));
- try testing.expect(p.raw_hover_intent);
- }
- }
- try testing.expectEqual(Status.ok, wr(p, body, "changed").reply.status);
- try testing.expect(p.look_hover_wait == null);
- try testing.expect(p.look_hover_preview == null);
- try testing.expect(!p.raw_hover_intent);
-}
-
-test "filesystem pane creation and truncation cancel Look hover" {
- const requests = [_]Req{
- .{ .tag = 2, .op = .open, .node = factory_base + @intFromEnum(PaneFile.body) },
- .{ .tag = 3, .op = .setattr, .node = 0, .truncate = true },
- };
- for (requests) |request| {
- const p = try withFile(testing.allocator, "word\n");
- defer p.deinit();
- p.look_hover_wait = .{ .col = 1, .row = 1, .pane = 0, .serial = serialOf(p) };
- p.raw_hover_intent = true;
- var req = request;
- if (req.op == .setattr) req.node = Node.of(serialOf(p), .body);
- try testing.expectEqual(Status.ok, call(p, req).reply.status);
- try testing.expect(p.look_hover_wait == null);
- try testing.expect(p.look_hover_preview == null);
- try testing.expect(!p.raw_hover_intent);
- }
-}
-
-test "terminal body handles keep one history snapshot across fragmented reads" {
- const gpa = std.testing.allocator;
- const p = try withTerm(gpa);
- defer p.deinit();
- const node = Node.of(serialOf(p), .body);
- p.update(.{ .output = .{ .pane = 0, .bytes = "old caf\xc3\xa9\r\nold tail" } });
- while (p.nextEffect()) |_| {}
- const original = try panes.Terminal.screenTextAlloc(p.panes[0].?, gpa);
- defer gpa.free(original);
- const opened = call(p, .{ .tag = 1, .op = .open, .node = node });
- try testing.expectEqual(Status.ok, opened.reply.status);
- const first = call(p, .{ .tag = 2, .op = .read, .node = node, .handle = opened.reply.handle, .size = 3 });
- try testing.expectEqualStrings(original[0..3], first.bytes);
- p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[3J\x1b[2J\x1b[Hnew output" } });
- while (p.nextEffect()) |_| {}
- var offset: usize = 3;
- while (offset < original.len) {
- const part = call(p, .{ .tag = 3, .op = .read, .node = node, .handle = opened.reply.handle, .off = offset, .size = 3 });
- try testing.expectEqualStrings(original[offset..][0..@min(3, original.len - offset)], part.bytes);
- offset += part.bytes.len;
- }
- try testing.expectEqualStrings("", call(p, .{ .tag = 4, .op = .read, .node = node, .handle = opened.reply.handle, .off = original.len, .size = 3 }).bytes);
- _ = call(p, .{ .tag = 5, .op = .release, .node = node, .handle = opened.reply.handle });
- const newer = call(p, .{ .tag = 6, .op = .open, .node = node });
- try testing.expectEqualStrings("new output", call(p, .{ .tag = 7, .op = .read, .node = node, .handle = newer.reply.handle, .size = 32 }).bytes);
- _ = call(p, .{ .tag = 8, .op = .release, .node = node, .handle = newer.reply.handle });
-}
-
-test "an empty terminal body snapshot stays empty while output continues" {
- const p = try withTerm(std.testing.allocator);
- defer p.deinit();
- const node = Node.of(serialOf(p), .body);
- const opened = call(p, .{ .tag = 1, .op = .open, .node = node });
- try testing.expectEqualStrings("", call(p, .{ .tag = 2, .op = .read, .node = node, .handle = opened.reply.handle, .size = 32 }).bytes);
- try testing.expect(p.fs.snapshots[opened.reply.handle - 1].bytes != null);
- p.update(.{ .output = .{ .pane = 0, .bytes = "new output" } });
- while (p.nextEffect()) |_| {}
- try testing.expectEqualStrings("", call(p, .{ .tag = 3, .op = .read, .node = node, .handle = opened.reply.handle, .size = 32 }).bytes);
- _ = call(p, .{ .tag = 4, .op = .release, .node = node, .handle = opened.reply.handle });
- const newer = call(p, .{ .tag = 5, .op = .open, .node = node });
- try testing.expectEqualStrings("new output", call(p, .{ .tag = 6, .op = .read, .node = node, .handle = newer.reply.handle, .size = 32 }).bytes);
- _ = call(p, .{ .tag = 7, .op = .release, .node = node, .handle = newer.reply.handle });
-}
-
-test "terminal body snapshots are lazy bounded and released after the pane closes" {
- const gpa = std.testing.allocator;
- const p = try withTerm(gpa);
- defer p.deinit();
- const node = Node.of(serialOf(p), .body);
- var handles: [32]u32 = undefined;
- for (&handles) |*opened_handle| {
- const opened = call(p, .{ .tag = 1, .op = .open, .node = node });
- try testing.expectEqual(Status.ok, opened.reply.status);
- opened_handle.* = opened.reply.handle;
- }
- for (p.fs.snapshots) |snapshot| {
- try testing.expectEqual(node, snapshot.node);
- try testing.expect(snapshot.bytes == null);
- }
- try testing.expectEqual(E.NFILE, call(p, .{ .tag = 2, .op = .open, .node = node }).errno());
- const scratch = call(p, .{ .tag = 2, .op = .open, .node = factory_base + @intFromEnum(PaneFile.body) });
- try testing.expectEqual(Status.ok, scratch.reply.status);
- const scratch_id = p.paneBySerial(Node.target(scratch.reply.attr.node).?.pane.serial).?;
- try testing.expect(p.panes[scratch_id].?.file != null);
- _ = call(p, .{ .tag = 2, .op = .release, .node = scratch.reply.attr.node, .handle = scratch.reply.handle });
- p.update(.{ .output = .{ .pane = 0, .bytes = "latest output" } });
- while (p.nextEffect()) |_| {}
- var failing = std.testing.FailingAllocator.init(gpa, .{ .fail_index = 0 });
- p.gpa = failing.allocator();
- const failed = call(p, .{ .tag = 3, .op = .read, .node = node, .handle = handles[0], .size = 32 });
- p.gpa = gpa;
- try testing.expectEqual(E.NOMEM, failed.errno());
- try testing.expect(p.fs.snapshots[handles[0] - 1].bytes == null);
- const first = call(p, .{ .tag = 4, .op = .read, .node = node, .handle = handles[0], .size = 32 });
- try testing.expectEqualStrings("latest output", first.bytes);
- const saved = p.fs.snapshots[handles[0] - 1].bytes.?;
- failing = std.testing.FailingAllocator.init(gpa, .{ .fail_index = 0 });
- p.gpa = failing.allocator();
- const second = call(p, .{ .tag = 5, .op = .read, .node = node, .handle = handles[0], .off = 7, .size = 32 });
- p.gpa = gpa;
- try testing.expectEqualStrings("output", second.bytes);
- try testing.expect(!failing.has_induced_failure);
- try testing.expectEqual(saved.ptr, p.fs.snapshots[handles[0] - 1].bytes.?.ptr);
- const screen = @intFromEnum(SelfFile.screen);
- try testing.expectEqual(E.INVAL, call(p, .{ .tag = 6, .op = .read, .node = screen, .handle = handles[0], .size = 32 }).errno());
- _ = call(p, .{ .tag = 7, .op = .release, .node = screen, .handle = handles[0] });
- try testing.expectEqual(node, p.fs.snapshots[handles[0] - 1].node);
- try p.removePane(0);
- for (handles) |opened_handle| _ = call(p, .{ .tag = 8, .op = .release, .node = node, .handle = opened_handle });
- for (p.fs.snapshots) |snapshot| {
- try testing.expectEqual(@as(u64, 0), snapshot.node);
- try testing.expect(snapshot.bytes == null);
- }
-}
-
-test "screen snapshots preserve rendered cells and styles until their handle is released" {
- const gpa = std.testing.allocator;
- const p = try withFile(gpa, "const value = 1;\n");
- defer p.deinit();
- const node = @intFromEnum(SelfFile.screen);
- const opened = call(p, .{ .tag = 1, .op = .open, .node = node });
- try testing.expectEqual(Status.ok, opened.reply.status);
- const snapshot = p.fs.snapshots[opened.reply.handle - 1].bytes.?;
- const original = try gpa.dupe(u8, snapshot);
- defer gpa.free(original);
- const parsed = try std.json.parseFromSlice(std.json.Value, gpa, original, .{});
- defer parsed.deinit();
- const data = parsed.value.object;
- try testing.expectEqual(@as(i64, p.screen_w), data.get("cols").?.integer);
- try testing.expectEqual(@as(i64, p.screen_h), data.get("rows").?.integer);
- try testing.expectEqual(@as(usize, p.screen_w) * p.screen_h, data.get("cells").?.array.items.len);
- try testing.expect(data.get("styles").?.array.items.len > 0);
- _ = wr(p, Node.of(serialOf(p), .body), "changed\n");
- const newer = call(p, .{ .tag = 2, .op = .open, .node = node });
- try testing.expectEqual(Status.ok, newer.reply.status);
- try testing.expect(!std.mem.eql(u8, original, p.fs.snapshots[newer.reply.handle - 1].bytes.?));
- var off: usize = 0;
- while (off < original.len) {
- const result = call(p, .{ .tag = 3, .op = .read, .node = node, .handle = opened.reply.handle, .off = off, .size = 13 });
- try testing.expectEqual(Status.ok, result.reply.status);
- try testing.expectEqualSlices(u8, original[off..][0..@min(13, original.len - off)], result.bytes);
- off += result.bytes.len;
- }
- _ = call(p, .{ .tag = 4, .op = .release, .node = node, .handle = opened.reply.handle });
- _ = call(p, .{ .tag = 5, .op = .release, .node = node, .handle = newer.reply.handle });
- for (p.fs.snapshots) |slot| try testing.expect(slot.node == 0);
-}
-
-test "screen inspection preserves acknowledged presentation and the next real frame" {
- const gpa = testing.allocator;
- for (std.enums.values(pardes.layout.Transition)) |transition| {
- errdefer std.debug.print("screen inspection during {s}\n", .{@tagName(transition)});
- const control = try withFile(gpa, "const value = 1;\n");
- defer control.deinit();
- const inspected = try withFile(gpa, "const value = 1;\n");
- defer inspected.deinit();
- var arena: std.heap.ArenaAllocator = .init(gpa);
- defer arena.deinit();
- for ([_]*Pardes{ control, inspected }) |p| {
- p.settings.panel_transition = .off;
- p.update(.tick);
- p.acknowledgePanelPresentation((try p.render(arena.allocator())).panelTracks());
- p.settings.panel_transition = transition;
- }
- for (0..4) |phase| {
- for ([_]*Pardes{ control, inspected }) |p| switch (phase) {
- 0 => {},
- 1 => p.update(.{ .command = "New" }),
- 2 => {
- p.update(.tick);
- try testing.expectEqual(Status.ok, wr(p, Node.of(p.panes[p.active].?.serial, .body), "changed cells\n").reply.status);
- },
- 3 => {
- for (0..transition.frames() + 1) |_| p.update(.tick);
- p.acknowledgePanelPresentation((try p.render(arena.allocator())).panelTracks());
- p.update(.{ .command = "Del" });
- },
- else => unreachable,
- };
- const screen = @intFromEnum(SelfFile.screen);
- for (0..2) |_| {
- const opened = call(inspected, .{ .tag = 1, .op = .open, .node = screen });
- try testing.expectEqual(Status.ok, opened.reply.status);
- const captured = call(inspected, .{ .tag = 2, .op = .read, .node = screen, .handle = opened.reply.handle, .size = 32 });
- try testing.expectEqual(Status.ok, captured.reply.status);
- try testing.expect(captured.bytes.len > 0);
- try testing.expectEqual(Status.ok, call(inspected, .{
- .tag = 3,
- .op = .release,
- .node = screen,
- .handle = opened.reply.handle,
- }).reply.status);
- }
- const a = &control.presentation;
- const b = &inspected.presentation;
- try testing.expectEqualDeep(a.shown, b.shown);
- try testing.expectEqualDeep(a.shown_tracks, b.shown_tracks);
- try testing.expectEqualDeep(a.shown_closing[0..a.shown_closing_len], b.shown_closing[0..b.shown_closing_len]);
- try testing.expectEqual(a.pending, b.pending);
- try testing.expectEqual(a.acknowledged, b.acknowledged);
- try testing.expectEqual(a.previous_valid, b.previous_valid);
- try testing.expectEqual(a.previous_cols, b.previous_cols);
- try testing.expectEqual(a.previous_rows, b.previous_rows);
- try testing.expectEqualDeep(a.previous_layout, b.previous_layout);
- try testing.expectEqual(a.previous_cells.len, b.previous_cells.len);
- for (a.previous_cells, b.previous_cells) |*expected, *actual|
- try testing.expect(expected.visuallyEqual(actual));
-
- const expected = try control.render(arena.allocator());
- const actual = try inspected.render(arena.allocator());
- try testing.expectEqual(expected.cols, actual.cols);
- try testing.expectEqual(expected.rows, actual.rows);
- try testing.expectEqualDeep(expected.cursor, actual.cursor);
- try testing.expectEqualDeep(expected.panelTracks(), actual.panelTracks());
- try testing.expectEqualDeep(expected.cell_diffs, actual.cell_diffs);
- try testing.expectEqual(expected.previous_cells.len, actual.previous_cells.len);
- try testing.expectEqual(expected.cells.len, actual.cells.len);
- try testing.expectEqual(@as(usize, 0), expected.nimages);
- try testing.expectEqual(@as(usize, 0), actual.nimages);
- for (expected.cells, actual.cells) |*left, *right| try testing.expect(left.visuallyEqual(right));
- for (expected.previous_cells, actual.previous_cells) |*left, *right| try testing.expect(left.visuallyEqual(right));
- if (phase == 1 and transition != .off) try testing.expect(expected.panelTracks().len > 0);
- if (phase == 1 and transition.needsPreviousGrid()) try testing.expect(expected.hasPanelDiff());
- if (phase == 3 and transition == .vertical) try testing.expect(a.closing_len > 0);
- control.acknowledgePanelPresentation(expected.panelTracks());
- inspected.acknowledgePanelPresentation(actual.panelTracks());
- try testing.expectEqualDeep(a.shown, b.shown);
- try testing.expectEqualDeep(a.previous_layout, b.previous_layout);
- _ = arena.reset(.retain_capacity);
- }
- }
-}
-
-const testing = std.testing;
-
-const Answer = struct {
- reply: Reply = .{ .tag = 0, .status = .err, .errno = E.IO },
- bytes: []const u8 = "",
- saved: bool = false,
- watch: ?bool = null,
- pty_buf: [256]u8 = undefined,
- pty_len: usize = 0,
- winsize: ?struct { cols: u16, rows: u16 } = null,
- signal: ?pardes.PtySignal = null,
- spawned: bool = false,
-
- fn pty(a: *const Answer) []const u8 {
- return a.pty_buf[0..a.pty_len];
- }
-
- fn errno(a: Answer) u16 {
- return if (a.reply.status == .err) a.reply.errno else 0;
- }
-};
-
-fn call(p: *Pardes, req: Req) Answer {
- p.update(.{ .fs_req = req });
- var ans: Answer = .{};
- while (p.nextEffect()) |e| switch (e) {
- .fs_reply => |r| {
- ans.reply = r;
- ans.bytes = p.fsPayload(r);
- },
- .save_file, .save_text => ans.saved = true,
- .watch => |w| ans.watch = w.on,
- .write => |w| {
- const b = w.bytes.slice();
- const n = @min(b.len, ans.pty_buf.len - ans.pty_len);
- @memcpy(ans.pty_buf[ans.pty_len..][0..n], b[0..n]);
- ans.pty_len += n;
- },
- .resize_pty => |r| ans.winsize = .{ .cols = r.cols, .rows = r.rows },
- .signal_pty => |s| ans.signal = s.sig,
- .spawn => ans.spawned = true,
- else => {},
- };
- return ans;
-}
-
-fn rd(p: *Pardes, node: u64, off: u64, size: u32) Answer {
- return call(p, .{ .tag = 1, .op = .read, .node = node, .off = off, .size = size });
-}
-
-fn wr(p: *Pardes, node: u64, data: []const u8) Answer {
- return call(p, .{ .tag = 2, .op = .write, .node = node, .data = data });
-}
-
-fn rdir(p: *Pardes, node: u64, skip: u64) Answer {
- return call(p, .{ .tag = 4, .op = .readdir, .node = node, .off = skip, .size = 4096 });
-}
-
-fn look_up(p: *Pardes, dir: u64, name: []const u8) Answer {
- return call(p, .{ .tag = 3, .op = .lookup, .node = dir, .data = name });
-}
-
-fn withFile(gpa: std.mem.Allocator, text: []const u8) !*Pardes {
- const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 });
- errdefer p.deinit();
- while (p.nextEffect()) |_| {}
- _ = try p.setTestFile(text);
- while (p.nextEffect()) |_| {}
- return p;
-}
-
-fn withTerm(gpa: std.mem.Allocator) !*Pardes {
- const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 });
- errdefer p.deinit();
- while (p.nextEffect()) |_| {}
- std.debug.assert(p.panes[0].?.isTerminal());
- return p;
-}
-
-fn serialOf(p: *Pardes) u32 {
- return p.panes[0].?.serial;
-}
-
-const Dirent = struct { node: u64, dir: bool, name: []const u8 };
-
-fn dirents(bytes: []const u8, out: []Dirent) []Dirent {
- var n: usize = 0;
- var i: usize = 0;
- while (i + 10 <= bytes.len and n < out.len) {
- const node = std.mem.readInt(u64, bytes[i..][0..8], .little);
- const kind = bytes[i + 8];
- const len = bytes[i + 9];
- i += 10;
- if (i + len > bytes.len) break;
- out[n] = .{ .node = node, .dir = kind == 1, .name = bytes[i .. i + len] };
- i += len;
- n += 1;
- }
- return out[0..n];
-}
-
-fn nameAt(list: []const Dirent, want: []const u8) ?Dirent {
- for (list) |d| if (std.mem.eql(u8, d.name, want)) return d;
- return null;
-}
-
-test "listener addresses are readable canonical dials with bounded partial reads" {
- const gpa = testing.allocator;
- const p = try Pardes.init(gpa, .{ .tty_only = true });
- defer p.deinit();
- p.fs.socket_path = "/tmp/a socket.sock";
- p.fs.tcp_address = .{ .ip4 = .loopback(5640) };
- p.fs.quic_address = .{ .ip6 = .loopback(5641) };
- const expected = "unix!/tmp/a socket.sock\ntcp!127.0.0.1!5640\nquic!::1!5641\n";
- for ([_][]const u8{ "/virtual/listeners", "/n/self/listeners" }) |path| {
- const bytes = try read(p, path);
- defer gpa.free(bytes);
- try testing.expectEqualStrings(expected, bytes);
- try testing.expectError(error.ReadOnlyFilesystem, write(p, path, ""));
- }
- const node = @intFromEnum(SelfFile.listeners);
- var off: usize = 0;
- while (off < expected.len) {
- const part = rd(p, node, off, 3);
- try testing.expectEqual(Status.ok, part.reply.status);
- try testing.expectEqualStrings(expected[off..][0..@min(3, expected.len - off)], part.bytes);
- off += part.bytes.len;
- }
- try testing.expectEqual(@as(usize, 0), rd(p, node, off, 3).bytes.len);
-}
-
-test "readdir lists the root, a pane directory, and new/ without creating anything" {
- const gpa = testing.allocator;
- const p = try withFile(gpa, "hello\n");
- defer p.deinit();
- const serial = serialOf(p);
- var buf: [32]Dirent = undefined;
-
- const root = rdir(p, @intFromEnum(SelfFile.root), 0);
- try testing.expectEqual(Status.ok, root.reply.status);
- const top = dirents(root.bytes, &buf);
- try testing.expect(top.len >= 5);
- try testing.expectEqualStrings("index", top[0].name);
- try testing.expectEqualStrings("cons", top[1].name);
- try testing.expectEqualStrings("new", top[2].name);
- try testing.expect(top[2].dir and !top[0].dir);
- try testing.expectEqualStrings("pane", top[3].name);
- try testing.expect(top[3].dir);
- try testing.expectEqual(namespace_panes, top[3].node);
- var idbuf: [16]u8 = undefined;
- try testing.expect(nameAt(top, try std.fmt.bufPrint(&idbuf, "{d}", .{serial})) == null);
- try testing.expect(nameAt(top, "src") != null);
-
- const rest = rdir(p, @intFromEnum(SelfFile.root), 3);
- try testing.expectEqual(top.len - 3, dirents(rest.bytes, &buf).len);
- const eof = rdir(p, @intFromEnum(SelfFile.root), 99);
- try testing.expectEqual(Status.ok, eof.reply.status);
- try testing.expectEqual(@as(usize, 0), eof.bytes.len);
-
- const dir = rdir(p, Node.of(serial, .dir), 0);
- const files = dirents(dir.bytes, &buf);
- try testing.expectEqual(@as(usize, 10), files.len);
- try testing.expect(nameAt(files, "addr") != null);
- try testing.expect(nameAt(files, "xdata") != null);
- try testing.expect(nameAt(files, ".") == null);
- try testing.expectEqual(Node.of(serial, .body), nameAt(files, "body").?.node);
-
- const before = p.next_serial;
- const new = rdir(p, @intFromEnum(SelfFile.new), 0);
- try testing.expectEqual(Status.ok, new.reply.status);
- const factories = dirents(new.bytes, &buf);
- try testing.expectEqual(@as(usize, 11), factories.len);
- try testing.expect(nameAt(factories, "README") != null);
- for (factories) |entry| {
- const inspected = look_up(p, @intFromEnum(SelfFile.new), entry.name);
- try testing.expectEqual(Status.ok, inspected.reply.status);
- try testing.expectEqual(entry.node, inspected.reply.attr.node);
- try testing.expectEqual(Status.ok, call(p, .{ .tag = 1, .op = .getattr, .node = entry.node }).reply.status);
- }
- try testing.expectEqual(before, p.next_serial);
-
- try testing.expectEqual(E.NOTDIR, rdir(p, Node.of(serial, .body), 0).errno());
-}
-
-test "lookup resolves top files, pane serials and pane files" {
- const gpa = testing.allocator;
- const p = try withFile(gpa, "hello\n");
- defer p.deinit();
- const serial = serialOf(p);
- const root = @intFromEnum(SelfFile.root);
-
- try testing.expectEqual(@as(u64, @intFromEnum(SelfFile.index)), look_up(p, root, "index").reply.attr.node);
- try testing.expect(look_up(p, root, "new").reply.attr.dir);
- try testing.expectEqual(E.NOENT, look_up(p, root, "nosuchthing").errno());
-
- var idbuf: [16]u8 = undefined;
- const serial_name = try std.fmt.bufPrint(&idbuf, "{d}", .{serial});
- try testing.expectEqual(E.NOENT, look_up(p, root, serial_name).errno());
- const dir = look_up(p, namespace_panes, serial_name);
- try testing.expectEqual(Node.of(serial, .dir), dir.reply.attr.node);
- try testing.expect(dir.reply.attr.dir);
- try testing.expectEqual(E.NOENT, look_up(p, namespace_panes, "99999").errno());
-
- const body = look_up(p, Node.of(serial, .dir), "body");
- try testing.expectEqual(Node.of(serial, .body), body.reply.attr.node);
- const stat = call(p, .{ .tag = 4, .op = .getattr, .node = Node.of(serial, .body) });
- try testing.expectEqual(body.reply.attr.size, stat.reply.attr.size);
- try testing.expectEqual(@as(u64, "hello\n".len), stat.reply.attr.size);
- try testing.expectEqual(E.NOENT, look_up(p, Node.of(serial, .dir), "editout").errno());
- try testing.expectEqual(E.NOTDIR, look_up(p, Node.of(serial, .body), "x").errno());
-}
-
-test "new/ lookup is inert and each open creates a distinct pane" {
- const gpa = testing.allocator;
- const p = try withFile(gpa, "first\n");
- defer p.deinit();
- const before = serialOf(p);
-
- try testing.expectEqual(E.NOENT, look_up(p, @intFromEnum(SelfFile.new), "bogus").errno());
- try testing.expectEqual(before, p.next_serial);
-
- const a = look_up(p, @intFromEnum(SelfFile.new), "body");
- try testing.expectEqual(Status.ok, a.reply.status);
- try testing.expectEqual(before, p.next_serial);
- const opened = call(p, .{ .tag = 1, .op = .open, .node = a.reply.attr.node });
- try testing.expectEqual(Status.ok, opened.reply.status);
- const made: Node = @bitCast(opened.reply.attr.node);
- try testing.expect(made.serial != before);
- try testing.expectEqual(@intFromEnum(PaneFile.body), made.file);
-
- _ = wr(p, opened.reply.attr.node, "hi");
- const second = call(p, .{ .tag = 2, .op = .open, .node = a.reply.attr.node });
- try testing.expectEqual(Status.ok, second.reply.status);
- try testing.expect(second.reply.attr.node != opened.reply.attr.node);
- const id = p.paneBySerial(@intCast(made.serial)).?;
- try testing.expectEqualStrings("hi", p.panes[id].?.file.?.content);
-}
-
-test "index prints winctlprint's five fields then the tag" {
- const gpa = testing.allocator;
- const p = try withFile(gpa, "hello\nthere\n");
- defer p.deinit();
- const pane = p.panes[0].?;
-
- const a = rd(p, @intFromEnum(SelfFile.index), 0, 4096);
- try testing.expectEqual(Status.ok, a.reply.status);
- var got: [512]u8 = undefined;
- @memcpy(got[0..a.bytes.len], a.bytes);
- const line = got[0..a.bytes.len];
-
- const tag = try gpa.dupe(u8, tagOf(p, pane));
- defer gpa.free(tag);
- var want: std.ArrayList(u8) = .empty;
- defer want.deinit(gpa);
- try want.print(gpa, "{d:>11} {d:>11} {d:>11} {d:>11} {d:>11} {s}\n", .{
- pane.serial, tag.len, @as(usize, "hello\nthere\n".len), 0, 0, firstLine(tag),
- });
- try testing.expectEqualStrings(want.items, line);
- try testing.expectEqual(@as(usize, 60), std.mem.indexOf(u8, line, firstLine(tag)).?);
-
- const mid = rd(p, @intFromEnum(SelfFile.index), 60, 5);
- try testing.expectEqualStrings(firstLine(tag)[0..5], mid.bytes);
-
- pane.file.?.saved_revision = pane.file.?.revision -% 1;
- const dirty = rd(p, @intFromEnum(SelfFile.index), 48, 12);
- try testing.expectEqualStrings(" 1 ", dirty.bytes);
-}
-
-test "ctl read is index's five fields plus width in cells, font and tab width" {
- const gpa = testing.allocator;
- const p = try withFile(gpa, "x\n");
- defer p.deinit();
- const pane = p.panes[0].?;
-
- const a = rd(p, Node.of(pane.serial, .ctl), 0, 4096);
- try testing.expectEqual(Status.ok, a.reply.status);
- var want: std.ArrayList(u8) = .empty;
- defer want.deinit(gpa);
- try want.print(gpa, "{d:>11} {d:>11} {d:>11} {d:>11} {d:>11} {d:>11} {s} {d:>11} ", .{
- pane.serial, tagOf(p, pane).len, @as(usize, 2), 0, 0, pane.cols, "default", config.tab_width,
- });
- try testing.expectEqualStrings(want.items, a.bytes);
-
- var quoted: std.ArrayList(u8) = .empty;
- defer quoted.deinit(gpa);
- stageQuoted(&quoted, gpa, "DejaVu Sans Mono");
- try testing.expectEqualStrings("'DejaVu Sans Mono'", quoted.items);
- quoted.clearRetainingCapacity();
- stageQuoted(&quoted, gpa, "it's");
- try testing.expectEqualStrings("'it''s'", quoted.items);
-}
-
-test "body reads at any offset and writes append" {
- const gpa = testing.allocator;
- const p = try withFile(gpa, "one\ntwo\n");
- defer p.deinit();
- const serial = serialOf(p);
- const body = Node.of(serial, .body);
-
- try testing.expectEqualStrings("one\ntwo\n", rd(p, body, 0, 100).bytes);
- try testing.expectEqualStrings("two\n", rd(p, body, 4, 100).bytes);
- try testing.expectEqualStrings("wo", rd(p, body, 5, 2).bytes);
- try testing.expectEqualStrings("", rd(p, body, 999, 2).bytes);
- try testing.expect(rd(p, body, 0, 100).bytes.ptr == p.panes[0].?.file.?.content.ptr);
-
- const w = call(p, .{ .tag = 5, .op = .write, .node = body, .off = 0, .data = "three\n" });
- try testing.expectEqual(@as(u32, 6), w.reply.written);
- try testing.expectEqualStrings("one\ntwo\nthree\n", p.panes[0].?.file.?.content);
-
- const short = wr(p, body, "a\xC3");
- try testing.expectEqual(@as(u32, 1), short.reply.written);
- try testing.expectEqualStrings("one\ntwo\nthree\na", p.panes[0].?.file.?.content);
-}
-
-test "a body write to a terminal pane types at its shell" {
- const gpa = testing.allocator;
- const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 40, .rows = 10 });
- defer p.deinit();
- while (p.nextEffect()) |_| {}
- const pane = p.panes[0].?;
- try testing.expect(pane.isTerminal());
-
- const a = wr(p, Node.of(pane.serial, .body), "ls -l\r");
- try testing.expectEqual(@as(u32, 6), a.reply.written);
- try testing.expectEqualStrings("ls -l\r", a.pty());
-
- const r = rd(p, Node.of(pane.serial, .body), 0, 64);
- try testing.expectEqual(Status.ok, r.reply.status);
-}
-
-test "tag reads the whole tag and writes append to the editable tail" {
- const gpa = testing.allocator;
- const p = try withFile(gpa, "x\n");
- defer p.deinit();
- const pane = p.panes[0].?;
- const node = Node.of(pane.serial, .tag);
-
- const whole = rd(p, node, 0, 4096);
- try testing.expect(std.mem.startsWith(u8, whole.bytes, "/test.txt"));
- try testing.expect(std.mem.indexOf(u8, whole.bytes, "Del") != null);
-
- const before = rd(p, node, 0, 4096).bytes.len;
- const w = wr(p, node, " Mine");
- try testing.expectEqual(@as(u32, 5), w.reply.written);
- try testing.expect(std.mem.endsWith(u8, pane.tag_tail[0..pane.tag_tail_len], " Mine"));
- const after = rd(p, node, 0, 4096);
- try testing.expectEqual(before + 5, after.bytes.len);
- try testing.expect(std.mem.endsWith(u8, after.bytes, " Mine"));
-
- pane.tag_tail_len = pane.tag_tail.len;
- try testing.expectEqual(E.NOSPC, wr(p, node, "x").errno());
-}
-
-test "the address language, form by form" {
- const gpa = testing.allocator;
- const p = try withFile(gpa, "one\ntwo\nthree\n");
- defer p.deinit();
- const serial = serialOf(p);
- const addr = Node.of(serial, .addr);
-
- const Case = struct { expr: []const u8, q0: u32, q1: u32 };
- for ([_]Case{
- .{ .expr = "#0", .q0 = 0, .q1 = 0 },
- .{ .expr = "#5", .q0 = 5, .q1 = 5 },
- .{ .expr = "0", .q0 = 0, .q1 = 0 },
- .{ .expr = "1", .q0 = 0, .q1 = 4 },
- .{ .expr = "2", .q0 = 4, .q1 = 8 },
- .{ .expr = "$", .q0 = 14, .q1 = 14 },
- .{ .expr = ",", .q0 = 0, .q1 = 14 },
- .{ .expr = "1,2", .q0 = 0, .q1 = 8 },
- .{ .expr = "#1,#4", .q0 = 1, .q1 = 4 },
- .{ .expr = "2+1", .q0 = 8, .q1 = 14 },
- .{ .expr = "$-1", .q0 = 8, .q1 = 14 },
- .{ .expr = "/two/", .q0 = 4, .q1 = 7 },
- .{ .expr = "/t.o/", .q0 = 4, .q1 = 7 },
- .{ .expr = "1\n", .q0 = 0, .q1 = 4 },
- }) |c| {
- _ = wr(p, addr, "#0");
- const w = wr(p, addr, c.expr);
- try testing.expectEqual(Status.ok, w.reply.status);
- const got = rd(p, addr, 0, 64);
- var want: [32]u8 = undefined;
- try testing.expectEqualStrings(
- try std.fmt.bufPrint(&want, "{d:>11} {d:>11} ", .{ c.q0, c.q1 }),
- got.bytes,
- );
- }
-
- _ = wr(p, addr, "1");
- _ = wr(p, addr, ".");
- try testing.expectEqual(@as(u32, 0), p.fs.panes[0].addr.q0);
- try testing.expectEqual(@as(u32, 4), p.fs.panes[0].addr.q1);
-
- _ = wr(p, addr, "$");
- _ = wr(p, addr, "?o?");
- try testing.expectEqual(@as(u32, 6), p.fs.panes[0].addr.q0); // the `o` in "two"
- try testing.expectEqual(@as(u32, 7), p.fs.panes[0].addr.q1);
-
- _ = wr(p, addr, "1");
- _ = wr(p, Node.of(serial, .ctl), "limit=addr\n");
- _ = wr(p, addr, "#0");
- try testing.expectEqual(E.INVAL, wr(p, addr, "/three/").errno());
- _ = wr(p, Node.of(serial, .ctl), "clean\n");
- _ = call(p, .{ .tag = 6, .op = .open, .node = Node.of(serial, .ctl) });
- try testing.expect(p.fs.panes[0].limit == null);
- _ = wr(p, addr, "#0");
- try testing.expectEqual(Status.ok, wr(p, addr, "/three/").reply.status);
-
- for ([_][]const u8{ "zzz", "#", "//", "/nomatch/", "1 2", "99", "/a\\" }) |bad| {
- _ = wr(p, addr, "#0");
- try testing.expectEqual(E.INVAL, wr(p, addr, bad).errno());
- }
-
- const nested = "," ** 4096;
- _ = wr(p, addr, "#0");
- try testing.expectEqual(E.INVAL, wr(p, addr, nested).errno());
-}
-
-test "data and xdata read from addr, move it, and write through it" {
- const gpa = testing.allocator;
- const p = try withFile(gpa, "one\ntwo\n");
- defer p.deinit();
- const serial = serialOf(p);
- const addr = Node.of(serial, .addr);
- const data = Node.of(serial, .data);
- const xdata = Node.of(serial, .xdata);
-
- _ = wr(p, addr, "#0");
- try testing.expectEqualStrings("one", rd(p, data, 0, 3).bytes);
- try testing.expectEqual(@as(u32, 3), p.fs.panes[0].addr.q0);
- try testing.expectEqual(@as(u32, 3), p.fs.panes[0].addr.q1);
-
- _ = wr(p, addr, "1");
- try testing.expectEqualStrings("one\n", rd(p, xdata, 0, 100).bytes);
- _ = wr(p, addr, "1");
- try testing.expectEqualStrings("one\ntwo\n", rd(p, data, 0, 100).bytes);
-
- _ = wr(p, addr, "1");
- const w = wr(p, data, "ONE\n");
- try testing.expectEqual(@as(u32, 4), w.reply.written);
- try testing.expectEqualStrings("ONE\ntwo\n", p.panes[0].?.file.?.content);
- try testing.expectEqual(@as(u32, 4), p.fs.panes[0].addr.q0);
-}
-
-test "data never splits a grapheme, in either direction" {
- const gpa = testing.allocator;
- const p = try withFile(gpa, "\u{00e9}x\n");
- defer p.deinit();
- const serial = serialOf(p);
- _ = wr(p, Node.of(serial, .addr), "#0");
- try testing.expectEqualStrings("", rd(p, Node.of(serial, .data), 0, 1).bytes);
- _ = wr(p, Node.of(serial, .addr), "#0");
- try testing.expectEqualStrings("\u{00e9}", rd(p, Node.of(serial, .data), 0, 2).bytes);
-
- _ = wr(p, Node.of(serial, .addr), "#0");
- try testing.expectEqual(@as(u32, 1), wr(p, Node.of(serial, .data), "a\xC3").reply.written);
-}
-
-test "rdsel reads the selection and wrsel replaces it" {
- const gpa = testing.allocator;
- const p = try withFile(gpa, "one\ntwo\n");
- defer p.deinit();
- const serial = serialOf(p);
- const ctl = Node.of(serial, .ctl);
-
- _ = wr(p, Node.of(serial, .addr), "#0,#3");
- try testing.expectEqual(Status.ok, wr(p, ctl, "dot=addr\n").reply.status);
- try testing.expectEqualStrings("one", rd(p, Node.of(serial, .rdsel), 0, 100).bytes);
-
- _ = wr(p, ctl, "addr=dot\n");
- try testing.expectEqual(@as(u32, 0), p.fs.panes[0].addr.q0);
- try testing.expectEqual(@as(u32, 3), p.fs.panes[0].addr.q1);
-
- try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .wrsel), "ONE").reply.status);
- try testing.expectEqualStrings("ONE\ntwo\n", p.panes[0].?.file.?.content);
- _ = wr(p, Node.of(serial, .wrsel), "!");
- try testing.expectEqualStrings("ONE!\ntwo\n", p.panes[0].?.file.?.content);
-}
-
-test "every ctl verb, and every refusal" {
- const gpa = testing.allocator;
- const p = try withFile(gpa, "one\ntwo\n");
- defer p.deinit();
- const serial = serialOf(p);
- const ctl = Node.of(serial, .ctl);
- const pane = p.panes[0].?;
- const pf = &p.fs.panes[0];
-
- try testing.expectEqual(Status.ok, wr(p, ctl, "nomark\nnoscroll\ndirty\n").reply.status);
- try testing.expect(pf.nomark and pf.noscroll and dirtyOf(pane));
- try testing.expectEqual(Status.ok, wr(p, ctl, "mark\nscroll\nclean\n").reply.status);
- try testing.expect(!pf.nomark and !pf.noscroll and !dirtyOf(pane));
-
- _ = wr(p, ctl, "cleartag\n");
- try testing.expectEqual(@as(usize, 0), pane.tag_tail_len);
-
- _ = wr(p, Node.of(serial, .addr), "2");
- _ = wr(p, ctl, "limit=addr\n");
- try testing.expectEqual(@as(u32, 4), pf.limit.?.q0);
- _ = wr(p, ctl, "dot=addr\nshow\n");
- try testing.expectEqual(@as(i32, 1), pane.cur_row);
-
- try testing.expectEqual(Status.ok, wr(p, ctl, "name /tmp/renamed.txt\n").reply.status);
- try testing.expectEqualStrings("/tmp/renamed.txt", pane.file.?.path);
- try testing.expectEqual(E.INVAL, wr(p, ctl, "name two words\n").errno());
- try testing.expectEqual(E.INVAL, wr(p, ctl, "name\n").errno());
- try testing.expectEqualStrings("/tmp/renamed.txt", pane.file.?.path);
-
- try testing.expectEqual(Status.ok, wr(p, ctl, "put\n").reply.status);
- try testing.expectEqualStrings(pane.file.?.content, p.fallback.get("/tmp/renamed.txt").?);
-
- for ([_][]const u8{
- "menu", "nomenu", "dump echo hi", "dumpdir /tmp", "font Go Mono", "lock", "unlock", "bogus", "DEL",
- }) |bad| try testing.expectEqual(E.INVAL, wr(p, ctl, bad).errno());
-
- try testing.expect(!dirtyOf(pane));
- try testing.expectEqual(E.INVAL, wr(p, ctl, "dirty\nbogus\n").errno());
- try testing.expect(!dirtyOf(pane));
-}
-
-test "ctl look opens spaced paths and locations without editing the source pane" {
- if (!platform_has_fs) return error.SkipZigTest;
- const gpa = testing.allocator;
- var tmp = testing.tmpDir(.{});
- defer tmp.cleanup();
- try tmp.dir.writeFile(testing.io, .{ .sub_path = "two words.zig", .data = "first\nsecond\nthird\n" });
- var path_buf: [4096]u8 = undefined;
- const path = path_buf[0..try tmp.dir.realPathFile(testing.io, "two words.zig", &path_buf)];
- const p = try withFile(gpa, "source stays intact\n");
- defer p.deinit();
- const source = p.panes[0].?;
- const ctl = Node.of(source.serial, .ctl);
- const revision = source.file.?.revision;
- const undo_len = source.file.?.history.undo_len;
- source.cur_col = 3;
- var command: [4200]u8 = undefined;
- try testing.expectEqual(Status.ok, wr(p, ctl, try std.fmt.bufPrint(&command, "look {s}:2:3\n", .{path})).reply.status);
- const opened = p.panes[p.active].?;
- try testing.expect(opened != source);
- try testing.expectEqualStrings(path, opened.file.?.path);
- try testing.expectEqualStrings("first\nsecond\nthird\n", opened.file.?.content);
- try testing.expectEqual(@as(i32, 1), opened.cur_row);
- try testing.expectEqual(@as(i32, 2), opened.cur_col);
- try testing.expectEqualStrings("source stays intact\n", source.file.?.content);
- try testing.expectEqual(revision, source.file.?.revision);
- try testing.expectEqual(undo_len, source.file.?.history.undo_len);
- try testing.expectEqual(@as(i32, 3), source.cur_col);
-
- try testing.expectEqual(Status.ok, wr(p, ctl, try std.fmt.bufPrint(&command, "look {s}:3:2\n", .{path})).reply.status);
- try testing.expect(p.panes[p.active].? == opened);
- try testing.expectEqual(@as(i32, 2), opened.cur_row);
- try testing.expectEqual(@as(i32, 1), opened.cur_col);
-}
-
-test "ctl look validates the whole batch before opening virtual files" {
- const gpa = testing.allocator;
- const p = try withFile(gpa, "source\n");
- defer p.deinit();
- const source = p.panes[0].?;
- const ctl = Node.of(source.serial, .ctl);
- const serial = p.next_serial;
- for ([_][]const u8{
- "look", "look \t", "look /virtual/index\x00ignored", "look /virtual/index\nbogus\n",
- }) |invalid| {
- try testing.expectEqual(E.INVAL, wr(p, ctl, invalid).errno());
- try testing.expectEqual(serial, p.next_serial);
- try testing.expectEqual(@as(usize, 0), p.active);
- try testing.expectEqualStrings("source\n", source.file.?.content);
- }
- try testing.expectEqual(Status.ok, wr(p, ctl, "look /n/self/index\n").reply.status);
- const opened = p.panes[p.active].?;
- try testing.expectEqualStrings("/virtual/index", opened.file.?.path);
- try testing.expect(std.mem.indexOf(u8, opened.file.?.content, "/test.txt") != null);
- try testing.expectEqualStrings("source\n", source.file.?.content);
-}
-
-test "ctl name promotes a scratch without changing its body or undo history" {
- const gpa = testing.allocator;
- const p = try withFile(gpa, "opener\n");
- defer p.deinit();
- _ = call(p, .{ .tag = 1, .op = .open, .node = factory_base + @intFromEnum(PaneFile.ctl) });
- const pane = p.panes[p.active].?;
- const ctl = Node.of(pane.serial, .ctl);
- const body = Node.of(pane.serial, .body);
- _ = wr(p, body, "scratch ");
- _ = wr(p, body, "work\n");
- const undo_len = pane.file.?.history.undo_len;
- var tmp = testing.tmpDir(.{});
- defer tmp.cleanup();
- try tmp.dir.writeFile(testing.io, .{ .sub_path = "renamed.zig", .data = "existing target\n" });
- var directory_buf: [4096]u8 = undefined;
- const directory = directory_buf[0..try tmp.dir.realPath(testing.io, &directory_buf)];
- var path_buffer: [4096]u8 = undefined;
- const path = try std.fmt.bufPrint(&path_buffer, "{s}/renamed.zig", .{directory});
- var command: [4104]u8 = undefined;
- const renamed = wr(p, ctl, try std.fmt.bufPrint(&command, "name {s}\n", .{path}));
- try testing.expectEqual(Status.ok, renamed.reply.status);
- try testing.expectEqual(false, renamed.watch.?);
- try testing.expectEqualStrings("scratch work\n", pane.file.?.content);
- try testing.expectEqual(undo_len, pane.file.?.history.undo_len);
- try testing.expect(pane.file.?.output == null and dirtyOf(pane));
- try testing.expect(pane.file.?.watch_after_save);
- const target = try readFile(gpa, path);
- defer gpa.free(target);
- try testing.expectEqualStrings("existing target\n", target);
- const saved = wr(p, ctl, "put\n");
- try testing.expectEqual(Status.ok, saved.reply.status);
- try testing.expectEqual(true, saved.watch.?);
- try testing.expect(!dirtyOf(pane) and !pane.file.?.watch_after_save);
- try testing.expectEqualStrings("scratch work\n", p.fallback.get(path).?);
-}
-
-test "ctl name refreshes cached syntax for unchanged contents" {
- if (!pardes.syntax.enabled) return error.SkipZigTest;
- pardes.syntax.start(testing.allocator);
- defer pardes.syntax.stop();
- const p = try withFile(testing.allocator, "fn check() void {}\n");
- defer p.deinit();
- const pane = p.panes[0].?;
- panes.File.refreshHighlights(p);
- try testing.expectEqual(@as(usize, 0), pane.file.?.highlights.len);
- try testing.expectEqual(Status.ok, wr(p, Node.of(pane.serial, .ctl), "name renamed.zig\n").reply.status);
- panes.File.refreshHighlights(p);
- try testing.expect(pane.file.?.highlights.len >= 2);
- try testing.expectEqual(@intFromEnum(pardes.syntax.Syn.keyword), pane.file.?.highlights[0]);
- try testing.expectEqual(@intFromEnum(pardes.syntax.Syn.keyword), pane.file.?.highlights[1]);
- try testing.expectEqualStrings("fn check() void {}\n", pane.file.?.content);
-}
-
-test "ctl relative names use the file directory and can name a new target" {
- const gpa = testing.allocator;
- const p = try withFile(gpa, "retained body\n");
- defer p.deinit();
- var tmp = testing.tmpDir(.{});
- defer tmp.cleanup();
- try tmp.dir.writeFile(testing.io, .{ .sub_path = "note.txt", .data = "from pane directory\n" });
- var directory_buf: [4096]u8 = undefined;
- const directory = directory_buf[0..try tmp.dir.realPath(testing.io, &directory_buf)];
- const pane = p.panes[0].?;
- const ctl = Node.of(pane.serial, .ctl);
- var command: [4140]u8 = undefined;
- try testing.expectEqual(Status.ok, wr(p, ctl, try std.fmt.bufPrint(&command, "name {s}/old.txt\n", .{directory})).reply.status);
- try testing.expectEqual(Status.ok, wr(p, ctl, "name child/../note.txt\nget\n").reply.status);
- try testing.expectEqualStrings("from pane directory\n", pane.file.?.content);
- try testing.expectEqualStrings(directory, Pardes.paneDir(pane));
- try testing.expectEqual(Status.ok, wr(p, ctl, "name ./created.txt\nput\n").reply.status);
- try testing.expect(!dirtyOf(pane));
- var expected_buf: [4096]u8 = undefined;
- const expected = try std.fmt.bufPrint(&expected_buf, "{s}/created.txt", .{directory});
- try testing.expectEqualStrings(expected, pane.file.?.path);
- try testing.expectEqualStrings(pane.file.?.content, p.fallback.get(expected).?);
-}
-
-test "ctl relative names follow inherited scratch and virtual directories" {
- const p = try withFile(testing.allocator, "source body\n");
- defer p.deinit();
- const source = p.panes[0].?;
- try testing.expectEqual(Status.ok, wr(p, Node.of(source.serial, .ctl), "name /project/src/source.zig\n").reply.status);
- _ = call(p, .{ .tag = 1, .op = .open, .node = factory_base + @intFromEnum(PaneFile.ctl) });
- const scratch = p.panes[p.active].?;
- const ctl = Node.of(scratch.serial, .ctl);
- try testing.expectEqualStrings("/project/src", Pardes.paneDir(scratch));
- try testing.expectEqual(Status.ok, wr(p, ctl, "name ../out/./notes.txt\n").reply.status);
- try testing.expectEqualStrings("/project/out/notes.txt", scratch.file.?.path);
- try testing.expectEqualStrings("/project/out", Pardes.paneDir(scratch));
- var command: [256]u8 = undefined;
- try testing.expectEqual(Status.ok, wr(p, ctl, try std.fmt.bufPrint(&command, "name /virtual/pane/{d}/./body\nget\n", .{source.serial})).reply.status);
- try testing.expectEqualStrings(source.file.?.content, scratch.file.?.content);
- const unchanged = wr(p, ctl, "name ./body\n");
- try testing.expectEqual(Status.ok, unchanged.reply.status);
- try testing.expect(unchanged.watch == null);
- try testing.expect(!dirtyOf(scratch));
- try testing.expect(!scratch.file.?.watch_after_save);
-}
-
-test "ctl get reloads the pane from disk and del honours a dirty body" {
- const gpa = testing.allocator;
- var tmp = testing.tmpDir(.{});
- defer tmp.cleanup();
- try tmp.dir.writeFile(testing.io, .{ .sub_path = "note.txt", .data = "from disk\n" });
- var directory_buf: [4096]u8 = undefined;
- const directory = directory_buf[0..try tmp.dir.realPath(testing.io, &directory_buf)];
- var path_buf: [4096]u8 = undefined;
- const path = try std.fmt.bufPrint(&path_buf, "{s}/note.txt", .{directory});
-
- const p = try withFile(gpa, "in memory\n");
- defer p.deinit();
- const serial = serialOf(p);
- const ctl = Node.of(serial, .ctl);
- const pane = p.panes[0].?;
-
- var name: [std.fs.max_path_bytes + 8]u8 = undefined;
- _ = wr(p, ctl, try std.fmt.bufPrint(&name, "name {s}\n", .{path}));
- try testing.expectEqual(Status.ok, wr(p, ctl, "get\n").reply.status);
- try testing.expectEqualStrings("from disk\n", pane.file.?.content);
- try testing.expect(!dirtyOf(pane));
- try testing.expect(pane.file.?.history.undo_len > 0);
-
- _ = wr(p, ctl, "dirty\n");
- try testing.expectEqual(E.INVAL, wr(p, ctl, "del\n").errno());
- try testing.expect(p.paneBySerial(serial) != null);
- _ = call(p, .{ .tag = 1, .op = .open, .node = factory_base + @intFromEnum(PaneFile.body) });
- try testing.expectEqual(Status.ok, wr(p, ctl, "delete\n").reply.status);
- try testing.expect(p.paneBySerial(serial) == null);
-}
-
-test "ctl get reports missing files without losing dirty contents or applying del" {
- const p = try withFile(testing.allocator, "unsaved contents\n");
- defer p.deinit();
- var tmp = testing.tmpDir(.{});
- defer tmp.cleanup();
- var directory_buf: [4096]u8 = undefined;
- const directory = directory_buf[0..try tmp.dir.realPath(testing.io, &directory_buf)];
- var name: [4140]u8 = undefined;
- const ctl = Node.of(serialOf(p), .ctl);
- try testing.expectEqual(Status.ok, wr(p, ctl, try std.fmt.bufPrint(&name, "name {s}/missing.txt\ndirty\n", .{directory})).reply.status);
- try testing.expectEqual(E.NOENT, wr(p, ctl, "get\ndel\n").errno());
- const pane = p.panes[0].?;
- try testing.expectEqualStrings("unsaved contents\n", pane.file.?.content);
- try testing.expect(dirtyOf(pane));
-}
-
-test "ctl put waits for one host write and stops before del when saving fails" {
- const Refusing = struct {
- p: *Pardes,
- calls: usize = 0,
-
- fn writeFile(ctx: ?*anyopaque, id: u8, _: []const u8, _: []const u8) void {
- const self: *@This() = @ptrCast(@alignCast(ctx.?));
- self.calls += 1;
- self.p.saveFailed(id, "save", error.PermissionDenied);
- }
- };
- const p = try withFile(testing.allocator, "retained contents\n");
- defer p.deinit();
- var refusing: Refusing = .{ .p = p };
- p.host = .{ .ctx = &refusing, .vtable = &.{ .write_file = Refusing.writeFile } };
- const serial = serialOf(p);
- const ctl = Node.of(serial, .ctl);
- for ([_][]const u8{ "clean\n", "dirty\n" }, 0..) |initial, i| {
- _ = wr(p, ctl, initial);
- const result = wr(p, ctl, "put\ndel\n");
- try testing.expectEqual(E.IO, result.errno());
- try testing.expect(!result.saved);
- try testing.expectEqual(i + 1, refusing.calls);
- const pane = p.panes[p.paneBySerial(serial).?].?;
- try testing.expect(dirtyOf(pane));
- try testing.expectEqualStrings("retained contents\n", pane.file.?.content);
- }
-}
-
-test "self directories walk to their namespace parents" {
- const p = try withFile(testing.allocator, "contents\n");
- defer p.deinit();
- const root = @intFromEnum(SelfFile.root);
- for ([_]struct { node: u64, parent: u64 }{
- .{ .node = root, .parent = namespace_root },
- .{ .node = @intFromEnum(SelfFile.new), .parent = root },
- .{ .node = namespace_panes, .parent = root },
- .{ .node = Node.of(serialOf(p), .dir), .parent = namespace_panes },
- }) |case| {
- const result = look_up(p, case.node, "..");
- try testing.expectEqual(Status.ok, result.reply.status);
- try testing.expectEqual(case.parent, result.reply.attr.node);
- }
-}
-
-test "errors and cons append to one +Errors buffer per directory" {
- const gpa = testing.allocator;
- const p = try withFile(gpa, "x\n");
- defer p.deinit();
- const serial = serialOf(p);
-
- const live = for (p.panes) |slot| {
- if (slot) |q| if (q.file) |f| if (f.output) |o| if (std.meta.activeTag(o.from) == .errors) break q;
- } else null;
- try testing.expect(live == null);
-
- try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .errors), "boom\n").reply.status);
- _ = wr(p, @intFromEnum(SelfFile.cons), "again\n");
-
- var found: usize = 0;
- for (p.panes) |slot| {
- const q = slot orelse continue;
- const f = q.file orelse continue;
- const o = f.output orelse continue;
- if (std.meta.activeTag(o.from) != .errors) continue;
- found += 1;
- try testing.expectEqualStrings("boom\nagain\n", f.content);
- try testing.expectEqualStrings("/+Errors", f.path);
- }
- try testing.expectEqual(@as(usize, 1), found);
-}
-
-test "setattr truncation empties the body and answers fresh attributes" {
- const gpa = testing.allocator;
- const p = try withFile(gpa, "one\ntwo\n");
- defer p.deinit();
- const serial = serialOf(p);
-
- const a = call(p, .{ .tag = 7, .op = .setattr, .node = Node.of(serial, .body), .truncate = true });
- try testing.expectEqual(Status.ok, a.reply.status);
- try testing.expectEqual(@as(u64, 0), a.reply.attr.size);
- try testing.expectEqualStrings("", p.panes[0].?.file.?.content);
-
- _ = wr(p, Node.of(serial, .body), "new text\n");
- try testing.expectEqualStrings("new text\n", p.panes[0].?.file.?.content);
-
- const noop = call(p, .{ .tag = 8, .op = .setattr, .node = Node.of(serial, .body) });
- try testing.expectEqual(@as(u64, 9), noop.reply.attr.size);
-}
-
-test "event records are acme's bytes, one per read, and .again when empty" {
- const gpa = testing.allocator;
- const p = try withFile(gpa, "Msg fs-ran\n");
- defer p.deinit();
- const serial = serialOf(p);
- const event = Node.of(serial, .event);
-
- _ = noteAction(p, 0, .body_exec, 1, 4, flag_builtin, "sg ");
- try testing.expect(p.fs.panes[0].events.empty());
-
- const h = call(p, .{ .tag = 10, .op = .open, .node = event });
- try testing.expect(h.reply.handle != 0);
- try testing.expectEqual(@as(u16, 1), p.fs.listeners);
-
- try testing.expectEqual(Status.again, rd(p, event, 0, 4096).reply.status);
-
- p.fs.origin = 'M';
- _ = noteAction(p, 0, .body_exec, 1, 4, flag_builtin, "ell");
- _ = noteAction(p, 0, .body_delete, 0, 3, 0, "");
- try testing.expectEqualStrings("MX1 4 1 3 ell\n", rd(p, event, 0, 4096).bytes);
- try testing.expectEqualStrings("MD0 3 0 0 \n", rd(p, event, 0, 4096).bytes);
- try testing.expectEqual(Status.again, rd(p, event, 0, 4096).reply.status);
-
- _ = noteAction(p, 0, .body_look, 0, 3, flag_filename, "one");
- try testing.expectEqual(E.INVAL, rd(p, event, 0, 4).errno());
- try testing.expectEqualStrings("ML0 3 4 3 one\n", rd(p, event, 0, 4096).bytes);
-
- const big = "z" ** max_record_text;
- _ = noteAction(p, 0, .body_exec, 0, max_record_text, 0, big);
- try testing.expectEqualStrings("MX0 256 0 0 \n", rd(p, event, 0, 4096).bytes);
-
- _ = call(p, .{ .tag = 11, .op = .release, .node = event, .handle = h.reply.handle });
- try testing.expectEqual(@as(u16, 0), p.fs.listeners);
-}
-
-fn drainEvents(p: *Pardes, node: u64, store: []u8, out: [][]const u8) [][]const u8 {
- var used: usize = 0;
- var n: usize = 0;
- while (n < out.len) {
- const a = rd(p, node, 0, 4096);
- if (a.reply.status != .ok) break;
- @memcpy(store[used..][0..a.bytes.len], a.bytes);
- out[n] = store[used..][0..a.bytes.len];
- used += a.bytes.len;
- n += 1;
- }
- return out[0..n];
-}
-
-test "a write through the filesystem is reported once, attributed to the file it came through" {
- const gpa = testing.allocator;
- const p = try withFile(gpa, "one\ntwo\n");
- defer p.deinit();
- const serial = serialOf(p);
- const event = Node.of(serial, .event);
- _ = call(p, .{ .tag = 40, .op = .open, .node = event });
- var store: [4096]u8 = undefined;
- var slots: [16][]const u8 = undefined;
- _ = drainEvents(p, event, &store, &slots);
-
- _ = wr(p, Node.of(serial, .body), "three\n");
- const body_recs = drainEvents(p, event, &store, &slots);
- try testing.expect(body_recs.len >= 1);
- try testing.expectEqualStrings("EI8 14 0 6 three\n\n", body_recs[0]);
- for (body_recs[1..]) |r| {
- try testing.expectEqual(@as(u8, 'E'), r[0]);
- try testing.expect(Action.fromChar(r[1]).?.onTag());
- }
-
- _ = wr(p, Node.of(serial, .addr), "1");
- _ = wr(p, Node.of(serial, .data), "ONE\n");
- const data_recs = drainEvents(p, event, &store, &slots);
- try testing.expectEqual(@as(usize, 2), data_recs.len);
- try testing.expectEqualStrings("FD0 3 0 0 \n", data_recs[0]);
- try testing.expectEqualStrings("FI0 3 0 3 ONE\n", data_recs[1]);
- try testing.expectEqual(Status.again, rd(p, event, 0, 4096).reply.status);
-}
-
-test "two event readers each count once, and the second closing leaves the first" {
- const gpa = testing.allocator;
- const p = try withFile(gpa, "x\n");
- defer p.deinit();
- const event = Node.of(serialOf(p), .event);
-
- _ = call(p, .{ .tag = 12, .op = .open, .node = event });
- _ = call(p, .{ .tag = 13, .op = .open, .node = event });
- try testing.expectEqual(@as(u16, 2), p.fs.panes[0].readers);
- try testing.expectEqual(@as(u16, 2), p.fs.listeners);
-
- _ = call(p, .{ .tag = 14, .op = .release, .node = event });
- try testing.expectEqual(@as(u16, 1), p.fs.panes[0].readers);
- try testing.expect(p.fs.scripted(0));
-
- _ = call(p, .{ .tag = 15, .op = .release, .node = Node.of(serialOf(p), .body) });
- try testing.expectEqual(@as(u16, 1), p.fs.listeners);
-
- _ = call(p, .{ .tag = 16, .op = .release, .node = event });
- try testing.expectEqual(@as(u16, 0), p.fs.listeners);
- try testing.expect(!p.fs.scripted(0));
- _ = call(p, .{ .tag = 17, .op = .release, .node = event });
- try testing.expectEqual(@as(u16, 0), p.fs.listeners);
-}
-
-test "a pane deleted while its event file is open leaves no suppression behind" {
- const gpa = testing.allocator;
- const p = try withFile(gpa, "x\n");
- defer p.deinit();
- const serial = serialOf(p);
- const event = Node.of(serial, .event);
- _ = call(p, .{ .tag = 1, .op = .open, .node = factory_base + @intFromEnum(PaneFile.body) });
-
- const a = call(p, .{ .tag = 18, .op = .open, .node = event });
- const b = call(p, .{ .tag = 19, .op = .open, .node = event });
- try testing.expectEqual(@as(u16, 2), p.fs.listeners);
-
- _ = wr(p, Node.of(serial, .ctl), "delete\n");
- try testing.expect(p.paneBySerial(serial) == null);
- try testing.expectEqual(@as(u16, 0), p.fs.listeners);
-
- _ = call(p, .{ .tag = 20, .op = .release, .node = event, .handle = a.reply.handle });
- _ = call(p, .{ .tag = 21, .op = .release, .node = event, .handle = b.reply.handle });
- try testing.expectEqual(@as(u16, 0), p.fs.listeners);
-
- try testing.expectEqual(E.NOENT, rd(p, event, 0, 64).errno());
- try testing.expectEqual(E.NOENT, rd(p, Node.of(serial, .body), 0, 64).errno());
- try testing.expectEqual(E.NOENT, wr(p, Node.of(serial, .ctl), "clean\n").errno());
- try testing.expectEqual(E.NOENT, call(p, .{ .tag = 22, .op = .open, .node = event }).errno());
-}
-
-test "writing an event record back performs the action it names" {
- const gpa = testing.allocator;
- const p = try withFile(gpa, "Msg fs-ran\n");
- defer p.deinit();
- const serial = serialOf(p);
- const event = Node.of(serial, .event);
- const pane = p.panes[0].?;
-
- const w = wr(p, event, "FX0 10\n");
- try testing.expectEqual(Status.ok, w.reply.status);
- try testing.expectEqualStrings("fs-ran", pane.msg[0..pane.msg_len]);
-
- pane.msg_len = 0;
- try testing.expectEqual(Status.ok, wr(p, event, "FX0 10\nFX0 10\n").reply.status);
- try testing.expectEqualStrings("fs-ran", pane.msg[0..pane.msg_len]);
-
- pane.msg_len = 0;
- for ([_][]const u8{
- "FX0 10\nFQ0 1\n", // unknown type character
- "FX0 999\n", // out of range
- "FX0 10", // no newline
- "FX5 1\n", // q0 > q1
- "FD0 3\n", // a report, not a request
- "F\n",
- }) |bad| {
- try testing.expectEqual(E.INVAL, wr(p, event, bad).errno());
- try testing.expectEqual(@as(usize, 0), pane.msg_len);
- }
-
- _ = call(p, .{ .tag = 23, .op = .open, .node = event });
- p.fs.origin = 'K';
- _ = wr(p, event, "KX0 10\n");
- try testing.expectEqual(@as(u8, 'F'), p.fs.origin);
-}
-
-test "a pane that is not a terminal has no pty/ at all" {
- const gpa = testing.allocator;
- const p = try withFile(gpa, "hello\n");
- defer p.deinit();
- const serial = serialOf(p);
- const dir = Node.of(serial, .dir);
-
- try testing.expectEqual(E.NOENT, look_up(p, dir, "pty").errno());
- try testing.expectEqual(E.NOENT, call(p, .{
- .tag = 1,
- .op = .getattr,
- .node = Node.of(serial, .pty),
- }).errno());
- try testing.expectEqual(E.NOENT, rd(p, Node.of(serial, .pty_status), 0, 256).errno());
- try testing.expectEqual(E.NOENT, wr(p, Node.of(serial, .pty_ctl), "winsize 80 24\n").errno());
- try testing.expectEqual(E.NOENT, rdir(p, Node.of(serial, .pty), 0).errno());
- try testing.expectEqual(E.NOENT, call(p, .{
- .tag = 2,
- .op = .open,
- .node = Node.of(serial, .pty_data),
- }).errno());
- try testing.expectEqual(@as(u16, 0), p.fs.panes[0].pty_readers);
-
- var buf: [32]Dirent = undefined;
- const files = dirents(rdir(p, dir, 0).bytes, &buf);
- try testing.expectEqual(@as(usize, 10), files.len);
- try testing.expect(nameAt(files, "pty") == null);
-
- try testing.expectEqual(E.NOENT, look_up(p, dir, "pty_ctl").errno());
- try testing.expectEqual(E.NOENT, look_up(p, dir, "status").errno());
-
- const before = p.next_serial;
- try testing.expectEqual(E.NOENT, look_up(p, @intFromEnum(SelfFile.new), "pty").errno());
- try testing.expectEqual(before, p.next_serial);
-}
-
-test "a terminal pane's pty/ holds exactly ctl, status and data" {
- const gpa = testing.allocator;
- const p = try withTerm(gpa);
- defer p.deinit();
- const serial = serialOf(p);
- const dir = Node.of(serial, .dir);
-
- const pty = look_up(p, dir, "pty");
- try testing.expectEqual(Node.of(serial, .pty), pty.reply.attr.node);
- try testing.expect(pty.reply.attr.dir);
- try testing.expectEqual(@as(u16, 0o500), pty.reply.attr.mode);
-
- var buf: [32]Dirent = undefined;
- const files = dirents(rdir(p, dir, 0).bytes, &buf);
- try testing.expectEqual(@as(usize, 11), files.len);
- try testing.expect(nameAt(files, "pty").?.dir);
-
- const inside = dirents(rdir(p, Node.of(serial, .pty), 0).bytes, &buf);
- try testing.expectEqual(@as(usize, 3), inside.len);
- try testing.expectEqualStrings("ctl", inside[0].name);
- try testing.expectEqualStrings("status", inside[1].name);
- try testing.expectEqualStrings("data", inside[2].name);
- for (inside) |d| try testing.expect(!d.dir);
- try testing.expectEqual(Node.of(serial, .pty_data), inside[2].node);
-
- const ctl = look_up(p, Node.of(serial, .pty), "ctl");
- try testing.expectEqual(Node.of(serial, .pty_ctl), ctl.reply.attr.node);
- try testing.expectEqual(@as(u16, 0o200), ctl.reply.attr.mode);
- try testing.expectEqual(@as(u16, 0o400), look_up(p, Node.of(serial, .pty), "status").reply.attr.mode);
- try testing.expectEqual(E.NOENT, look_up(p, Node.of(serial, .pty), "body").errno());
- try testing.expectEqual(E.NOENT, look_up(p, Node.of(serial, .pty), "pty").errno());
-
- try testing.expectEqual(E.NOTDIR, look_up(p, Node.of(serial, .pty_ctl), "x").errno());
- try testing.expectEqual(E.NOTDIR, rdir(p, Node.of(serial, .pty_ctl), 0).errno());
- try testing.expectEqual(E.PERM, rd(p, Node.of(serial, .pty), 0, 16).errno());
- try testing.expectEqual(E.PERM, rd(p, Node.of(serial, .pty_ctl), 0, 16).errno());
- try testing.expectEqual(E.PERM, wr(p, Node.of(serial, .pty_status), "x").errno());
-}
-
-test "every pty/ctl verb, and every refusal" {
- const gpa = testing.allocator;
- const p = try withTerm(gpa);
- defer p.deinit();
- const ctl = Node.of(serialOf(p), .pty_ctl);
-
- const pane = p.panes[0].?;
- const cols = pane.cols;
- const rows = pane.rows;
- const ws = wr(p, ctl, "winsize 132 44\n");
- try testing.expectEqual(@as(u32, "winsize 132 44\n".len), ws.reply.written);
- try testing.expectEqual(@as(u16, 132), ws.winsize.?.cols);
- try testing.expectEqual(@as(u16, 44), ws.winsize.?.rows);
- try testing.expectEqual(cols, pane.cols);
- try testing.expectEqual(rows, pane.rows);
-
- for ([_]struct { line: []const u8, want: pardes.PtySignal }{
- .{ .line = "sig INT", .want = .int },
- .{ .line = "sig TERM", .want = .term },
- .{ .line = "sig HUP", .want = .hup },
- .{ .line = "sig QUIT", .want = .quit },
- .{ .line = "sig KILL", .want = .kill },
- }) |c| {
- const a = wr(p, ctl, c.line);
- try testing.expectEqual(Status.ok, a.reply.status);
- try testing.expectEqual(c.want, a.signal.?);
- }
-
- const ex = wr(p, ctl, "exec\n");
- try testing.expectEqual(Status.ok, ex.reply.status);
- try testing.expect(ex.spawned);
-
- const both = wr(p, ctl, "winsize 100 30\nsig TERM");
- try testing.expectEqual(@as(u16, 100), both.winsize.?.cols);
- try testing.expectEqual(pardes.PtySignal.term, both.signal.?);
-
- for ([_][]const u8{
- "winsize", // no arguments
- "winsize 80", // one argument
- "winsize 80 24 extra", // three
- "winsize 0 24", // zero is "unknown", never a width
- "winsize 80 0",
- "winsize -1 24", // not a decimal
- "winsize 999999 24", // wider than a u16
- "sig", // no name
- "sig INT TERM", // two
- "sig SIGINT",
- "sig int", // lower case
- "sig 9", // a number is one platform's number
- "sig USR1", // a real signal, deliberately not offered
- "exec /bin/sh", // the effect carries no argv; refused, never ignored
- "raw",
- "cooked",
- "winsize 80 24\nbogus", // a good verb beside a bad one
- "bogus\nwinsize 80 24",
- "name x", // a `ctl` verb; the two files share no vocabulary
- "del",
- }) |bad| {
- const a = wr(p, ctl, bad);
- try testing.expectEqual(E.INVAL, a.errno());
- try testing.expect(a.winsize == null);
- try testing.expect(a.signal == null);
- try testing.expect(!a.spawned);
- }
-
- const spaced = wr(p, ctl, "\n winsize 90 20 \n\n");
- try testing.expectEqual(Status.ok, spaced.reply.status);
- try testing.expectEqual(@as(u16, 90), spaced.winsize.?.cols);
- try testing.expectEqual(Status.ok, wr(p, ctl, "").reply.status);
-}
-
-const FakeTty = struct {
- taken: bool,
-
- const vtable: pardes.Host.VTable = .{ .tty_taken = answer };
-
- fn answer(ctx: ?*anyopaque, pane: u8) bool {
- _ = pane;
- const f: *FakeTty = @ptrCast(@alignCast(ctx.?));
- return f.taken;
- }
-};
-
-test "owned cwd pty exec rejects long paths before applying its batch" {
- const p = try withTerm(testing.allocator);
- defer p.deinit();
- const pane = p.panes[0].?;
- const ctl = Node.of(pane.serial, .pty_ctl);
- var path: [1025]u8 = @splat('d');
- path[0] = '/';
- p.setCwd(0, &path);
- for ([_][]const u8{ "exec\n", "winsize 100 30\nexec\n", "sig TERM\nexec\n" }) |command| {
- const result = wr(p, ctl, command);
- try testing.expectEqual(E.INVAL, result.errno());
- try testing.expect(!result.spawned);
- try testing.expect(result.signal == null and result.winsize == null);
- }
- try testing.expectEqualStrings(&path, pane.cwdSlice());
- p.setCwd(0, path[0..pardes.effect_path_cap]);
- const accepted = wr(p, ctl, "exec\n");
- try testing.expectEqual(Status.ok, accepted.reply.status);
- try testing.expect(accepted.spawned);
-}
-
-test "owned cwd name promotion releases the former directory" {
- const p = try withTerm(testing.allocator);
- defer p.deinit();
- p.newScratchBelow(0);
- const id = p.active;
- const pane = p.panes[id].?;
- try pane.setOwnedCwd("/old/directory");
- const result = wr(p, Node.of(pane.serial, .ctl), "name saved.txt\n");
- try testing.expectEqual(Status.ok, result.reply.status);
- try testing.expect(pane.cwd == .none);
- try testing.expect(pane.file.?.output == null);
- try testing.expectEqualStrings("/old/directory/saved.txt", pane.file.?.path);
- try testing.expectEqualStrings("/old/directory", Pardes.paneDir(pane));
-}
-
-test "pty/status reports the grid and who holds the tty" {
- const gpa = testing.allocator;
- const p = try withTerm(gpa);
- defer p.deinit();
- const pane = p.panes[0].?;
- const status = Node.of(pane.serial, .pty_status);
-
- const a = rd(p, status, 0, 256);
- try testing.expectEqual(Status.ok, a.reply.status);
- var want: [64]u8 = undefined;
- const whole = try std.fmt.bufPrint(&want, "{d:>11} {d:>11} {d:>11} ", .{ pane.cols, pane.rows, 0 });
- try testing.expectEqualStrings(whole, a.bytes);
- try testing.expectEqual(@as(usize, 3 * 12), a.bytes.len);
- try testing.expectEqualStrings(whole[12..], rd(p, status, 12, 256).bytes);
-
- var probe: FakeTty = .{ .taken = true };
- p.host = .{ .ctx = &probe, .vtable = &FakeTty.vtable };
- const held = rd(p, status, 0, 256);
- try testing.expectEqualStrings(
- try std.fmt.bufPrint(&want, "{d:>11} {d:>11} {d:>11} ", .{ pane.cols, pane.rows, 1 }),
- held.bytes,
- );
-}
-
-test "pty/data writes at the shell and reads the raw stream" {
- const gpa = testing.allocator;
- const p = try withTerm(gpa);
- defer p.deinit();
- const serial = serialOf(p);
- const data = Node.of(serial, .pty_data);
-
- const w = call(p, .{ .tag = 2, .op = .write, .node = data, .off = 999, .data = "ls -l\r" });
- try testing.expectEqual(@as(u32, 6), w.reply.written);
- try testing.expectEqualStrings("ls -l\r", w.pty());
- try testing.expectEqual(@as(u32, 1), wr(p, data, "a\xC3").reply.written);
- try testing.expectEqual(@as(u32, 0), wr(p, data, "").reply.written);
-
- try testing.expectEqual(Status.again, rd(p, data, 0, 64).reply.status);
-
- p.update(.{ .output = .{ .pane = 0, .bytes = "unwatched" } });
- while (p.nextEffect()) |_| {}
- try testing.expectEqual(@as(usize, 0), p.fs.panes[0].pty_out.buf.items.len);
- try testing.expectEqual(Status.again, rd(p, data, 0, 64).reply.status);
-
- _ = call(p, .{ .tag = 5, .op = .open, .node = data });
- try testing.expectEqual(@as(u16, 1), p.fs.panes[0].pty_readers);
- try testing.expectEqual(@as(u16, 0), p.fs.listeners);
- try testing.expect(!p.fs.scripted(0));
-
- p.update(.{ .output = .{ .pane = 0, .bytes = "hello" } });
- while (p.nextEffect()) |_| {}
- try testing.expectEqualStrings("hello", rd(p, data, 0, 64).bytes);
- try testing.expectEqual(Status.again, rd(p, data, 0, 64).reply.status);
-
- p.update(.{ .output = .{ .pane = 0, .bytes = "abcdef" } });
- while (p.nextEffect()) |_| {}
- try testing.expectEqualStrings("ab", rd(p, data, 0, 2).bytes);
- try testing.expectEqualStrings("cd", rd(p, data, 0, 2).bytes);
- p.update(.{ .output = .{ .pane = 0, .bytes = "ghi" } });
- while (p.nextEffect()) |_| {}
- try testing.expectEqualStrings("efghi", rd(p, data, 0, 64).bytes);
-
- p.update(.{ .output = .{ .pane = 0, .bytes = "orphan" } });
- while (p.nextEffect()) |_| {}
- _ = call(p, .{ .tag = 6, .op = .release, .node = data });
- try testing.expectEqual(@as(u16, 0), p.fs.panes[0].pty_readers);
- try testing.expectEqual(@as(usize, 0), p.fs.panes[0].pty_out.buf.capacity);
- try testing.expectEqual(Status.again, rd(p, data, 0, 64).reply.status);
-
- _ = call(p, .{ .tag = 7, .op = .open, .node = data });
- _ = call(p, .{ .tag = 8, .op = .open, .node = data });
- _ = call(p, .{ .tag = 9, .op = .release, .node = data });
- try testing.expectEqual(@as(u16, 1), p.fs.panes[0].pty_readers);
- p.update(.{ .output = .{ .pane = 0, .bytes = "still" } });
- while (p.nextEffect()) |_| {}
- try testing.expectEqualStrings("still", rd(p, data, 0, 64).bytes);
-}
-
-test "the pty queue drops the oldest at its cap" {
- const gpa = testing.allocator;
- const p = try withTerm(gpa);
- defer p.deinit();
- const data = Node.of(serialOf(p), .pty_data);
- _ = call(p, .{ .tag = 5, .op = .open, .node = data });
-
- const oldest: [4096]u8 = @splat('A');
- const rest: [4096]u8 = @splat('B');
- notePtyOutput(p, 0, &oldest);
- for (0..queue_cap / rest.len + 4) |_| notePtyOutput(p, 0, &rest);
- const q = &p.fs.panes[0].pty_out;
- try testing.expect(q.buf.items.len - q.head <= queue_cap);
-
- var seen: usize = 0;
- while (true) {
- const a = rd(p, data, 0, 1 << 16);
- if (a.reply.status == .again) break;
- try testing.expect(std.mem.indexOfScalar(u8, a.bytes, 'A') == null);
- if (a.bytes.len == 0) break;
- seen += a.bytes.len;
- }
- try testing.expect(seen > 0 and seen <= queue_cap);
+test {
+ _ = tree;
}
diff --git a/src/main.zig b/src/main.zig
index 40ec8667..4b1910bd 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -281,10 +281,10 @@ fn nativeMain(init: std.process.Init) !void {
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 ctl = try std.fmt.bufPrint(&ctl_buf, "/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 tag = try std.fmt.bufPrint(&tag_buf, "/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);
diff --git a/src/memory.zig b/src/memory.zig
index 60ec1bb6..f30e4392 100644
--- a/src/memory.zig
+++ b/src/memory.zig
@@ -19,7 +19,8 @@ pub const limits = struct {
pub const message_log = if (board) 16 else 128;
pub const max_tag_tail: usize = if (board) 512 else 4096;
pub const host_path_cap: usize = if (board) 0 else 4095;
- pub const embedded_sources = !board;
+ /// -Dembed-sources: the editor serves its own sources under /src.
+ pub const embedded_sources = config.embed_sources;
pub const hexdump_row_bytes: u32 = if (board) 8 else 16;
pub const arena = struct {
@@ -138,7 +139,7 @@ test "memory limits preserve desktop capacities" {
try std.testing.expectEqual(128, limits.message_log);
try std.testing.expectEqual(@as(usize, 4096), limits.max_tag_tail);
try std.testing.expectEqual(@as(usize, 4095), limits.host_path_cap);
- try std.testing.expect(limits.embedded_sources);
+ try std.testing.expectEqual(config.embed_sources, limits.embedded_sources);
try std.testing.expectEqual(@as(u32, 16), limits.hexdump_row_bytes);
try std.testing.expectEqual(32 * MiB, limits.arena.pardes);
try std.testing.expectEqual(16 * MiB, limits.arena.frame);
diff --git a/src/ninep/addr.zig b/src/ninep/addr.zig
new file mode 100644
index 00000000..f7642b9e
--- /dev/null
+++ b/src/ninep/addr.zig
@@ -0,0 +1,276 @@
+//! The address language of a pane's `addr` file: acme's, with mvzr regexps.
+const std = @import("std");
+const mvzr = @import("mvzr");
+const modal = @import("../modal.zig");
+const pane_files = @import("pane.zig");
+
+pub const Range = pane_files.State.Range;
+
+fn clip(n: usize) u32 {
+ return std.math.cast(u32, n) orelse std.math.maxInt(u32);
+}
+
+fn safePattern(pat: []const u8) bool {
+ var i: usize = 0;
+ while (i < pat.len) : (i += 1) {
+ if (pat[i] != '\\') continue;
+ if (i + 1 >= pat.len) return false;
+ i += 1;
+ }
+ return true;
+}
+
+pub const Addr = struct {
+ text: []const u8,
+ lim: ?Range,
+ expr: []const u8,
+ i: usize = 0,
+ depth: u8 = 0,
+
+ const max_depth = 32;
+ const Size = enum { char, line };
+
+ pub fn address(a: *Addr, ar_in: Range) ?Range {
+ const start = a.i;
+ var ar = ar_in;
+ var r = ar_in;
+ var dir: u8 = 0;
+ var size: Size = .line;
+ var c: u8 = 0;
+ while (a.i < a.expr.len) {
+ const prevc = c;
+ c = a.expr[a.i];
+ a.i += 1;
+ switch (c) {
+ ',', ';' => {
+ if (c == ';') ar = r;
+ if (prevc == 0) r.q0 = 0; // lhs defaults to 0
+ if (a.i >= a.expr.len) {
+ r.q1 = clip(a.text.len); // rhs defaults to $
+ } else {
+ if (a.depth >= max_depth) return null;
+ a.depth += 1;
+ const nr = a.address(ar) orelse return null;
+ a.depth -= 1;
+ r.q1 = nr.q1;
+ }
+ return r;
+ },
+ '+', '-' => {
+ if (prevc == '+' or prevc == '-') {
+ const nc = if (a.i < a.expr.len) a.expr[a.i] else 0;
+ if (nc != '#' and nc != '/' and nc != '?')
+ r = a.number(r, 1, prevc, .line) orelse return null;
+ }
+ dir = c;
+ },
+ '.', '$' => {
+ if (a.i != start + 1) {
+ a.i -= 1;
+ return r;
+ }
+ r = if (c == '.') ar else .{ .q0 = clip(a.text.len), .q1 = clip(a.text.len) };
+ dir = if (a.i < a.expr.len) '+' else 0;
+ },
+ '#', '0'...'9' => {
+ var digit = c;
+ if (c == '#') {
+ if (a.i >= a.expr.len or a.expr[a.i] < '0' or a.expr[a.i] > '9') {
+ a.i -= 1;
+ return r;
+ }
+ digit = a.expr[a.i];
+ a.i += 1;
+ size = .char;
+ }
+ var n: u64 = digit - '0';
+ while (a.i < a.expr.len) : (a.i += 1) {
+ const d = a.expr[a.i];
+ if (d < '0' or d > '9') break;
+ n = @min(n * 10 + (d - '0'), std.math.maxInt(u32));
+ }
+ r = a.number(r, @intCast(n), dir, size) orelse return null;
+ dir = 0;
+ size = .line;
+ },
+ '/', '?' => {
+ const back = c == '?';
+ r = a.regexp(r, a.pattern(c), back) orelse return null;
+ dir = 0;
+ size = .line;
+ },
+ else => {
+ a.i -= 1;
+ return r;
+ },
+ }
+ }
+ if (dir != 0) r = a.number(r, 1, dir, .line) orelse return null;
+ return r;
+ }
+
+ fn pattern(a: *Addr, delim: u8) []const u8 {
+ const s = a.i;
+ while (a.i < a.expr.len) {
+ const c = a.expr[a.i];
+ if (c == '\n') break;
+ a.i += 1;
+ if (c == '\\') {
+ if (a.i < a.expr.len) a.i += 1;
+ continue;
+ }
+ if (c == delim) return a.expr[s .. a.i - 1];
+ }
+ return a.expr[s..a.i];
+ }
+
+ fn number(a: *Addr, r_in: Range, n: u32, dir: u8, size: Size) ?Range {
+ var r = r_in;
+ if (size == .char) {
+ var off: i64 = n;
+ if (dir == '+') {
+ off = @as(i64, r.q1) + n;
+ } else if (dir == '-') {
+ if (r.q0 == 0 and n > 0) r.q0 = clip(a.text.len);
+ off = @as(i64, r.q0) - n;
+ }
+ if (off < 0 or off > @as(i64, @intCast(a.text.len))) return null;
+ const g = clip(modal.graphemeStart(a.text, @intCast(off)));
+ return .{ .q0 = g, .q1 = g };
+ }
+ var line: i64 = n;
+ var q0: usize = r.q0;
+ var q1: usize = r.q1;
+ switch (dir) {
+ '-' => {
+ if (q0 < a.text.len) while (q0 > 0 and a.text[q0 - 1] != '\n') {
+ q0 -= 1;
+ };
+ q1 = q0;
+ while (line > 0 and q0 > 0) {
+ if (a.text[q0 - 1] == '\n') {
+ line -= 1;
+ q1 = q0;
+ }
+ q0 -= 1;
+ }
+ if (line > 1) return null;
+ while (q0 > 0 and a.text[q0 - 1] != '\n') q0 -= 1;
+ return .{ .q0 = clip(q0), .q1 = clip(q1) };
+ },
+ '+' => {
+ if (q1 > 0) while (q1 < a.text.len and a.text[q1 - 1] != '\n') {
+ q1 += 1;
+ };
+ q0 = q1;
+ },
+ else => {
+ q0 = 0;
+ q1 = 0;
+ },
+ }
+ while (line > 0 and q1 < a.text.len) {
+ const ch = a.text[q1];
+ q1 += 1;
+ if (ch == '\n' or q1 == a.text.len) {
+ line -= 1;
+ if (line > 0) q0 = q1;
+ }
+ }
+ if (line > 0) return null;
+ return .{ .q0 = clip(q0), .q1 = clip(q1) };
+ }
+
+ fn regexp(a: *Addr, r: Range, pat: []const u8, back: bool) ?Range {
+ if (pat.len == 0 or !safePattern(pat)) return null;
+ const re = mvzr.compile(pat) orelse return null;
+ if (back) {
+ const hi = @min(@as(usize, r.q0), a.text.len);
+ var best: ?mvzr.Match = null;
+ var at: usize = 0;
+ while (at < hi) {
+ const m = re.matchPos(at, a.text[0..hi]) orelse break;
+ best = m;
+ at = if (m.end > m.start) m.end else m.end + 1;
+ }
+ const m = best orelse return null;
+ return .{ .q0 = clip(m.start), .q1 = clip(m.end) };
+ }
+ const hi = if (a.lim) |l| @min(@as(usize, l.q1), a.text.len) else a.text.len;
+ const from = @min(@as(usize, r.q1), hi);
+ const m = re.match(a.text[from..hi]) orelse return null;
+ return .{ .q0 = clip(from + m.start), .q1 = clip(from + m.end) };
+ }
+};
+
+const testing = std.testing;
+const th = @import("testing.zig");
+const tree = @import("tree.zig");
+const Node = tree.Node;
+const E = tree.E;
+const Status = tree.Status;
+
+test "the address language, form by form" {
+ const gpa = testing.allocator;
+ const p = try th.withFile(gpa, "one\ntwo\nthree\n");
+ defer p.deinit();
+ const serial = th.serialOf(p);
+ const addr = Node.of(serial, .addr);
+
+ const Case = struct { expr: []const u8, q0: u32, q1: u32 };
+ for ([_]Case{
+ .{ .expr = "#0", .q0 = 0, .q1 = 0 },
+ .{ .expr = "#5", .q0 = 5, .q1 = 5 },
+ .{ .expr = "0", .q0 = 0, .q1 = 0 },
+ .{ .expr = "1", .q0 = 0, .q1 = 4 },
+ .{ .expr = "2", .q0 = 4, .q1 = 8 },
+ .{ .expr = "$", .q0 = 14, .q1 = 14 },
+ .{ .expr = ",", .q0 = 0, .q1 = 14 },
+ .{ .expr = "1,2", .q0 = 0, .q1 = 8 },
+ .{ .expr = "#1,#4", .q0 = 1, .q1 = 4 },
+ .{ .expr = "2+1", .q0 = 8, .q1 = 14 },
+ .{ .expr = "$-1", .q0 = 8, .q1 = 14 },
+ .{ .expr = "/two/", .q0 = 4, .q1 = 7 },
+ .{ .expr = "/t.o/", .q0 = 4, .q1 = 7 },
+ .{ .expr = "1\n", .q0 = 0, .q1 = 4 },
+ }) |c| {
+ _ = th.wr(p, addr, "#0");
+ const w = th.wr(p, addr, c.expr);
+ try testing.expectEqual(Status.ok, w.reply.status);
+ const got = th.rd(p, addr, 0, 64);
+ var want: [32]u8 = undefined;
+ try testing.expectEqualStrings(
+ try std.fmt.bufPrint(&want, "{d:>11} {d:>11} ", .{ c.q0, c.q1 }),
+ got.bytes,
+ );
+ }
+
+ _ = th.wr(p, addr, "1");
+ _ = th.wr(p, addr, ".");
+ try testing.expectEqual(@as(u32, 0), p.fs.panes[0].addr.q0);
+ try testing.expectEqual(@as(u32, 4), p.fs.panes[0].addr.q1);
+
+ _ = th.wr(p, addr, "$");
+ _ = th.wr(p, addr, "?o?");
+ try testing.expectEqual(@as(u32, 6), p.fs.panes[0].addr.q0); // the `o` in "two"
+ try testing.expectEqual(@as(u32, 7), p.fs.panes[0].addr.q1);
+
+ _ = th.wr(p, addr, "1");
+ _ = th.wr(p, Node.of(serial, .ctl), "limit=addr\n");
+ _ = th.wr(p, addr, "#0");
+ try testing.expectEqual(E.INVAL, th.wr(p, addr, "/three/").errno());
+ _ = th.wr(p, Node.of(serial, .ctl), "clean\n");
+ _ = th.call(p, .{ .tag = 6, .op = .open, .node = Node.of(serial, .ctl) });
+ try testing.expect(p.fs.panes[0].limit == null);
+ _ = th.wr(p, addr, "#0");
+ try testing.expectEqual(Status.ok, th.wr(p, addr, "/three/").reply.status);
+
+ for ([_][]const u8{ "zzz", "#", "//", "/nomatch/", "1 2", "99", "/a\\" }) |bad| {
+ _ = th.wr(p, addr, "#0");
+ try testing.expectEqual(E.INVAL, th.wr(p, addr, bad).errno());
+ }
+
+ const nested = "," ** 4096;
+ _ = th.wr(p, addr, "#0");
+ try testing.expectEqual(E.INVAL, th.wr(p, addr, nested).errno());
+}
diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig
new file mode 100644
index 00000000..d61ebf75
--- /dev/null
+++ b/src/ninep/ctl.zig
@@ -0,0 +1,503 @@
+//! The two ctl files. `look TEXT` is a right click and `exec TEXT` a middle
+//! click in the editor's own command language; a pane's ctl adds the addr
+//! verbs. Reading /ctl answers the serials the last command made or touched.
+const std = @import("std");
+const pardes = @import("../pardes.zig");
+const panes = @import("../panes.zig");
+const config = @import("../config.zig");
+const fs = @import("../fs.zig");
+const tree = @import("tree.zig");
+const pane_files = @import("pane.zig");
+
+const Pardes = pardes.Pardes;
+const Pane = pardes.Pane;
+const MAX_PANES = pardes.MAX_PANES;
+const Req = tree.Req;
+const Reply = tree.Reply;
+const E = tree.E;
+
+const Verb = enum {
+ @"addr=dot",
+ clean,
+ cleartag,
+ dirty,
+ @"dot=addr",
+ get,
+ @"limit=addr",
+ mark,
+ nomark,
+ noscroll,
+ scroll,
+ show,
+};
+
+fn verbIs(line: []const u8, word: []const u8) bool {
+ if (!std.mem.startsWith(u8, line, word)) return false;
+ return line.len == word.len or line[word.len] == ' ' or line[word.len] == '\t';
+}
+
+/// The text after `look` or `exec`, or null when the line is not that verb.
+/// Control characters other than tab are refused.
+fn textAfter(line: []const u8, verb: []const u8) ?[]const u8 {
+ if (!verbIs(line, verb)) return null;
+ const text = std.mem.trim(u8, line[verb.len..], " \t");
+ if (text.len == 0) return "";
+ for (text) |c| if (c < ' ' and c != '\t') return "";
+ return text;
+}
+
+// ---- which panes a command made or touched ----
+
+const Serials = struct {
+ list: [MAX_PANES]u32 = undefined,
+ len: usize = 0,
+
+ fn snapshot(p: *Pardes) Serials {
+ var s: Serials = .{};
+ for (p.panes) |slot| if (slot) |pane| {
+ s.list[s.len] = pane.serial;
+ s.len += 1;
+ };
+ return s;
+ }
+
+ fn has(s: *const Serials, serial: u32) bool {
+ for (s.list[0..s.len]) |known| if (known == serial) return true;
+ return false;
+ }
+};
+
+fn noteResult(p: *Pardes, serial: u32) void {
+ for (p.fs.results[0..p.fs.results_len]) |known| if (known == serial) return;
+ if (p.fs.results_len == p.fs.results.len) return;
+ p.fs.results[p.fs.results_len] = serial;
+ p.fs.results_len += 1;
+}
+
+fn activeSerial(p: *Pardes) ?u32 {
+ const pane = p.panes[p.active] orelse return null;
+ return pane.serial;
+}
+
+/// Runs one look or exec from the pane `id`, then records the panes it
+/// created; failing that, a look records the pane it focused and an exec
+/// the pane it acted on (even one it closed).
+fn run(p: *Pardes, id: usize, exec: bool, text: []const u8) void {
+ const context = p.panes[id].?.serial;
+ const before = Serials.snapshot(p);
+ if (exec) _ = p.execute(id, text) else p.lookAt(id, text);
+ const after = Serials.snapshot(p);
+ var created = false;
+ for (after.list[0..after.len]) |serial| if (!before.has(serial)) {
+ noteResult(p, serial);
+ created = true;
+ };
+ if (created) return;
+ noteResult(p, if (exec) context else activeSerial(p) orelse return);
+}
+
+fn beginResults(p: *Pardes) void {
+ p.fs.results_len = 0;
+ p.fs.results_valid = true;
+}
+
+// ---- /ctl ----
+
+pub fn writeRoot(p: *Pardes, req: Req) Reply {
+ for ([2]bool{ false, true }) |apply| {
+ if (apply) beginResults(p);
+ var it = std.mem.splitScalar(u8, req.data, '\n');
+ while (it.next()) |raw| {
+ const line = std.mem.trim(u8, raw, " \t\r");
+ if (line.len == 0) continue;
+ const exec = verbIs(line, "exec");
+ const text = textAfter(line, if (exec) "exec" else "look") orelse return Reply.fail(req.tag, E.INVAL);
+ if (text.len == 0) return Reply.fail(req.tag, E.INVAL);
+ if (!apply) continue;
+ if (p.panes[p.active] == null) return Reply.fail(req.tag, E.NOENT);
+ run(p, p.active, exec, text);
+ }
+ }
+ return .{ .tag = req.tag, .written = @intCast(req.data.len) };
+}
+
+pub fn rootText(p: *Pardes, buf: []u8) []const u8 {
+ var w = std.Io.Writer.fixed(buf);
+ if (p.fs.results_valid) {
+ for (p.fs.results[0..p.fs.results_len]) |serial| w.print("{d}\n", .{serial}) catch break;
+ return w.buffered();
+ }
+ var count: usize = 0;
+ for (p.panes) |slot| count += @intFromBool(slot != null);
+ const pid: u32 = if (comptime pardes.hosted) @intCast(std.c.getpid()) else 0;
+ w.print("pid {d}\nversion {s}\npanes {d}\n", .{ pid, pardes.version, count }) catch {};
+ return w.buffered();
+}
+
+pub const root_text_cap = @max(MAX_PANES * 11, 64 + pardes.version.len);
+
+pub fn readRoot(p: *Pardes, req: Req) Reply {
+ var buf: [root_text_cap]u8 = undefined;
+ const text = rootText(p, &buf);
+ const out = p.fs.stage(p.gpa);
+ out.appendSlice(p.gpa, text) catch return Reply.fail(req.tag, E.NOMEM);
+ return tree.stagedReply(p, req);
+}
+
+// ---- /pane/<n>/ctl ----
+
+fn fontName(p: *Pardes) []const u8 {
+ const name = p.settings.font.effective_name.get();
+ return if (name.len == 0) "default" else name;
+}
+
+fn writeQuoted(w: *std.Io.Writer, s: []const u8) !void {
+ const plain = s.len > 0 and for (s) |c| {
+ if (c <= ' ' or c == '\'') break false;
+ } else true;
+ if (plain) return w.writeAll(s);
+ try w.writeByte('\'');
+ for (s) |c| {
+ if (c == '\'') try w.writeByte('\'');
+ try w.writeByte(c);
+ }
+ try w.writeByte('\'');
+}
+
+pub const pane_text_cap = 7 * 12 + 2 * 256 + 4;
+
+/// acme's winctlprint: serial, tag length, body length, a reserved zero, the
+/// dirty flag, then the width in cells, the font and the tab width.
+pub fn paneText(p: *Pardes, pane: *Pane, buf: []u8) []const u8 {
+ var w = std.Io.Writer.fixed(buf);
+ w.print("{d:>11} {d:>11} {d:>11} {d:>11} {d:>11} {d:>11} ", .{
+ pane.serial,
+ pane_files.tagOf(p, pane).len,
+ pane_files.bodyOf(pane).len,
+ @as(u32, 0),
+ @intFromBool(pane_files.dirtyOf(pane)),
+ pane.cols,
+ }) catch return w.buffered();
+ writeQuoted(&w, fontName(p)) catch return w.buffered();
+ w.print(" {d:>11} ", .{config.tab_width}) catch {};
+ return w.buffered();
+}
+
+pub fn readPane(p: *Pardes, req: Req, pane: *Pane) Reply {
+ var buf: [pane_text_cap]u8 = undefined;
+ const out = p.fs.stage(p.gpa);
+ out.appendSlice(p.gpa, paneText(p, pane, &buf)) catch return Reply.fail(req.tag, E.NOMEM);
+ return tree.stagedReply(p, req);
+}
+
+pub fn writePane(p: *Pardes, req: Req, serial: u32) Reply {
+ for ([2]bool{ false, true }) |apply| {
+ if (apply) beginResults(p);
+ var it = std.mem.splitScalar(u8, req.data, '\n');
+ while (it.next()) |raw| {
+ const line = std.mem.trim(u8, raw, " \t\r");
+ if (line.len == 0) continue;
+ const live = p.paneBySerial(serial) orelse if (apply) break else return Reply.fail(req.tag, E.NOENT);
+ const errno = paneVerb(p, live, line, apply);
+ if (errno != 0) return Reply.fail(req.tag, errno);
+ }
+ }
+ return .{ .tag = req.tag, .written = @intCast(req.data.len) };
+}
+
+fn paneVerb(p: *Pardes, id: usize, line: []const u8, apply: bool) u16 {
+ const pane = p.panes[id] orelse return E.INVAL;
+ const pf = &p.fs.panes[id];
+
+ for ([_]bool{ false, true }) |exec| if (textAfter(line, if (exec) "exec" else "look")) |text| {
+ if (text.len == 0) return E.INVAL;
+ if (apply) run(p, id, exec, text);
+ return 0;
+ };
+ const v = std.meta.stringToEnum(Verb, line) orelse return E.INVAL;
+ if (!apply) return 0;
+
+ switch (v) {
+ .@"addr=dot" => pf.addr = pane_files.dotOf(pane),
+ .@"dot=addr" => {
+ pane_files.clampAddr(pf, pane_files.bodyOf(pane).len);
+ pane_files.setDot(pane, pf.addr);
+ },
+ .@"limit=addr" => {
+ pane_files.clampAddr(pf, pane_files.bodyOf(pane).len);
+ pf.limit = pf.addr;
+ },
+ .clean => if (pane_files.fileOf(pane)) |f| {
+ f.saved_revision = f.revision;
+ },
+ .dirty => if (pane_files.fileOf(pane)) |f| {
+ f.saved_revision = f.revision -% 1;
+ },
+ .cleartag => {
+ pane.tag_tail_len = 0;
+ pane.tag_init = true;
+ },
+ .get => if (pane_files.fileOf(pane)) |f| {
+ if (panes.Output.fileTraits(f.output).saves) {
+ if (fs.read(p, f.path)) |bytes| {
+ panes.File.pushUndo(p, pane);
+ panes.File.setContent(p, f, bytes);
+ f.saved_revision = f.revision;
+ } else |err| return switch (err) {
+ error.FileNotFound => E.NOENT,
+ else => E.IO,
+ };
+ }
+ },
+ .mark => {
+ pf.nomark = false;
+ panes.File.pushUndo(p, pane);
+ },
+ .nomark => pf.nomark = true,
+ .noscroll => pf.noscroll = true,
+ .scroll => pf.noscroll = false,
+ .show => pane_files.showOffset(pane, pane_files.dotOf(pane).q0),
+ }
+ return 0;
+}
+
+// ---- tests ----
+
+const testing = std.testing;
+const th = @import("testing.zig");
+const call = th.call;
+const rd = th.rd;
+const wr = th.wr;
+const withFile = th.withFile;
+const serialOf = th.serialOf;
+const Node = tree.Node;
+const Status = tree.Status;
+const root_ctl = @intFromEnum(tree.TopFile.ctl);
+
+test "pane ctl read is index's five fields plus width in cells, font and tab width" {
+ const gpa = testing.allocator;
+ const p = try withFile(gpa, "x\n");
+ defer p.deinit();
+ const pane = p.panes[0].?;
+
+ const a = rd(p, Node.of(pane.serial, .ctl), 0, 4096);
+ try testing.expectEqual(Status.ok, a.reply.status);
+ var want: std.ArrayList(u8) = .empty;
+ defer want.deinit(gpa);
+ try want.print(gpa, "{d:>11} {d:>11} {d:>11} {d:>11} {d:>11} {d:>11} {s} {d:>11} ", .{
+ pane.serial, pane_files.tagOf(p, pane).len, @as(usize, 2), 0, 0, pane.cols, "default", config.tab_width,
+ });
+ try testing.expectEqualStrings(want.items, a.bytes);
+ try testing.expectEqual(@as(u64, a.bytes.len), call(p, .{ .tag = 1, .op = .getattr, .node = Node.of(pane.serial, .ctl) }).reply.attr.size);
+
+ var quoted: [64]u8 = undefined;
+ var w = std.Io.Writer.fixed(&quoted);
+ try writeQuoted(&w, "DejaVu Sans Mono");
+ try testing.expectEqualStrings("'DejaVu Sans Mono'", w.buffered());
+ w = std.Io.Writer.fixed(&quoted);
+ try writeQuoted(&w, "it's");
+ try testing.expectEqualStrings("'it''s'", w.buffered());
+}
+
+test "every pane ctl verb, and every refusal" {
+ const gpa = testing.allocator;
+ const p = try withFile(gpa, "one\ntwo\n");
+ defer p.deinit();
+ const serial = serialOf(p);
+ const ctl_node = Node.of(serial, .ctl);
+ const pane = p.panes[0].?;
+ const pf = &p.fs.panes[0];
+
+ try testing.expectEqual(Status.ok, wr(p, ctl_node, "nomark\nnoscroll\ndirty\n").reply.status);
+ try testing.expect(pf.nomark and pf.noscroll and pane_files.dirtyOf(pane));
+ try testing.expectEqual(Status.ok, wr(p, ctl_node, "mark\nscroll\nclean\n").reply.status);
+ try testing.expect(!pf.nomark and !pf.noscroll and !pane_files.dirtyOf(pane));
+
+ _ = wr(p, ctl_node, "cleartag\n");
+ try testing.expectEqual(@as(usize, 0), pane.tag_tail_len);
+
+ _ = wr(p, Node.of(serial, .addr), "2");
+ _ = wr(p, ctl_node, "limit=addr\n");
+ try testing.expectEqual(@as(u32, 4), pf.limit.?.q0);
+ _ = wr(p, ctl_node, "dot=addr\nshow\n");
+ try testing.expectEqual(@as(i32, 1), pane.cur_row);
+
+ for ([_][]const u8{
+ "menu", "nomenu", "dump echo hi", "dumpdir /tmp", "font Go Mono", "lock", "unlock", "bogus", "DEL",
+ "name x.txt", "put", "del", "delete", "Look x", "Exec Save",
+ }) |bad| try testing.expectEqual(E.INVAL, wr(p, ctl_node, bad).errno());
+
+ try testing.expect(!pane_files.dirtyOf(pane));
+ try testing.expectEqual(E.INVAL, wr(p, ctl_node, "dirty\nbogus\n").errno());
+ try testing.expect(!pane_files.dirtyOf(pane));
+}
+
+test "exec runs a builtin at the pane and records the pane it acted on" {
+ const gpa = testing.allocator;
+ const p = try withFile(gpa, "Msg fs-ran\n");
+ defer p.deinit();
+ const serial = serialOf(p);
+ const pane = p.panes[0].?;
+ try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .ctl), "exec Msg from ctl\n").reply.status);
+ try testing.expectEqualStrings("from ctl", pane.msg[0..pane.msg_len]);
+ var expected: [16]u8 = undefined;
+ try testing.expectEqualStrings(try std.fmt.bufPrint(&expected, "{d}\n", .{serial}), rd(p, root_ctl, 0, 4096).bytes);
+
+ try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .ctl), "exec New\n").reply.status);
+ const made = p.panes[p.active].?;
+ try testing.expect(made.serial != serial);
+ try testing.expectEqualStrings(try std.fmt.bufPrint(&expected, "{d}\n", .{made.serial}), rd(p, root_ctl, 0, 4096).bytes);
+
+ try testing.expectEqual(Status.ok, wr(p, Node.of(made.serial, .ctl), "exec Del\n").reply.status);
+ try testing.expect(p.paneBySerial(made.serial) == null);
+ try testing.expectEqualStrings(try std.fmt.bufPrint(&expected, "{d}\n", .{made.serial}), rd(p, root_ctl, 0, 4096).bytes);
+}
+
+test "the root ctl reads editor status until a command runs and validates whole batches" {
+ const gpa = testing.allocator;
+ const p = try withFile(gpa, "Msg root\n");
+ defer p.deinit();
+ const status = rd(p, root_ctl, 0, 4096);
+ try testing.expectEqual(Status.ok, status.reply.status);
+ var lines = std.mem.tokenizeScalar(u8, status.bytes, '\n');
+ try testing.expect(std.mem.startsWith(u8, lines.next().?, "pid "));
+ var expected: [64]u8 = undefined;
+ try testing.expectEqualStrings(try std.fmt.bufPrint(&expected, "version {s}", .{pardes.version}), lines.next().?);
+ try testing.expectEqualStrings("panes 1", lines.next().?);
+ try testing.expect(lines.next() == null);
+ const stat = call(p, .{ .tag = 1, .op = .getattr, .node = root_ctl });
+ try testing.expectEqual(@as(u64, status.bytes.len), stat.reply.attr.size);
+ try testing.expectEqual(@as(u16, 0o666), stat.reply.attr.mode);
+
+ const serial = p.next_serial;
+ for ([_][]const u8{ "look", "exec", "look \t", "Look /virtual/index", "exec Msg a\x00b", "exec Msg x\nbogus\n", "name x", "put" }) |invalid| {
+ try testing.expectEqual(E.INVAL, wr(p, root_ctl, invalid).errno());
+ try testing.expectEqual(serial, p.next_serial);
+ }
+ try testing.expectEqualStrings(status.bytes, rd(p, root_ctl, 0, 4096).bytes);
+
+ const pane = p.panes[0].?;
+ try testing.expectEqual(Status.ok, wr(p, root_ctl, "exec Msg hello there\n").reply.status);
+ try testing.expectEqualStrings("hello there", pane.msg[0..pane.msg_len]);
+ try testing.expectEqualStrings(try std.fmt.bufPrint(&expected, "{d}\n", .{pane.serial}), rd(p, root_ctl, 0, 4096).bytes);
+
+ try testing.expectEqual(Status.ok, wr(p, root_ctl, "look /virtual/index\nexec New\n").reply.status);
+ var made: [MAX_PANES]u32 = undefined;
+ var n: usize = 0;
+ for (p.panes) |slot| if (slot) |q| if (q.serial != pane.serial) {
+ made[n] = q.serial;
+ n += 1;
+ };
+ try testing.expectEqual(@as(usize, 2), n);
+ const listed = rd(p, root_ctl, 0, 4096);
+ try testing.expectEqualStrings(try std.fmt.bufPrint(&expected, "{d}\n{d}\n", .{ @min(made[0], made[1]), @max(made[0], made[1]) }), listed.bytes);
+ const opened = p.panes[p.paneBySerial(@min(made[0], made[1])).?].?;
+ try testing.expectEqualStrings("/virtual/index", opened.file.?.path);
+}
+
+test "look opens spaced paths and locations without editing the source pane" {
+ if (!fs.platform_has_fs) return error.SkipZigTest;
+ const gpa = testing.allocator;
+ var tmp = testing.tmpDir(.{});
+ defer tmp.cleanup();
+ try tmp.dir.writeFile(testing.io, .{ .sub_path = "two words.zig", .data = "first\nsecond\nthird\n" });
+ var path_buf: [4096]u8 = undefined;
+ const path = path_buf[0..try tmp.dir.realPathFile(testing.io, "two words.zig", &path_buf)];
+ const p = try withFile(gpa, "source stays intact\n");
+ defer p.deinit();
+ const source = p.panes[0].?;
+ const ctl_node = Node.of(source.serial, .ctl);
+ const revision = source.file.?.revision;
+ const undo_len = source.file.?.history.undo_len;
+ source.cur_col = 3;
+ var command: [4200]u8 = undefined;
+ try testing.expectEqual(Status.ok, wr(p, ctl_node, try std.fmt.bufPrint(&command, "look {s}:2:3\n", .{path})).reply.status);
+ const opened = p.panes[p.active].?;
+ try testing.expect(opened != source);
+ try testing.expectEqualStrings(path, opened.file.?.path);
+ try testing.expectEqualStrings("first\nsecond\nthird\n", opened.file.?.content);
+ try testing.expectEqual(@as(i32, 1), opened.cur_row);
+ try testing.expectEqual(@as(i32, 2), opened.cur_col);
+ try testing.expectEqualStrings("source stays intact\n", source.file.?.content);
+ try testing.expectEqual(revision, source.file.?.revision);
+ try testing.expectEqual(undo_len, source.file.?.history.undo_len);
+ try testing.expectEqual(@as(i32, 3), source.cur_col);
+ var expected: [16]u8 = undefined;
+ try testing.expectEqualStrings(try std.fmt.bufPrint(&expected, "{d}\n", .{opened.serial}), rd(p, root_ctl, 0, 4096).bytes);
+
+ p.active = 0;
+ try testing.expectEqual(Status.ok, wr(p, root_ctl, try std.fmt.bufPrint(&command, "look {s}:3:2\n", .{path})).reply.status);
+ try testing.expect(p.panes[p.active].? == opened);
+ try testing.expectEqual(@as(i32, 2), opened.cur_row);
+ try testing.expectEqual(@as(i32, 1), opened.cur_col);
+ try testing.expectEqualStrings(try std.fmt.bufPrint(&expected, "{d}\n", .{opened.serial}), rd(p, root_ctl, 0, 4096).bytes);
+}
+
+test "look validates the whole batch before opening virtual files" {
+ const gpa = testing.allocator;
+ const p = try withFile(gpa, "source\n");
+ defer p.deinit();
+ const source = p.panes[0].?;
+ const ctl_node = Node.of(source.serial, .ctl);
+ const serial = p.next_serial;
+ for ([_][]const u8{
+ "look", "look \t", "look /virtual/index\x00ignored", "look /virtual/index\nbogus\n",
+ }) |invalid| {
+ try testing.expectEqual(E.INVAL, wr(p, ctl_node, invalid).errno());
+ try testing.expectEqual(serial, p.next_serial);
+ try testing.expectEqual(@as(usize, 0), p.active);
+ try testing.expectEqualStrings("source\n", source.file.?.content);
+ }
+ try testing.expectEqual(Status.ok, wr(p, ctl_node, "look /n/self/index\n").reply.status);
+ const opened = p.panes[p.active].?;
+ try testing.expectEqualStrings("/virtual/index", opened.file.?.path);
+ try testing.expect(std.mem.indexOf(u8, opened.file.?.content, "/test.txt") != null);
+ try testing.expectEqualStrings("source\n", source.file.?.content);
+}
+
+test "get reloads the pane from disk and reports a missing file without losing dirty contents" {
+ const gpa = testing.allocator;
+ var tmp = testing.tmpDir(.{});
+ defer tmp.cleanup();
+ try tmp.dir.writeFile(testing.io, .{ .sub_path = "note.txt", .data = "from disk\n" });
+ var directory_buf: [4096]u8 = undefined;
+ const directory = directory_buf[0..try tmp.dir.realPath(testing.io, &directory_buf)];
+ var path_buf: [4096]u8 = undefined;
+ const path = try std.fmt.bufPrint(&path_buf, "{s}/note.txt", .{directory});
+
+ const p = try withFile(gpa, "in memory\n");
+ defer p.deinit();
+ const serial = serialOf(p);
+ const ctl_node = Node.of(serial, .ctl);
+ const pane = p.panes[0].?;
+
+ var name: [std.fs.max_path_bytes + 8]u8 = undefined;
+ _ = wr(p, Node.of(serial, .name), try std.fmt.bufPrint(&name, "{s}\n", .{path}));
+ try testing.expectEqual(Status.ok, wr(p, ctl_node, "get\n").reply.status);
+ try testing.expectEqualStrings("from disk\n", pane.file.?.content);
+ try testing.expect(!pane_files.dirtyOf(pane));
+ try testing.expect(pane.file.?.history.undo_len > 0);
+
+ _ = wr(p, Node.of(serial, .name), try std.fmt.bufPrint(&name, "{s}/missing.txt\n", .{directory}));
+ _ = wr(p, Node.of(serial, .body), "unsaved\n");
+ try testing.expectEqual(E.NOENT, wr(p, ctl_node, "get\nexec Del\n").errno());
+ try testing.expect(p.paneBySerial(serial) != null);
+ try testing.expectEqualStrings("from disk\nunsaved\n", pane.file.?.content);
+ try testing.expect(pane_files.dirtyOf(pane));
+}
+
+test "exec Save writes the named file through the host" {
+ const gpa = testing.allocator;
+ const p = try withFile(gpa, "one\ntwo\n");
+ defer p.deinit();
+ const serial = serialOf(p);
+ const pane = p.panes[0].?;
+ try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .name), "/tmp/renamed.txt\n").reply.status);
+ try testing.expect(pane_files.dirtyOf(pane));
+ const saved = wr(p, Node.of(serial, .ctl), "exec Save\n");
+ try testing.expectEqual(Status.ok, saved.reply.status);
+ try testing.expect(saved.saved);
+ p.perform(.{ .save_file = .{ .pane = 0 } });
+ try testing.expect(!pane_files.dirtyOf(pane));
+ try testing.expectEqualStrings(pane.file.?.content, p.fallback.get("/tmp/renamed.txt").?);
+}
diff --git a/src/ninep/events.zig b/src/ninep/events.zig
new file mode 100644
index 00000000..fb49e41b
--- /dev/null
+++ b/src/ninep/events.zig
@@ -0,0 +1,558 @@
+//! Event streams: per-pane `event` records, the editor-wide `log`, and the
+//! bounded queues that park a read until something happens.
+const std = @import("std");
+const pardes = @import("../pardes.zig");
+const tree = @import("tree.zig");
+const pane_files = @import("pane.zig");
+
+const Pardes = pardes.Pardes;
+const Pane = pardes.Pane;
+const MAX_PANES = pardes.MAX_PANES;
+const Req = tree.Req;
+const Reply = tree.Reply;
+const E = tree.E;
+
+pub const queue_cap = 64 * 1024;
+
+/// Wall-clock seconds, or zero where the platform has no clock.
+pub fn now() u32 {
+ if (comptime !pardes.hosted) return 0;
+ var ts: std.c.timespec = undefined;
+ if (std.c.clock_gettime(.REALTIME, &ts) != 0) return 0;
+ return std.math.cast(u32, ts.sec) orelse 0;
+}
+
+pub const Queue = struct {
+ buf: std.ArrayList(u8) = .empty,
+ head: usize = 0,
+
+ pub fn deinit(q: *Queue, gpa: std.mem.Allocator) void {
+ q.buf.deinit(gpa);
+ q.head = 0;
+ }
+
+ pub fn push(q: *Queue, gpa: std.mem.Allocator, record: []const u8) void {
+ if (record.len > std.math.maxInt(u32)) return;
+ while (q.buf.items.len - q.head + record.len + 4 > queue_cap) {
+ if (q.peek() == null) return;
+ q.pop();
+ }
+ q.compact();
+ var head: [4]u8 = undefined;
+ std.mem.writeInt(u32, &head, @intCast(record.len), .little);
+ q.buf.appendSlice(gpa, &head) catch return;
+ q.buf.appendSlice(gpa, record) catch {
+ q.buf.shrinkRetainingCapacity(q.buf.items.len - 4);
+ return;
+ };
+ }
+
+ pub fn peek(q: *const Queue) ?[]const u8 {
+ const rest = q.buf.items[@min(q.head, q.buf.items.len)..];
+ if (rest.len < 4) return null;
+ const len = std.mem.readInt(u32, rest[0..4], .little);
+ if (rest.len < 4 + len) return null;
+ return rest[4 .. 4 + len];
+ }
+
+ pub fn pop(q: *Queue) void {
+ const record = q.peek() orelse return;
+ q.head += 4 + record.len;
+ if (q.head == q.buf.items.len) {
+ q.buf.clearRetainingCapacity();
+ q.head = 0;
+ }
+ }
+
+ pub fn popFront(q: *Queue, n: usize) void {
+ const record = q.peek() orelse return;
+ if (n >= record.len) return q.pop();
+ q.head += n;
+ std.mem.writeInt(u32, q.buf.items[q.head..][0..4], @intCast(record.len - n), .little);
+ }
+
+ fn compact(q: *Queue) void {
+ if (q.head == 0 or q.head * 2 < q.buf.items.len) return;
+ const rest = q.buf.items.len - q.head;
+ std.mem.copyForwards(u8, q.buf.items[0..rest], q.buf.items[q.head..]);
+ q.buf.shrinkRetainingCapacity(rest);
+ q.head = 0;
+ }
+
+ pub fn empty(q: *const Queue) bool {
+ return q.peek() == null;
+ }
+
+ pub fn clearAndFree(q: *Queue, gpa: std.mem.Allocator) void {
+ q.buf.clearAndFree(gpa);
+ q.head = 0;
+ }
+};
+
+/// One record per read; `.again` parks the read until a record arrives.
+pub fn readQueue(p: *Pardes, req: Req, q: *Queue) Reply {
+ const record = q.peek() orelse return .{ .tag = req.tag, .status = .again };
+ if (req.size < record.len) return Reply.fail(req.tag, E.INVAL);
+ const out = p.fs.stage(p.gpa);
+ out.appendSlice(p.gpa, record) catch return Reply.fail(req.tag, E.NOMEM);
+ q.pop();
+ return .{ .tag = req.tag, .payload = .{ .staged = @intCast(out.items.len) } };
+}
+
+// ---- the editor-wide log ----
+
+pub const LogKind = enum { new, del, rename, save };
+
+/// A pane installed now is announced by `announce` once the update that made
+/// it has finished, when its name is known.
+pub fn noteInstall(p: *Pardes, id: usize) void {
+ if (id < MAX_PANES) p.fs.panes[id].unannounced = true;
+}
+
+/// A pane leaving; one never announced leaves silently.
+pub fn noteRetire(p: *Pardes, id: usize, pane: *Pane) void {
+ if (id >= MAX_PANES) return;
+ if (p.fs.panes[id].unannounced) {
+ p.fs.panes[id].unannounced = false;
+ return;
+ }
+ noteLog(p, .del, pane);
+}
+
+/// Called at the end of every update: reports the panes installed by it.
+pub fn announce(p: *Pardes) void {
+ for (p.panes, 0..) |slot, id| {
+ const pf = &p.fs.panes[id];
+ if (!pf.unannounced) continue;
+ pf.unannounced = false;
+ if (slot) |pane| noteLog(p, .new, pane);
+ }
+}
+
+/// Records `<kind> <serial> <name>` while a reader holds /log open.
+pub fn noteLog(p: *Pardes, kind: LogKind, pane: *Pane) void {
+ if (p.fs.log_readers == 0) return;
+ var buf: [4096 + 64]u8 = undefined;
+ const name = pane_files.nameOf(pane);
+ const record = std.fmt.bufPrint(&buf, "{s} {d} {s}\n", .{ @tagName(kind), pane.serial, name[0..@min(name.len, 4096)] }) catch return;
+ p.fs.log.push(p.gpa, record);
+}
+
+// ---- per-pane event records ----
+
+pub const max_record_text = 256;
+
+pub const Action = enum(u8) {
+ body_delete = 'D',
+ tag_delete = 'd',
+ body_insert = 'I',
+ tag_insert = 'i',
+ body_look = 'L',
+ tag_look = 'l',
+ body_exec = 'X',
+ tag_exec = 'x',
+
+ pub fn char(a: Action) u8 {
+ return @intFromEnum(a);
+ }
+
+ pub fn fromChar(c: u8) ?Action {
+ return std.enums.fromInt(Action, c);
+ }
+
+ pub fn onTag(a: Action) bool {
+ return @intFromEnum(a) >= 'a';
+ }
+};
+
+pub const flag_builtin: u32 = 1;
+pub const flag_expansion: u32 = 2;
+pub const flag_filename: u32 = 4;
+pub const flag_chorded: u32 = 8;
+
+pub fn formatRecord(
+ buf: []u8,
+ origin: u8,
+ action: Action,
+ q0: u32,
+ q1: u32,
+ flag: u32,
+ text: []const u8,
+) []const u8 {
+ const sent = if (text.len >= max_record_text) text[0..0] else text;
+ return std.fmt.bufPrint(buf, "{c}{c}{d} {d} {d} {d} {s}\n", .{
+ origin,
+ action.char(),
+ q0,
+ q1,
+ flag,
+ sent.len,
+ sent,
+ }) catch buf[0..0];
+}
+
+pub const Span = struct { at: u32, removed: u32, inserted: u32 };
+
+pub fn diffSpan(old: []const u8, new: []const u8) Span {
+ const both = @min(old.len, new.len);
+ const stride = 64;
+ var head: usize = 0;
+ while (head + stride <= both and
+ std.mem.eql(u8, old[head..][0..stride], new[head..][0..stride])) head += stride;
+ while (head < both and old[head] == new[head]) head += 1;
+ var tail: usize = 0;
+ const rest = both - head;
+ while (tail + stride <= rest and std.mem.eql(
+ u8,
+ old[old.len - tail - stride ..][0..stride],
+ new[new.len - tail - stride ..][0..stride],
+ )) tail += stride;
+ while (tail < rest and old[old.len - 1 - tail] == new[new.len - 1 - tail]) tail += 1;
+ return .{
+ .at = @intCast(head),
+ .removed = @intCast(old.len - tail - head),
+ .inserted = @intCast(new.len - tail - head),
+ };
+}
+
+pub fn noteReplace(p: *Pardes, id: usize, on_tag: bool, old: []const u8, new: []const u8) void {
+ if (!p.fs.scripted(id)) return;
+ const span = diffSpan(old, new);
+ if (span.removed == 0 and span.inserted == 0) return;
+ if (span.removed > 0) _ = noteAction(
+ p,
+ id,
+ if (on_tag) .tag_delete else .body_delete,
+ span.at,
+ span.at + span.removed,
+ 0,
+ "",
+ );
+ if (span.inserted > 0) _ = noteAction(
+ p,
+ id,
+ if (on_tag) .tag_insert else .body_insert,
+ span.at,
+ span.at + span.inserted,
+ 0,
+ new[span.at..][0..span.inserted],
+ );
+}
+
+pub fn noteAction(
+ p: *Pardes,
+ id: usize,
+ action: Action,
+ q0: u32,
+ q1: u32,
+ flag: u32,
+ text: []const u8,
+) bool {
+ if (!p.fs.scripted(id)) return false;
+ var buf: [max_record_text + 64]u8 = undefined;
+ const record = formatRecord(&buf, p.fs.origin, action, q0, q1, flag, text);
+ p.fs.panes[id].events.push(p.gpa, record);
+ return true;
+}
+
+pub fn notePtyOutput(p: *Pardes, id: usize, bytes: []const u8) void {
+ if (id >= MAX_PANES or bytes.len == 0) return;
+ const pf = &p.fs.panes[id];
+ if (pf.pty_readers == 0) return;
+ var off: usize = 0;
+ while (off < bytes.len) {
+ const n = @min(bytes.len - off, queue_cap / 2);
+ pf.pty_out.push(p.gpa, bytes[off..][0..n]);
+ off += n;
+ }
+}
+
+const EventRecord = struct { action: Action, q0: u32, q1: u32 };
+
+const EventReader = struct {
+ data: []const u8,
+ i: usize = 0,
+
+ fn next(er: *EventReader) ?EventRecord {
+ if (er.i >= er.data.len) return null;
+ var i = er.i;
+ if (i + 2 > er.data.len) return null;
+ i += 1;
+ const action = Action.fromChar(er.data[i]) orelse return null;
+ i += 1;
+ const q0 = scanNumber(er.data, &i) orelse return null;
+ const q1 = scanNumber(er.data, &i) orelse return null;
+ while (i < er.data.len and er.data[i] == ' ') i += 1;
+ if (i >= er.data.len or er.data[i] != '\n') return null;
+ er.i = i + 1;
+ return .{ .action = action, .q0 = q0, .q1 = q1 };
+ }
+};
+
+fn scanNumber(data: []const u8, i: *usize) ?u32 {
+ while (i.* < data.len and data[i.*] == ' ') i.* += 1;
+ const s = i.*;
+ var n: u64 = 0;
+ while (i.* < data.len and data[i.*] >= '0' and data[i.*] <= '9') : (i.* += 1)
+ n = @min(n * 10 + (data[i.*] - '0'), std.math.maxInt(u32));
+ if (i.* == s) return null;
+ return @intCast(n);
+}
+
+/// Writing a Look or Exec record back performs the action it names.
+pub fn writeEvent(p: *Pardes, req: Req, id: usize) Reply {
+ const pane0 = p.panes[id] orelse return Reply.fail(req.tag, E.NOENT);
+ const serial = pane0.serial;
+ {
+ const body = pane_files.bodyOf(pane0);
+ const tag = pane_files.tagOf(p, pane0);
+ var check: EventReader = .{ .data = req.data };
+ while (check.next()) |r| {
+ switch (r.action) {
+ .body_look, .tag_look, .body_exec, .tag_exec => {},
+ else => return Reply.fail(req.tag, E.INVAL),
+ }
+ const n = if (r.action.onTag()) tag.len else body.len;
+ if (r.q0 > r.q1 or r.q1 > n) return Reply.fail(req.tag, E.INVAL);
+ }
+ if (check.i != req.data.len) return Reply.fail(req.tag, E.INVAL);
+ }
+ var run: EventReader = .{ .data = req.data };
+ while (run.next()) |r| {
+ const live = p.paneBySerial(serial) orelse break;
+ const pane = p.panes[live].?;
+ const whole = if (r.action.onTag()) pane_files.tagOf(p, pane) else pane_files.bodyOf(pane);
+ const lo = @min(@as(usize, r.q0), whole.len);
+ const hi = @max(lo, @min(@as(usize, r.q1), whole.len));
+ const text = p.scratch.allocator().dupe(u8, whole[lo..hi]) catch continue;
+ switch (r.action) {
+ .body_exec, .tag_exec => _ = p.execute(live, text),
+ .body_look, .tag_look => p.lookAt(live, text),
+ else => unreachable,
+ }
+ }
+ return .{ .tag = req.tag, .written = @intCast(req.data.len) };
+}
+
+// ---- tests ----
+
+const testing = std.testing;
+const th = @import("testing.zig");
+const call = th.call;
+const rd = th.rd;
+const wr = th.wr;
+const withFile = th.withFile;
+const serialOf = th.serialOf;
+const Node = tree.Node;
+const Status = tree.Status;
+
+test "event records are acme's bytes, one per read, and .again when empty" {
+ const gpa = testing.allocator;
+ const p = try withFile(gpa, "Msg fs-ran\n");
+ defer p.deinit();
+ const serial = serialOf(p);
+ const event = Node.of(serial, .event);
+
+ _ = noteAction(p, 0, .body_exec, 1, 4, flag_builtin, "sg ");
+ try testing.expect(p.fs.panes[0].events.empty());
+
+ const h = call(p, .{ .tag = 10, .op = .open, .node = event });
+ try testing.expect(h.reply.handle != 0);
+ try testing.expectEqual(@as(u16, 1), p.fs.listeners);
+
+ try testing.expectEqual(Status.again, rd(p, event, 0, 4096).reply.status);
+
+ p.fs.origin = 'M';
+ _ = noteAction(p, 0, .body_exec, 1, 4, flag_builtin, "ell");
+ _ = noteAction(p, 0, .body_delete, 0, 3, 0, "");
+ try testing.expectEqualStrings("MX1 4 1 3 ell\n", rd(p, event, 0, 4096).bytes);
+ try testing.expectEqualStrings("MD0 3 0 0 \n", rd(p, event, 0, 4096).bytes);
+ try testing.expectEqual(Status.again, rd(p, event, 0, 4096).reply.status);
+
+ _ = noteAction(p, 0, .body_look, 0, 3, flag_filename, "one");
+ try testing.expectEqual(E.INVAL, rd(p, event, 0, 4).errno());
+ try testing.expectEqualStrings("ML0 3 4 3 one\n", rd(p, event, 0, 4096).bytes);
+
+ const big = "z" ** max_record_text;
+ _ = noteAction(p, 0, .body_exec, 0, max_record_text, 0, big);
+ try testing.expectEqualStrings("MX0 256 0 0 \n", rd(p, event, 0, 4096).bytes);
+
+ _ = call(p, .{ .tag = 11, .op = .release, .node = event, .handle = h.reply.handle });
+ try testing.expectEqual(@as(u16, 0), p.fs.listeners);
+}
+
+fn drainEvents(p: *Pardes, node: u64, store: []u8, out: [][]const u8) [][]const u8 {
+ var used: usize = 0;
+ var n: usize = 0;
+ while (n < out.len) {
+ const a = rd(p, node, 0, 4096);
+ if (a.reply.status != .ok) break;
+ @memcpy(store[used..][0..a.bytes.len], a.bytes);
+ out[n] = store[used..][0..a.bytes.len];
+ used += a.bytes.len;
+ n += 1;
+ }
+ return out[0..n];
+}
+
+test "a write through the filesystem is reported once, attributed to the file it came through" {
+ const gpa = testing.allocator;
+ const p = try withFile(gpa, "one\ntwo\n");
+ defer p.deinit();
+ const serial = serialOf(p);
+ const event = Node.of(serial, .event);
+ _ = call(p, .{ .tag = 40, .op = .open, .node = event });
+ var store: [4096]u8 = undefined;
+ var slots: [16][]const u8 = undefined;
+ _ = drainEvents(p, event, &store, &slots);
+
+ _ = wr(p, Node.of(serial, .body), "three\n");
+ const body_recs = drainEvents(p, event, &store, &slots);
+ try testing.expect(body_recs.len >= 1);
+ try testing.expectEqualStrings("EI8 14 0 6 three\n\n", body_recs[0]);
+ for (body_recs[1..]) |r| {
+ try testing.expectEqual(@as(u8, 'E'), r[0]);
+ try testing.expect(Action.fromChar(r[1]).?.onTag());
+ }
+
+ _ = wr(p, Node.of(serial, .addr), "1");
+ _ = wr(p, Node.of(serial, .data), "ONE\n");
+ const data_recs = drainEvents(p, event, &store, &slots);
+ try testing.expectEqual(@as(usize, 2), data_recs.len);
+ try testing.expectEqualStrings("FD0 3 0 0 \n", data_recs[0]);
+ try testing.expectEqualStrings("FI0 3 0 3 ONE\n", data_recs[1]);
+ try testing.expectEqual(Status.again, rd(p, event, 0, 4096).reply.status);
+}
+
+test "two event readers each count once, and the second closing leaves the first" {
+ const gpa = testing.allocator;
+ const p = try withFile(gpa, "x\n");
+ defer p.deinit();
+ const event = Node.of(serialOf(p), .event);
+
+ _ = call(p, .{ .tag = 12, .op = .open, .node = event });
+ _ = call(p, .{ .tag = 13, .op = .open, .node = event });
+ try testing.expectEqual(@as(u16, 2), p.fs.panes[0].readers);
+ try testing.expectEqual(@as(u16, 2), p.fs.listeners);
+
+ _ = call(p, .{ .tag = 14, .op = .release, .node = event });
+ try testing.expectEqual(@as(u16, 1), p.fs.panes[0].readers);
+ try testing.expect(p.fs.scripted(0));
+
+ _ = call(p, .{ .tag = 15, .op = .release, .node = Node.of(serialOf(p), .body) });
+ try testing.expectEqual(@as(u16, 1), p.fs.listeners);
+
+ _ = call(p, .{ .tag = 16, .op = .release, .node = event });
+ try testing.expectEqual(@as(u16, 0), p.fs.listeners);
+ try testing.expect(!p.fs.scripted(0));
+ _ = call(p, .{ .tag = 17, .op = .release, .node = event });
+ try testing.expectEqual(@as(u16, 0), p.fs.listeners);
+}
+
+test "a pane deleted while its event file is open leaves no suppression behind" {
+ const gpa = testing.allocator;
+ const p = try withFile(gpa, "x\n");
+ defer p.deinit();
+ const serial = serialOf(p);
+ const event = Node.of(serial, .event);
+ _ = try th.newPane(p);
+
+ const a = call(p, .{ .tag = 18, .op = .open, .node = event });
+ const b = call(p, .{ .tag = 19, .op = .open, .node = event });
+ try testing.expectEqual(@as(u16, 2), p.fs.listeners);
+
+ _ = wr(p, Node.of(serial, .ctl), "exec Del\n");
+ try testing.expect(p.paneBySerial(serial) == null);
+ try testing.expectEqual(@as(u16, 0), p.fs.listeners);
+
+ _ = call(p, .{ .tag = 20, .op = .release, .node = event, .handle = a.reply.handle });
+ _ = call(p, .{ .tag = 21, .op = .release, .node = event, .handle = b.reply.handle });
+ try testing.expectEqual(@as(u16, 0), p.fs.listeners);
+
+ try testing.expectEqual(E.NOENT, rd(p, event, 0, 64).errno());
+ try testing.expectEqual(E.NOENT, rd(p, Node.of(serial, .body), 0, 64).errno());
+ try testing.expectEqual(E.NOENT, wr(p, Node.of(serial, .ctl), "clean\n").errno());
+ try testing.expectEqual(E.NOENT, call(p, .{ .tag = 22, .op = .open, .node = event }).errno());
+}
+
+test "writing an event record back performs the action it names" {
+ const gpa = testing.allocator;
+ const p = try withFile(gpa, "Msg fs-ran\n");
+ defer p.deinit();
+ const serial = serialOf(p);
+ const event = Node.of(serial, .event);
+ const pane = p.panes[0].?;
+
+ const w = wr(p, event, "FX0 10\n");
+ try testing.expectEqual(Status.ok, w.reply.status);
+ try testing.expectEqualStrings("fs-ran", pane.msg[0..pane.msg_len]);
+
+ pane.msg_len = 0;
+ try testing.expectEqual(Status.ok, wr(p, event, "FX0 10\nFX0 10\n").reply.status);
+ try testing.expectEqualStrings("fs-ran", pane.msg[0..pane.msg_len]);
+
+ pane.msg_len = 0;
+ for ([_][]const u8{
+ "FX0 10\nFQ0 1\n", // unknown type character
+ "FX0 999\n", // out of range
+ "FX0 10", // no newline
+ "FX5 1\n", // q0 > q1
+ "FD0 3\n", // a report, not a request
+ "F\n",
+ }) |bad| {
+ try testing.expectEqual(E.INVAL, wr(p, event, bad).errno());
+ try testing.expectEqual(@as(usize, 0), pane.msg_len);
+ }
+
+ _ = call(p, .{ .tag = 23, .op = .open, .node = event });
+ p.fs.origin = 'K';
+ _ = wr(p, event, "KX0 10\n");
+ try testing.expectEqual(@as(u8, 'F'), p.fs.origin);
+}
+
+test "the log parks until a pane is created, renamed, saved or deleted" {
+ const gpa = testing.allocator;
+ const p = try withFile(gpa, "logged\n");
+ defer p.deinit();
+ const log = @intFromEnum(tree.TopFile.log);
+ _ = try th.newPane(p);
+ try testing.expect(p.fs.log.empty());
+
+ const opened = call(p, .{ .tag = 1, .op = .open, .node = log });
+ try testing.expectEqual(Status.ok, opened.reply.status);
+ try testing.expectEqual(@as(u16, 1), p.fs.log_readers);
+ try testing.expectEqual(Status.again, rd(p, log, 0, 4096).reply.status);
+
+ const serial = try th.newPane(p);
+ const id = p.paneBySerial(serial).?;
+ var expected: [4200]u8 = undefined;
+ try testing.expectEqualStrings(
+ try std.fmt.bufPrint(&expected, "new {d} {s}\n", .{ serial, pane_files.nameOf(p.panes[id].?) }),
+ rd(p, log, 0, 4096).bytes,
+ );
+ try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .name), "/tmp/logged.txt\n").reply.status);
+ try testing.expectEqualStrings(
+ try std.fmt.bufPrint(&expected, "rename {d} /tmp/logged.txt\n", .{serial}),
+ rd(p, log, 0, 4096).bytes,
+ );
+ const saving = wr(p, Node.of(serial, .ctl), "exec Save\n");
+ try testing.expectEqual(Status.ok, saving.reply.status);
+ try testing.expect(saving.saved);
+ p.perform(.{ .save_file = .{ .pane = @intCast(id) } });
+ try testing.expectEqualStrings(
+ try std.fmt.bufPrint(&expected, "save {d} /tmp/logged.txt\n", .{serial}),
+ rd(p, log, 0, 4096).bytes,
+ );
+ try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .ctl), "exec Del\n").reply.status);
+ try testing.expectEqualStrings(
+ try std.fmt.bufPrint(&expected, "del {d} /tmp/logged.txt\n", .{serial}),
+ rd(p, log, 0, 4096).bytes,
+ );
+ try testing.expectEqual(Status.again, rd(p, log, 0, 4096).reply.status);
+
+ _ = call(p, .{ .tag = 2, .op = .release, .node = log, .handle = opened.reply.handle });
+ try testing.expectEqual(@as(u16, 0), p.fs.log_readers);
+ _ = try th.newPane(p);
+ try testing.expect(p.fs.log.empty());
+ try testing.expectEqual(@as(usize, 0), p.fs.log.buf.capacity);
+}
diff --git a/src/ninep/pane.zig b/src/ninep/pane.zig
new file mode 100644
index 00000000..b37ae645
--- /dev/null
+++ b/src/ninep/pane.zig
@@ -0,0 +1,840 @@
+//! The files under /pane/<serial>/: name, body, tag, ctl, addr, data, xdata,
+//! sel, errors and event. Terminal panes add pty/ (pty.zig).
+const std = @import("std");
+const pardes = @import("../pardes.zig");
+const panes = @import("../panes.zig");
+const modal = @import("../modal.zig");
+const config = @import("../config.zig");
+const fs = @import("../fs.zig");
+const tree = @import("tree.zig");
+const events = @import("events.zig");
+const addressing = @import("addr.zig");
+const ctl = @import("ctl.zig");
+const pty = @import("pty.zig");
+const screen = @import("screen.zig");
+
+const Pardes = pardes.Pardes;
+const Pane = pardes.Pane;
+const Req = tree.Req;
+const Reply = tree.Reply;
+const E = tree.E;
+const PaneFile = tree.PaneFile;
+const Node = tree.Node;
+
+/// Filesystem state a pane carries beside its editor state.
+pub const State = struct {
+ addr: Range = .{},
+ limit: ?Range = null,
+ readers: u16 = 0,
+ events: events.Queue = .{},
+ nomark: bool = false,
+ noscroll: bool = false,
+ tag_snap: std.ArrayList(u8) = .empty,
+ pty_readers: u16 = 0,
+ pty_out: events.Queue = .{},
+ /// Installed during this update; /log hears about it once the update ends
+ /// and the pane has its file, name and place.
+ unannounced: bool = false,
+
+ pub const Range = struct { q0: u32 = 0, q1: u32 = 0 };
+
+ pub fn deinit(pf: *State, gpa: std.mem.Allocator) void {
+ pf.events.deinit(gpa);
+ pf.pty_out.deinit(gpa);
+ pf.tag_snap.deinit(gpa);
+ pf.* = .{};
+ }
+};
+
+// ---- what a pane is ----
+
+pub fn bodyOf(pane: *const Pane) []const u8 {
+ if (pane.file) |*f| return f.content;
+ return "";
+}
+
+pub fn fileOf(pane: *Pane) ?*panes.File.State {
+ return if (pane.file) |*f| f else null;
+}
+
+pub fn tagOf(p: *Pardes, pane: *Pane) []const u8 {
+ return p.tagText(p.scratch.allocator(), pane) catch "";
+}
+
+pub fn dirOf(pane: *Pane) []const u8 {
+ if (pane.file) |*f| return std.fs.path.dirname(f.path) orelse "/";
+ const cwd = pane.cwdSlice();
+ return if (cwd.len > 0) cwd else "/";
+}
+
+pub fn dirtyOf(pane: *const Pane) bool {
+ const f = if (pane.file) |*x| x else return false;
+ if (!panes.Output.fileTraits(f.output).saves) return false;
+ return f.revision != f.saved_revision;
+}
+
+/// The pane's name: its file, its document, or a terminal's directory.
+pub fn nameOf(pane: *const Pane) []const u8 {
+ if (pane.file) |*f| return f.path;
+ if (pane.pdfPath()) |path| return path;
+ if (pane.image) |state| return state.path;
+ const cwd = pane.cwdSlice();
+ return if (cwd.len > 0) cwd else "/";
+}
+
+pub fn kindOf(pane: *const Pane) []const u8 {
+ if (pane.file != null) return "text";
+ if (pane.pdfPath() != null) return "pdf";
+ if (pane.image != null) return "image";
+ return "term";
+}
+
+pub fn mtimeOf(p: *Pardes, pane: *const Pane) u32 {
+ if (pane.file) |*f| if (f.mtime != 0) return f.mtime;
+ return p.fs.started;
+}
+
+pub fn clip(n: usize) u32 {
+ return std.math.cast(u32, n) orelse std.math.maxInt(u32);
+}
+
+fn cellOf(row: i32, col: i32) modal.Cursor {
+ return .{ .row = @intCast(@max(0, row)), .col = @intCast(@max(0, col)) };
+}
+
+pub fn dotOf(pane: *Pane) State.Range {
+ const text = bodyOf(pane);
+ const head = modal.offsetAt(text, cellOf(pane.cur_row, pane.cur_col));
+ if (!pane.vsel.active) return .{ .q0 = clip(head), .q1 = clip(head) };
+ const anchor = modal.offsetAt(text, cellOf(pane.vsel.row, pane.vsel.col));
+ var hi = @max(head, anchor);
+ if (hi < text.len) hi = modal.nextGrapheme(text, hi);
+ return .{ .q0 = clip(@min(head, anchor)), .q1 = clip(hi) };
+}
+
+pub fn setDot(pane: *Pane, r: State.Range) void {
+ const text = bodyOf(pane);
+ const q0 = @min(@as(usize, r.q0), text.len);
+ const q1 = @max(q0, @min(@as(usize, r.q1), text.len));
+ const a = modal.positionAt(text, q0);
+ pane.vsel = .{ .active = q1 > q0, .row = @intCast(a.row), .col = @intCast(a.col), .explicit = true };
+ const h = modal.positionAt(text, if (q1 > q0) modal.prevGrapheme(text, q1) else q0);
+ pane.cur_row = @intCast(h.row);
+ pane.cur_col = @intCast(h.col);
+ pane.cur_pinned = true;
+ pane.sticky_col = -1;
+ pane.msel.active = false;
+ pane.ensureCursorVisible();
+}
+
+pub fn showOffset(pane: *Pane, off: usize) void {
+ const text = bodyOf(pane);
+ const c = modal.positionAt(text, @min(off, text.len));
+ pane.cur_row = @intCast(c.row);
+ pane.cur_col = @intCast(c.col);
+ pane.cur_pinned = true;
+ pane.sticky_col = -1;
+ pane.ensureCursorVisible();
+}
+
+pub fn clampAddr(pf: *State, len: usize) void {
+ const n = clip(len);
+ pf.addr.q0 = @min(pf.addr.q0, n);
+ pf.addr.q1 = @min(pf.addr.q1, n);
+ if (pf.limit) |*l| {
+ l.q0 = @min(l.q0, n);
+ l.q1 = @min(l.q1, n);
+ }
+}
+
+fn shiftBy(r: State.Range, at: u32, removed: u32, inserted: u32) State.Range {
+ return .{ .q0 = shiftOne(r.q0, at, removed, inserted), .q1 = shiftOne(r.q1, at, removed, inserted) };
+}
+
+fn shiftOne(v: u32, at: u32, removed: u32, inserted: u32) u32 {
+ if (v <= at) return v;
+ if (v <= at +| removed) return at +| inserted;
+ return v - removed +| inserted;
+}
+
+pub fn wholeUtf8(data: []const u8) usize {
+ var i = data.len;
+ var back: usize = 0;
+ while (i > 0 and back < 4) : (back += 1) {
+ i -= 1;
+ const c = data[i];
+ if (c < 0x80) return data.len;
+ if (c & 0xC0 == 0xC0) {
+ const need = std.unicode.utf8ByteSequenceLength(c) catch return data.len;
+ if (i + need <= data.len or i == 0) return data.len;
+ return i;
+ }
+ }
+ return data.len;
+}
+
+pub fn spliceBody(p: *Pardes, id: usize, pane: *Pane, q0: usize, q1: usize, bytes: []const u8) ?usize {
+ const f = fileOf(pane) orelse return null;
+ const take = if (bytes.len == 0) 0 else wholeUtf8(bytes);
+ const lo = @min(q0, f.content.len);
+ const hi = @max(lo, @min(q1, f.content.len));
+ const new = p.gpa.alloc(u8, f.content.len - (hi - lo) + take) catch return null;
+ @memcpy(new[0..lo], f.content[0..lo]);
+ @memcpy(new[lo..][0..take], bytes[0..take]);
+ @memcpy(new[lo + take ..], f.content[hi..]);
+ if (!p.fs.panes[id].nomark) panes.File.pushUndo(p, pane);
+ panes.File.setContent(p, f, new);
+ return take;
+}
+
+// ---- sizes reported by stat ----
+
+pub fn fileSize(p: *Pardes, id: usize, f: PaneFile) u64 {
+ const pane = p.panes[id] orelse return 0;
+ return switch (f) {
+ .body, .data, .xdata => bodyOf(pane).len,
+ .tag => tagOf(p, pane).len,
+ .name => nameOf(pane).len + 1,
+ .ctl => ctl: {
+ var buf: [ctl.pane_text_cap]u8 = undefined;
+ break :ctl ctl.paneText(p, pane, &buf).len;
+ },
+ .sel => sel: {
+ const d = dotOf(pane);
+ break :sel @max(d.q0, d.q1) - d.q0;
+ },
+ .dir, .addr, .errors, .event, .pty, .pty_ctl, .pty_status, .pty_data => 0,
+ };
+}
+
+// ---- the index ----
+
+pub fn stageIndexLine(p: *Pardes, out: *std.ArrayList(u8), pane: *Pane) void {
+ out.print(p.gpa, "{d} {s} {d} {s}\n", .{ pane.serial, kindOf(pane), @intFromBool(dirtyOf(pane)), nameOf(pane) }) catch {};
+}
+
+pub fn indexLen(p: *Pardes) u64 {
+ var n: u64 = 0;
+ var last: u32 = 0;
+ while (tree.nextSerialAfter(p, last)) |serial| {
+ last = serial;
+ const pane = p.panes[p.paneBySerial(serial).?].?;
+ var digits: [16]u8 = undefined;
+ n += (std.fmt.bufPrint(&digits, "{d}", .{serial}) catch unreachable).len;
+ n += 1 + kindOf(pane).len + 3 + nameOf(pane).len + 1;
+ }
+ return n;
+}
+
+// ---- reads ----
+
+pub fn read(p: *Pardes, req: Req, id: usize, pane: *Pane, file: PaneFile) Reply {
+ const pf = &p.fs.panes[id];
+ return switch (file) {
+ .name => name: {
+ const out = p.fs.stage(p.gpa);
+ out.appendSlice(p.gpa, nameOf(pane)) catch {};
+ out.append(p.gpa, '\n') catch {};
+ break :name tree.stagedReply(p, req);
+ },
+ .body => readBody(p, req, id, pane),
+ .tag => tag: {
+ const out = p.fs.stage(p.gpa);
+ out.appendSlice(p.gpa, tagOf(p, pane)) catch {};
+ break :tag tree.stagedReply(p, req);
+ },
+ .ctl => ctl.readPane(p, req, pane),
+ .addr => addr: {
+ clampAddr(pf, bodyOf(pane).len);
+ const out = p.fs.stage(p.gpa);
+ out.print(p.gpa, "{d:>11} {d:>11} ", .{ pf.addr.q0, pf.addr.q1 }) catch {};
+ break :addr tree.stagedReply(p, req);
+ },
+ .data => readData(req, id, pane, pf, false),
+ .xdata => readData(req, id, pane, pf, true),
+ .sel => readSel(req, id, pane),
+ .event => events.readQueue(p, req, &pf.events),
+ .pty_status => pty.readStatus(p, req, id, pane),
+ .pty_data => pty.readData(p, req, pf),
+ .dir, .errors, .pty, .pty_ctl => Reply.fail(req.tag, E.PERM),
+ };
+}
+
+fn readBody(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply {
+ if (pane.file != null) {
+ const text = bodyOf(pane);
+ const off = @min(req.off, text.len);
+ const n = @min(text.len - off, req.size);
+ return .{ .tag = req.tag, .payload = .{ .region = .{
+ .pane = @intCast(id),
+ .serial = pane.serial,
+ .off = clip(off),
+ .len = clip(n),
+ } } };
+ }
+ if (req.handle != 0 and pane.isTerminal()) return screen.readSnapshot(p, req, pane);
+ const text = panes.Terminal.screenTextAlloc(pane, p.gpa) catch
+ return Reply.fail(req.tag, E.NOMEM);
+ defer p.gpa.free(text);
+ const out = p.fs.stage(p.gpa);
+ out.appendSlice(p.gpa, text) catch return Reply.fail(req.tag, E.NOMEM);
+ return tree.stagedReply(p, req);
+}
+
+fn readData(req: Req, id: usize, pane: *Pane, pf: *State, stop_at_end: bool) Reply {
+ const text = bodyOf(pane);
+ clampAddr(pf, text.len);
+ const q0: usize = pf.addr.q0;
+ const hi: usize = if (stop_at_end) @max(q0, @as(usize, pf.addr.q1)) else text.len;
+ var end = @min(hi, q0 +| req.size);
+ end = @max(q0, modal.graphemeStart(text, end));
+ pf.addr.q0 = clip(end);
+ if (!stop_at_end) pf.addr.q1 = clip(end);
+ if (pane.file == null) return .{ .tag = req.tag };
+ return .{ .tag = req.tag, .payload = .{ .region = .{
+ .pane = @intCast(id),
+ .serial = pane.serial,
+ .off = clip(q0),
+ .len = clip(end - q0),
+ } } };
+}
+
+fn readSel(req: Req, id: usize, pane: *Pane) Reply {
+ if (pane.file == null) return .{ .tag = req.tag };
+ const text = bodyOf(pane);
+ const d = dotOf(pane);
+ const lo = @min(@as(usize, d.q0), text.len);
+ const hi = @max(lo, @min(@as(usize, d.q1), text.len));
+ const off = @min(req.off, hi - lo);
+ const n = @min(hi - lo - off, req.size);
+ return .{ .tag = req.tag, .payload = .{ .region = .{
+ .pane = @intCast(id),
+ .serial = pane.serial,
+ .off = clip(lo + off),
+ .len = clip(n),
+ } } };
+}
+
+// ---- writes ----
+
+pub fn write(p: *Pardes, req: Req, id: usize, pane: *Pane, file: PaneFile) Reply {
+ return switch (file) {
+ .name => writeName(p, req, id, pane),
+ .body => writeBody(p, req, id, pane),
+ .tag => writeTag(p, req, pane),
+ .ctl => ctl.writePane(p, req, pane.serial),
+ .addr => writeAddr(p, req, id, pane),
+ .data, .xdata => writeData(p, req, id, pane),
+ .sel => writeSel(p, req, id, pane),
+ .event => events.writeEvent(p, req, id),
+ .errors => if (appendErrors(p, id, req.data)) |took|
+ .{ .tag = req.tag, .written = @intCast(took) }
+ else
+ Reply.fail(req.tag, E.IO),
+ .pty_ctl => pty.writeCtl(p, req, id),
+ .pty_data => pty.writeData(p, req, id),
+ .dir, .pty, .pty_status => Reply.fail(req.tag, E.PERM),
+ };
+}
+
+fn writeBody(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply {
+ if (req.data.len == 0) return .{ .tag = req.tag, .written = 0 };
+ if (pane.file == null) {
+ const take = wholeUtf8(req.data);
+ p.emitWrite(id, req.data[0..take]);
+ return .{ .tag = req.tag, .written = @intCast(take) };
+ }
+ const at = bodyOf(pane).len;
+ const take = spliceBody(p, id, pane, at, at, req.data) orelse
+ return Reply.fail(req.tag, E.NOMEM);
+ if (!p.fs.panes[id].noscroll) showOffset(pane, at + take);
+ return .{ .tag = req.tag, .written = @intCast(take) };
+}
+
+fn writeTag(p: *Pardes, req: Req, pane: *Pane) Reply {
+ if (req.data.len == 0) return .{ .tag = req.tag, .written = 0 };
+ p.seedTail(pane);
+ const room = pane.tag_tail.len - pane.tag_tail_len;
+ if (room == 0) return Reply.fail(req.tag, E.NOSPC);
+ const take = wholeUtf8(req.data[0..@min(req.data.len, room)]);
+ @memcpy(pane.tag_tail[pane.tag_tail_len..][0..take], req.data[0..take]);
+ pane.tag_tail_len += take;
+ pane.tag_init = true;
+ return .{ .tag = req.tag, .written = @intCast(take) };
+}
+
+fn writeData(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply {
+ if (fileOf(pane) == null) return Reply.fail(req.tag, E.INVAL);
+ const pf = &p.fs.panes[id];
+ clampAddr(pf, bodyOf(pane).len);
+ const q0: usize = pf.addr.q0;
+ const q1: usize = @max(q0, @as(usize, pf.addr.q1));
+ const before = dotOf(pane);
+ const take = spliceBody(p, id, pane, q0, q1, req.data) orelse
+ return Reply.fail(req.tag, E.NOMEM);
+ setDot(pane, shiftBy(before, clip(q0), clip(q1 - q0), clip(take)));
+ pf.addr = .{ .q0 = clip(q0 + take), .q1 = clip(q0 + take) };
+ if (!pf.noscroll) showOffset(pane, q0 + take);
+ return .{ .tag = req.tag, .written = @intCast(take) };
+}
+
+fn writeSel(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply {
+ if (fileOf(pane) == null) return Reply.fail(req.tag, E.INVAL);
+ const d = dotOf(pane);
+ const q0: usize = d.q0;
+ const q1: usize = @max(q0, @as(usize, d.q1));
+ const take = spliceBody(p, id, pane, q0, q1, req.data) orelse
+ return Reply.fail(req.tag, E.NOMEM);
+ setDot(pane, .{ .q0 = clip(q0 + take), .q1 = clip(q0 + take) });
+ return .{ .tag = req.tag, .written = @intCast(take) };
+}
+
+fn writeAddr(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply {
+ const pf = &p.fs.panes[id];
+ const text = bodyOf(pane);
+ clampAddr(pf, text.len);
+ const expr = std.mem.trimEnd(u8, req.data, "\n");
+ var a: addressing.Addr = .{ .text = text, .lim = pf.limit, .expr = expr };
+ const r = a.address(pf.addr) orelse return Reply.fail(req.tag, E.INVAL);
+ if (a.i < expr.len) return Reply.fail(req.tag, E.INVAL);
+ pf.addr = r;
+ return .{ .tag = req.tag, .written = @intCast(req.data.len) };
+}
+
+/// Writing `name` renames the buffer: a relative name resolves against the
+/// pane's directory, as the tag's name field does.
+fn writeName(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply {
+ const name = std.mem.trim(u8, req.data, " \t\r\n");
+ if (name.len == 0) return Reply.fail(req.tag, E.INVAL);
+ for (name) |c| if (c < ' ') return Reply.fail(req.tag, E.INVAL);
+ if (fileOf(pane) == null) return Reply.fail(req.tag, E.PERM);
+ const full = std.fs.path.resolvePosix(p.scratch.allocator(), &.{ pardes.Pardes.paneDir(pane), name }) catch
+ return Reply.fail(req.tag, E.NOMEM);
+ if (!std.fs.path.isAbsolute(full) or full.len >= 4096) return Reply.fail(req.tag, E.INVAL);
+ nameBuffer(p, id, full, false) catch |err| return Reply.fail(req.tag, switch (err) {
+ error.OutOfMemory => E.NOMEM,
+ else => E.INVAL,
+ });
+ return .{ .tag = req.tag, .written = @intCast(req.data.len) };
+}
+
+/// Change a buffer's absolute name without writing or renaming a disk file.
+/// Tag editing preserves user commands; the filesystem's rename resets its tail.
+pub fn nameBuffer(p: *Pardes, id: usize, full: []const u8, preserve_tag: bool) !void {
+ const pane = p.panes[id] orelse return error.NoPane;
+ const f = fileOf(pane) orelse return error.NotFile;
+ if (!std.fs.path.isAbsolute(full) or full.len == 0 or full.len >= 4096)
+ return error.InvalidName;
+ for (full) |c| if (c < ' ') return error.InvalidName;
+ if (std.mem.eql(u8, f.path, full)) return;
+ const copy = try p.gpa.dupe(u8, full);
+ p.gpa.free(f.path);
+ f.path = copy;
+ if (panes.Output.fileTraits(f.output).saves) {
+ f.output = null;
+ pane.clearCwd();
+ if (!preserve_tag) {
+ pane.tag_init = false;
+ pane.tag_tail_len = 0;
+ }
+ f.saved_revision = f.revision -% 1;
+ f.watch_after_save = fs.localPath(full) != null;
+ }
+ if (f.highlights.len > 0) p.tree_sitter_gpa.free(f.highlights);
+ f.highlights = &.{};
+ f.highlight_start = 0;
+ f.syntax_dirty = true;
+ f.context_revision = null;
+ pane.context_rows = 0;
+ pane.wrap_n = 0;
+ p.invalidateLookHover(id);
+ p.emit(.{ .watch = .{ .pane = @intCast(id), .on = false } });
+ events.noteLog(p, .rename, pane);
+}
+
+/// Diagnostics go to the +Errors buffer of the pane's directory.
+pub fn appendErrors(p: *Pardes, id: usize, text: []const u8) ?usize {
+ if (text.len == 0) return 0;
+ const pane = p.panes[id] orelse return null;
+ const dir = dirOf(pane);
+ for (p.panes, 0..) |slot, i| {
+ const q = slot orelse continue;
+ const qf = fileOf(q) orelse continue;
+ const o = qf.output orelse continue;
+ if (std.meta.activeTag(o.from) != .errors) continue;
+ if (!std.mem.eql(u8, std.fs.path.dirname(qf.path) orelse "", dir)) continue;
+ return spliceBody(p, i, q, qf.content.len, qf.content.len, text);
+ }
+ const free = p.freeSlot() orelse return null;
+ const content = p.gpa.dupe(u8, text) catch return null;
+ const np = panes.Output.open(p, free, dir, .errors, "", content) catch {
+ p.gpa.free(content);
+ return null;
+ };
+ p.placeDoc(id, free, np);
+ return text.len;
+}
+
+/// Truncation empties a body; every other setattr just answers fresh attributes.
+pub fn truncate(p: *Pardes, id: usize, pane: *Pane, file: PaneFile) tree.Status {
+ switch (file) {
+ .body, .data, .xdata => if (fileOf(pane) != null) {
+ _ = spliceBody(p, id, pane, 0, bodyOf(pane).len, "") orelse return .err;
+ p.fs.panes[id].addr = .{};
+ setDot(pane, .{});
+ },
+ else => {},
+ }
+ return .ok;
+}
+
+// ---- tests ----
+
+const testing = std.testing;
+const th = @import("testing.zig");
+const call = th.call;
+const rd = th.rd;
+const wr = th.wr;
+const withFile = th.withFile;
+const serialOf = th.serialOf;
+const Status = tree.Status;
+
+test "index lists serial kind dirty flag and name and stat reports its length" {
+ const gpa = testing.allocator;
+ const p = try withFile(gpa, "hello\nthere\n");
+ defer p.deinit();
+ const pane = p.panes[0].?;
+ const index = @intFromEnum(tree.TopFile.index);
+
+ const a = rd(p, index, 0, 4096);
+ try testing.expectEqual(Status.ok, a.reply.status);
+ var want: [64]u8 = undefined;
+ try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "{d} text 0 /test.txt\n", .{pane.serial}), a.bytes);
+ const stat = call(p, .{ .tag = 1, .op = .getattr, .node = index });
+ try testing.expectEqual(@as(u64, a.bytes.len), stat.reply.attr.size);
+ try testing.expectEqual(@as(u16, 0o444), stat.reply.attr.mode);
+
+ pane.file.?.saved_revision = pane.file.?.revision -% 1;
+ try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "{d} text 1 /test.txt\n", .{pane.serial}), rd(p, index, 0, 4096).bytes);
+ const mid = rd(p, index, 2, 4);
+ try testing.expectEqualStrings("text", mid.bytes);
+
+ const serial = try th.newPane(p);
+ const both = rd(p, index, 0, 4096);
+ try testing.expectEqual(@as(u64, both.bytes.len), indexLen(p));
+ var expected: [4200]u8 = undefined;
+ const made = p.panes[p.paneBySerial(serial).?].?;
+ try testing.expect(std.mem.endsWith(u8, both.bytes, try std.fmt.bufPrint(&expected, "\n{d} text 0 {s}\n", .{ serial, nameOf(made) })));
+}
+
+test "a terminal is listed as term with its directory as name" {
+ const p = try th.withTerm(testing.allocator);
+ defer p.deinit();
+ const pane = p.panes[0].?;
+ p.setCwd(0, "/work/dir");
+ var want: [64]u8 = undefined;
+ try testing.expectEqualStrings(
+ try std.fmt.bufPrint(&want, "{d} term 0 /work/dir\n", .{pane.serial}),
+ rd(p, @intFromEnum(tree.TopFile.index), 0, 4096).bytes,
+ );
+ try testing.expectEqualStrings("/work/dir\n", rd(p, Node.of(pane.serial, .name), 0, 4096).bytes);
+ try testing.expectEqual(E.PERM, wr(p, Node.of(pane.serial, .name), "/elsewhere\n").errno());
+}
+
+test "body reads at any offset and writes append" {
+ const gpa = testing.allocator;
+ const p = try withFile(gpa, "one\ntwo\n");
+ defer p.deinit();
+ const serial = serialOf(p);
+ const body = Node.of(serial, .body);
+
+ try testing.expectEqualStrings("one\ntwo\n", rd(p, body, 0, 100).bytes);
+ try testing.expectEqualStrings("two\n", rd(p, body, 4, 100).bytes);
+ try testing.expectEqualStrings("wo", rd(p, body, 5, 2).bytes);
+ try testing.expectEqualStrings("", rd(p, body, 999, 2).bytes);
+ try testing.expect(rd(p, body, 0, 100).bytes.ptr == p.panes[0].?.file.?.content.ptr);
+
+ const w = call(p, .{ .tag = 5, .op = .write, .node = body, .off = 0, .data = "three\n" });
+ try testing.expectEqual(@as(u32, 6), w.reply.written);
+ try testing.expectEqualStrings("one\ntwo\nthree\n", p.panes[0].?.file.?.content);
+
+ const short = wr(p, body, "a\xC3");
+ try testing.expectEqual(@as(u32, 1), short.reply.written);
+ try testing.expectEqualStrings("one\ntwo\nthree\na", p.panes[0].?.file.?.content);
+
+ const stat = call(p, .{ .tag = 6, .op = .getattr, .node = body });
+ try testing.expectEqual(@as(u64, "one\ntwo\nthree\na".len), stat.reply.attr.size);
+ try testing.expectEqual(@as(u16, 0o666), stat.reply.attr.mode);
+ try testing.expect(stat.reply.attr.mtime >= p.fs.started);
+}
+
+test "a body write to a terminal pane types at its shell" {
+ const gpa = testing.allocator;
+ const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 40, .rows = 10 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = p.panes[0].?;
+ try testing.expect(pane.isTerminal());
+
+ const a = wr(p, Node.of(pane.serial, .body), "ls -l\r");
+ try testing.expectEqual(@as(u32, 6), a.reply.written);
+ try testing.expectEqualStrings("ls -l\r", a.pty());
+
+ const r = rd(p, Node.of(pane.serial, .body), 0, 64);
+ try testing.expectEqual(Status.ok, r.reply.status);
+}
+
+test "tag reads the whole tag and writes append to the editable tail" {
+ const gpa = testing.allocator;
+ const p = try withFile(gpa, "x\n");
+ defer p.deinit();
+ const pane = p.panes[0].?;
+ const node = Node.of(pane.serial, .tag);
+
+ const whole = rd(p, node, 0, 4096);
+ try testing.expect(std.mem.startsWith(u8, whole.bytes, "/test.txt"));
+ try testing.expect(std.mem.indexOf(u8, whole.bytes, "Del") != null);
+ try testing.expectEqual(@as(u64, whole.bytes.len), call(p, .{ .tag = 1, .op = .getattr, .node = node }).reply.attr.size);
+
+ const before = rd(p, node, 0, 4096).bytes.len;
+ const w = wr(p, node, " Mine");
+ try testing.expectEqual(@as(u32, 5), w.reply.written);
+ try testing.expect(std.mem.endsWith(u8, pane.tag_tail[0..pane.tag_tail_len], " Mine"));
+ const after = rd(p, node, 0, 4096);
+ try testing.expectEqual(before + 5, after.bytes.len);
+ try testing.expect(std.mem.endsWith(u8, after.bytes, " Mine"));
+
+ pane.tag_tail_len = pane.tag_tail.len;
+ try testing.expectEqual(E.NOSPC, wr(p, node, "x").errno());
+}
+
+test "data and xdata read from addr, move it, and write through it" {
+ const gpa = testing.allocator;
+ const p = try withFile(gpa, "one\ntwo\n");
+ defer p.deinit();
+ const serial = serialOf(p);
+ const addr = Node.of(serial, .addr);
+ const data = Node.of(serial, .data);
+ const xdata = Node.of(serial, .xdata);
+
+ _ = wr(p, addr, "#0");
+ try testing.expectEqualStrings("one", rd(p, data, 0, 3).bytes);
+ try testing.expectEqual(@as(u32, 3), p.fs.panes[0].addr.q0);
+ try testing.expectEqual(@as(u32, 3), p.fs.panes[0].addr.q1);
+
+ _ = wr(p, addr, "1");
+ try testing.expectEqualStrings("one\n", rd(p, xdata, 0, 100).bytes);
+ _ = wr(p, addr, "1");
+ try testing.expectEqualStrings("one\ntwo\n", rd(p, data, 0, 100).bytes);
+
+ _ = wr(p, addr, "1");
+ const w = wr(p, data, "ONE\n");
+ try testing.expectEqual(@as(u32, 4), w.reply.written);
+ try testing.expectEqualStrings("ONE\ntwo\n", p.panes[0].?.file.?.content);
+ try testing.expectEqual(@as(u32, 4), p.fs.panes[0].addr.q0);
+}
+
+test "data never splits a grapheme, in either direction" {
+ const gpa = testing.allocator;
+ const p = try withFile(gpa, "\u{00e9}x\n");
+ defer p.deinit();
+ const serial = serialOf(p);
+ _ = wr(p, Node.of(serial, .addr), "#0");
+ try testing.expectEqualStrings("", rd(p, Node.of(serial, .data), 0, 1).bytes);
+ _ = wr(p, Node.of(serial, .addr), "#0");
+ try testing.expectEqualStrings("\u{00e9}", rd(p, Node.of(serial, .data), 0, 2).bytes);
+
+ _ = wr(p, Node.of(serial, .addr), "#0");
+ try testing.expectEqual(@as(u32, 1), wr(p, Node.of(serial, .data), "a\xC3").reply.written);
+}
+
+test "sel reads the selection and writing sel replaces it" {
+ const gpa = testing.allocator;
+ const p = try withFile(gpa, "one\ntwo\n");
+ defer p.deinit();
+ const serial = serialOf(p);
+ const ctl_node = Node.of(serial, .ctl);
+ const sel = Node.of(serial, .sel);
+
+ _ = wr(p, Node.of(serial, .addr), "#0,#3");
+ try testing.expectEqual(Status.ok, wr(p, ctl_node, "dot=addr\n").reply.status);
+ try testing.expectEqualStrings("one", rd(p, sel, 0, 100).bytes);
+ try testing.expectEqual(@as(u64, 3), call(p, .{ .tag = 1, .op = .getattr, .node = sel }).reply.attr.size);
+
+ _ = wr(p, ctl_node, "addr=dot\n");
+ try testing.expectEqual(@as(u32, 0), p.fs.panes[0].addr.q0);
+ try testing.expectEqual(@as(u32, 3), p.fs.panes[0].addr.q1);
+
+ try testing.expectEqual(Status.ok, wr(p, sel, "ONE").reply.status);
+ try testing.expectEqualStrings("ONE\ntwo\n", p.panes[0].?.file.?.content);
+ _ = wr(p, sel, "!");
+ try testing.expectEqualStrings("ONE!\ntwo\n", p.panes[0].?.file.?.content);
+ try testing.expectEqual(@as(u64, 0), call(p, .{ .tag = 2, .op = .getattr, .node = sel }).reply.attr.size);
+}
+
+test "name reads the file name and writing it promotes a scratch without touching its body" {
+ const gpa = testing.allocator;
+ const p = try withFile(gpa, "opener\n");
+ defer p.deinit();
+ const serial = try th.newPane(p);
+ const pane = p.panes[p.paneBySerial(serial).?].?;
+ const name = Node.of(serial, .name);
+ const body = Node.of(serial, .body);
+ var scratch_name: [4200]u8 = undefined;
+ const scratch_line = try std.fmt.bufPrint(&scratch_name, "{s}\n", .{nameOf(pane)});
+ try testing.expectEqualStrings(scratch_line, rd(p, name, 0, 4096).bytes);
+ try testing.expectEqual(@as(u64, scratch_line.len), call(p, .{ .tag = 1, .op = .getattr, .node = name }).reply.attr.size);
+ _ = wr(p, body, "scratch ");
+ _ = wr(p, body, "work\n");
+ const undo_len = pane.file.?.history.undo_len;
+ var tmp = testing.tmpDir(.{});
+ defer tmp.cleanup();
+ try tmp.dir.writeFile(testing.io, .{ .sub_path = "renamed.zig", .data = "existing target\n" });
+ var directory_buf: [4096]u8 = undefined;
+ const directory = directory_buf[0..try tmp.dir.realPath(testing.io, &directory_buf)];
+ var path_buffer: [4096]u8 = undefined;
+ const path = try std.fmt.bufPrint(&path_buffer, "{s}/renamed.zig", .{directory});
+ var line: [4104]u8 = undefined;
+ const renamed = wr(p, name, try std.fmt.bufPrint(&line, "{s}\n", .{path}));
+ try testing.expectEqual(Status.ok, renamed.reply.status);
+ try testing.expectEqual(false, renamed.watch.?);
+ try testing.expectEqualStrings("scratch work\n", pane.file.?.content);
+ try testing.expectEqual(undo_len, pane.file.?.history.undo_len);
+ try testing.expect(pane.file.?.output == null and dirtyOf(pane));
+ try testing.expect(pane.file.?.watch_after_save);
+ try testing.expectEqualStrings(path, std.mem.trimEnd(u8, rd(p, name, 0, 4096).bytes, "\n"));
+ const target = try fs.readFile(gpa, path);
+ defer gpa.free(target);
+ try testing.expectEqualStrings("existing target\n", target);
+ for ([_][]const u8{ "", "\n", "bad\x01name\n" }) |bad|
+ try testing.expectEqual(E.INVAL, wr(p, name, bad).errno());
+ try testing.expectEqualStrings(path, pane.file.?.path);
+ try testing.expectEqual(Status.ok, wr(p, name, "two words.zig\n").reply.status);
+ const spaced = try std.fmt.bufPrint(&path_buffer, "{s}/two words.zig", .{directory});
+ try testing.expectEqualStrings(spaced, pane.file.?.path);
+}
+
+test "name refreshes cached syntax for unchanged contents" {
+ if (!pardes.syntax.enabled) return error.SkipZigTest;
+ pardes.syntax.start(testing.allocator);
+ defer pardes.syntax.stop();
+ const p = try withFile(testing.allocator, "fn check() void {}\n");
+ defer p.deinit();
+ const pane = p.panes[0].?;
+ panes.File.refreshHighlights(p);
+ try testing.expectEqual(@as(usize, 0), pane.file.?.highlights.len);
+ try testing.expectEqual(Status.ok, wr(p, Node.of(pane.serial, .name), "renamed.zig\n").reply.status);
+ panes.File.refreshHighlights(p);
+ try testing.expect(pane.file.?.highlights.len >= 2);
+ try testing.expectEqual(@intFromEnum(pardes.syntax.Syn.keyword), pane.file.?.highlights[0]);
+ try testing.expectEqual(@intFromEnum(pardes.syntax.Syn.keyword), pane.file.?.highlights[1]);
+ try testing.expectEqualStrings("fn check() void {}\n", pane.file.?.content);
+}
+
+test "relative names use the file directory and can name a new target" {
+ const gpa = testing.allocator;
+ const p = try withFile(gpa, "retained body\n");
+ defer p.deinit();
+ var tmp = testing.tmpDir(.{});
+ defer tmp.cleanup();
+ try tmp.dir.writeFile(testing.io, .{ .sub_path = "note.txt", .data = "from pane directory\n" });
+ var directory_buf: [4096]u8 = undefined;
+ const directory = directory_buf[0..try tmp.dir.realPath(testing.io, &directory_buf)];
+ const pane = p.panes[0].?;
+ const name = Node.of(pane.serial, .name);
+ const ctl_node = Node.of(pane.serial, .ctl);
+ var line: [4140]u8 = undefined;
+ try testing.expectEqual(Status.ok, wr(p, name, try std.fmt.bufPrint(&line, "{s}/old.txt\n", .{directory})).reply.status);
+ try testing.expectEqual(Status.ok, wr(p, name, "child/../note.txt\n").reply.status);
+ try testing.expectEqual(Status.ok, wr(p, ctl_node, "get\n").reply.status);
+ try testing.expectEqualStrings("from pane directory\n", pane.file.?.content);
+ try testing.expectEqualStrings(directory, Pardes.paneDir(pane));
+ try testing.expectEqual(Status.ok, wr(p, name, "./created.txt\n").reply.status);
+ var expected_buf: [4096]u8 = undefined;
+ const expected = try std.fmt.bufPrint(&expected_buf, "{s}/created.txt", .{directory});
+ try testing.expectEqualStrings(expected, pane.file.?.path);
+ try testing.expect(dirtyOf(pane));
+}
+
+test "relative names follow inherited scratch and virtual directories" {
+ const p = try withFile(testing.allocator, "source body\n");
+ defer p.deinit();
+ const source = p.panes[0].?;
+ try testing.expectEqual(Status.ok, wr(p, Node.of(source.serial, .name), "/project/src/source.zig\n").reply.status);
+ const serial = try th.newPane(p);
+ const scratch = p.panes[p.paneBySerial(serial).?].?;
+ const name = Node.of(serial, .name);
+ try testing.expectEqualStrings("/project/src", Pardes.paneDir(scratch));
+ try testing.expectEqual(Status.ok, wr(p, name, "../out/./notes.txt\n").reply.status);
+ try testing.expectEqualStrings("/project/out/notes.txt", scratch.file.?.path);
+ try testing.expectEqualStrings("/project/out", Pardes.paneDir(scratch));
+ var line: [256]u8 = undefined;
+ try testing.expectEqual(Status.ok, wr(p, name, try std.fmt.bufPrint(&line, "/virtual/pane/{d}/./body\n", .{source.serial})).reply.status);
+ try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .ctl), "get\n").reply.status);
+ try testing.expectEqualStrings(source.file.?.content, scratch.file.?.content);
+ const unchanged = wr(p, name, "./body\n");
+ try testing.expectEqual(Status.ok, unchanged.reply.status);
+ try testing.expect(unchanged.watch == null);
+ try testing.expect(!dirtyOf(scratch));
+ try testing.expect(!scratch.file.?.watch_after_save);
+}
+
+test "owned cwd name promotion releases the former directory" {
+ const p = try th.withTerm(testing.allocator);
+ defer p.deinit();
+ p.newScratchBelow(0);
+ const id = p.active;
+ const pane = p.panes[id].?;
+ try pane.setOwnedCwd("/old/directory");
+ const result = wr(p, Node.of(pane.serial, .name), "saved.txt\n");
+ try testing.expectEqual(Status.ok, result.reply.status);
+ try testing.expect(pane.cwd == .none);
+ try testing.expect(pane.file.?.output == null);
+ try testing.expectEqualStrings("/old/directory/saved.txt", pane.file.?.path);
+ try testing.expectEqualStrings("/old/directory", Pardes.paneDir(pane));
+}
+
+test "errors append to one +Errors buffer per directory" {
+ const gpa = testing.allocator;
+ const p = try withFile(gpa, "x\n");
+ defer p.deinit();
+ const serial = serialOf(p);
+
+ const live = for (p.panes) |slot| {
+ if (slot) |q| if (q.file) |f| if (f.output) |o| if (std.meta.activeTag(o.from) == .errors) break q;
+ } else null;
+ try testing.expect(live == null);
+
+ try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .errors), "boom\n").reply.status);
+ try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .errors), "again\n").reply.status);
+
+ var found: usize = 0;
+ for (p.panes) |slot| {
+ const q = slot orelse continue;
+ const f = q.file orelse continue;
+ const o = f.output orelse continue;
+ if (std.meta.activeTag(o.from) != .errors) continue;
+ found += 1;
+ try testing.expectEqualStrings("boom\nagain\n", f.content);
+ try testing.expectEqualStrings("/+Errors", f.path);
+ }
+ try testing.expectEqual(@as(usize, 1), found);
+}
+
+test "setattr truncation empties the body and answers fresh attributes" {
+ const gpa = testing.allocator;
+ const p = try withFile(gpa, "one\ntwo\n");
+ defer p.deinit();
+ const serial = serialOf(p);
+
+ const a = call(p, .{ .tag = 7, .op = .setattr, .node = Node.of(serial, .body), .truncate = true });
+ try testing.expectEqual(Status.ok, a.reply.status);
+ try testing.expectEqual(@as(u64, 0), a.reply.attr.size);
+ try testing.expectEqualStrings("", p.panes[0].?.file.?.content);
+
+ _ = wr(p, Node.of(serial, .body), "new text\n");
+ try testing.expectEqualStrings("new text\n", p.panes[0].?.file.?.content);
+
+ const noop = call(p, .{ .tag = 8, .op = .setattr, .node = Node.of(serial, .body) });
+ try testing.expectEqual(@as(u64, 9), noop.reply.attr.size);
+}
diff --git a/src/ninep/pty.zig b/src/ninep/pty.zig
new file mode 100644
index 00000000..e6696b85
--- /dev/null
+++ b/src/ninep/pty.zig
@@ -0,0 +1,388 @@
+//! A terminal pane's pty/: ctl (winsize, sig, exec), status and the raw data stream.
+const std = @import("std");
+const pardes = @import("../pardes.zig");
+const tree = @import("tree.zig");
+const pane_files = @import("pane.zig");
+
+const Pardes = pardes.Pardes;
+const Pane = pardes.Pane;
+const Req = tree.Req;
+const Reply = tree.Reply;
+const E = tree.E;
+const PaneFile = tree.PaneFile;
+
+pub fn fileNamed(name: []const u8) ?PaneFile {
+ if (std.mem.eql(u8, name, "ctl")) return .pty_ctl;
+ if (std.mem.eql(u8, name, "status")) return .pty_status;
+ if (std.mem.eql(u8, name, "data")) return .pty_data;
+ return null;
+}
+
+const Verb = enum { winsize, sig, exec };
+
+fn dimension(word: []const u8) ?u16 {
+ if (word.len == 0 or word.len > 5) return null;
+ for (word) |c| if (c < '0' or c > '9') return null;
+ const n = std.fmt.parseInt(u16, word, 10) catch return null;
+ return if (n == 0) null else n;
+}
+
+fn signalNamed(word: []const u8) ?pardes.PtySignal {
+ if (std.mem.eql(u8, word, "INT")) return .int;
+ if (std.mem.eql(u8, word, "TERM")) return .term;
+ if (std.mem.eql(u8, word, "HUP")) return .hup;
+ if (std.mem.eql(u8, word, "QUIT")) return .quit;
+ if (std.mem.eql(u8, word, "KILL")) return .kill;
+ return null;
+}
+
+pub fn writeCtl(p: *Pardes, req: Req, id: usize) Reply {
+ for ([2]bool{ false, true }) |apply| {
+ var it = std.mem.splitScalar(u8, req.data, '\n');
+ while (it.next()) |raw| {
+ const line = std.mem.trim(u8, raw, " \t\r");
+ if (line.len == 0) continue;
+ if (!verb(p, id, line, apply)) return Reply.fail(req.tag, E.INVAL);
+ }
+ }
+ return .{ .tag = req.tag, .written = @intCast(req.data.len) };
+}
+
+fn verb(p: *Pardes, id: usize, line: []const u8, apply: bool) bool {
+ const pane = p.panes[id] orelse return false;
+ var words = std.mem.tokenizeAny(u8, line, " \t");
+ const v = std.meta.stringToEnum(Verb, words.next() orelse return false) orelse return false;
+ switch (v) {
+ .winsize => {
+ const cols = dimension(words.next() orelse return false) orelse return false;
+ const rows = dimension(words.next() orelse return false) orelse return false;
+ if (words.next() != null) return false;
+ if (!apply) return true;
+ p.emit(.{ .resize_pty = .{ .pane = @intCast(id), .cols = cols, .rows = rows } });
+ },
+ .sig => {
+ const which = signalNamed(words.next() orelse return false) orelse return false;
+ if (words.next() != null) return false;
+ if (!apply) return true;
+ p.emit(.{ .signal_pty = .{ .pane = @intCast(id), .sig = which } });
+ },
+ .exec => {
+ if (words.next() != null) return false;
+ if (pane.cwdSlice().len > pardes.effect_path_cap) return false;
+ if (!apply) return true;
+ p.emit(.{ .spawn = .{ .pane = @intCast(id), .cwd = .from(pane.cwdSlice()) } });
+ },
+ }
+ return true;
+}
+
+pub fn readStatus(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply {
+ const out = p.fs.stage(p.gpa);
+ out.print(p.gpa, "{d:>11} {d:>11} {d:>11} ", .{
+ pane.cols,
+ pane.rows,
+ @intFromBool(p.hostTtyTaken(id)),
+ }) catch {};
+ return tree.stagedReply(p, req);
+}
+
+pub fn readData(p: *Pardes, req: Req, pf: *pane_files.State) Reply {
+ if (pf.pty_out.empty()) return .{ .tag = req.tag, .status = .again };
+ const out = p.fs.stage(p.gpa);
+ while (out.items.len < req.size) {
+ const chunk = pf.pty_out.peek() orelse break;
+ const n = @min(chunk.len, req.size - out.items.len);
+ out.appendSlice(p.gpa, chunk[0..n]) catch break;
+ pf.pty_out.popFront(n);
+ }
+ return .{ .tag = req.tag, .payload = .{ .staged = @intCast(out.items.len) } };
+}
+
+pub fn writeData(p: *Pardes, req: Req, id: usize) Reply {
+ if (req.data.len == 0) return .{ .tag = req.tag, .written = 0 };
+ const take = pane_files.wholeUtf8(req.data);
+ p.emitWrite(id, req.data[0..take]);
+ return .{ .tag = req.tag, .written = @intCast(take) };
+}
+
+// ---- tests ----
+
+const testing = std.testing;
+const th = @import("testing.zig");
+const events = @import("events.zig");
+const call = th.call;
+const rd = th.rd;
+const wr = th.wr;
+const rdir = th.rdir;
+const look_up = th.look_up;
+const withFile = th.withFile;
+const withTerm = th.withTerm;
+const serialOf = th.serialOf;
+const Node = tree.Node;
+const Status = tree.Status;
+
+test "a pane that is not a terminal has no pty/ at all" {
+ const gpa = testing.allocator;
+ const p = try withFile(gpa, "hello\n");
+ defer p.deinit();
+ const serial = serialOf(p);
+ const dir = Node.of(serial, .dir);
+
+ try testing.expectEqual(E.NOENT, look_up(p, dir, "pty").errno());
+ try testing.expectEqual(E.NOENT, call(p, .{
+ .tag = 1,
+ .op = .getattr,
+ .node = Node.of(serial, .pty),
+ }).errno());
+ try testing.expectEqual(E.NOENT, rd(p, Node.of(serial, .pty_status), 0, 256).errno());
+ try testing.expectEqual(E.NOENT, wr(p, Node.of(serial, .pty_ctl), "winsize 80 24\n").errno());
+ try testing.expectEqual(E.NOENT, rdir(p, Node.of(serial, .pty), 0).errno());
+ try testing.expectEqual(E.NOENT, call(p, .{
+ .tag = 2,
+ .op = .open,
+ .node = Node.of(serial, .pty_data),
+ }).errno());
+ try testing.expectEqual(@as(u16, 0), p.fs.panes[0].pty_readers);
+
+ var buf: [32]th.Dirent = undefined;
+ const files = th.dirents(rdir(p, dir, 0).bytes, &buf);
+ try testing.expectEqual(@as(usize, 10), files.len);
+ try testing.expect(th.nameAt(files, "pty") == null);
+
+ try testing.expectEqual(E.NOENT, look_up(p, dir, "pty_ctl").errno());
+ try testing.expectEqual(E.NOENT, look_up(p, dir, "status").errno());
+}
+
+test "a terminal pane's pty/ holds exactly ctl, status and data" {
+ const gpa = testing.allocator;
+ const p = try withTerm(gpa);
+ defer p.deinit();
+ const serial = serialOf(p);
+ const dir = Node.of(serial, .dir);
+
+ const pty = look_up(p, dir, "pty");
+ try testing.expectEqual(Node.of(serial, .pty), pty.reply.attr.node);
+ try testing.expect(pty.reply.attr.dir);
+ try testing.expectEqual(@as(u16, 0o755), pty.reply.attr.mode);
+
+ var buf: [32]th.Dirent = undefined;
+ const files = th.dirents(rdir(p, dir, 0).bytes, &buf);
+ try testing.expectEqual(@as(usize, 11), files.len);
+ try testing.expect(th.nameAt(files, "pty").?.dir);
+
+ const inside = th.dirents(rdir(p, Node.of(serial, .pty), 0).bytes, &buf);
+ try testing.expectEqual(@as(usize, 3), inside.len);
+ try testing.expectEqualStrings("ctl", inside[0].name);
+ try testing.expectEqualStrings("status", inside[1].name);
+ try testing.expectEqualStrings("data", inside[2].name);
+ for (inside) |d| try testing.expect(!d.dir);
+ try testing.expectEqual(Node.of(serial, .pty_data), inside[2].node);
+
+ const ctl = look_up(p, Node.of(serial, .pty), "ctl");
+ try testing.expectEqual(Node.of(serial, .pty_ctl), ctl.reply.attr.node);
+ try testing.expectEqual(@as(u16, 0o222), ctl.reply.attr.mode);
+ try testing.expectEqual(@as(u16, 0o444), look_up(p, Node.of(serial, .pty), "status").reply.attr.mode);
+ try testing.expectEqual(E.NOENT, look_up(p, Node.of(serial, .pty), "body").errno());
+ try testing.expectEqual(E.NOENT, look_up(p, Node.of(serial, .pty), "pty").errno());
+
+ try testing.expectEqual(E.NOTDIR, look_up(p, Node.of(serial, .pty_ctl), "x").errno());
+ try testing.expectEqual(E.NOTDIR, rdir(p, Node.of(serial, .pty_ctl), 0).errno());
+ try testing.expectEqual(E.PERM, rd(p, Node.of(serial, .pty), 0, 16).errno());
+ try testing.expectEqual(E.PERM, rd(p, Node.of(serial, .pty_ctl), 0, 16).errno());
+ try testing.expectEqual(E.PERM, wr(p, Node.of(serial, .pty_status), "x").errno());
+}
+
+test "every pty/ctl verb, and every refusal" {
+ const gpa = testing.allocator;
+ const p = try withTerm(gpa);
+ defer p.deinit();
+ const ctl = Node.of(serialOf(p), .pty_ctl);
+
+ const pane = p.panes[0].?;
+ const cols = pane.cols;
+ const rows = pane.rows;
+ const ws = wr(p, ctl, "winsize 132 44\n");
+ try testing.expectEqual(@as(u32, "winsize 132 44\n".len), ws.reply.written);
+ try testing.expectEqual(@as(u16, 132), ws.winsize.?.cols);
+ try testing.expectEqual(@as(u16, 44), ws.winsize.?.rows);
+ try testing.expectEqual(cols, pane.cols);
+ try testing.expectEqual(rows, pane.rows);
+
+ for ([_]struct { line: []const u8, want: pardes.PtySignal }{
+ .{ .line = "sig INT", .want = .int },
+ .{ .line = "sig TERM", .want = .term },
+ .{ .line = "sig HUP", .want = .hup },
+ .{ .line = "sig QUIT", .want = .quit },
+ .{ .line = "sig KILL", .want = .kill },
+ }) |c| {
+ const a = wr(p, ctl, c.line);
+ try testing.expectEqual(Status.ok, a.reply.status);
+ try testing.expectEqual(c.want, a.signal.?);
+ }
+
+ const ex = wr(p, ctl, "exec\n");
+ try testing.expectEqual(Status.ok, ex.reply.status);
+ try testing.expect(ex.spawned);
+
+ const both = wr(p, ctl, "winsize 100 30\nsig TERM");
+ try testing.expectEqual(@as(u16, 100), both.winsize.?.cols);
+ try testing.expectEqual(pardes.PtySignal.term, both.signal.?);
+
+ for ([_][]const u8{
+ "winsize", // no arguments
+ "winsize 80", // one argument
+ "winsize 80 24 extra", // three
+ "winsize 0 24", // zero is "unknown", never a width
+ "winsize 80 0",
+ "winsize -1 24", // not a decimal
+ "winsize 999999 24", // wider than a u16
+ "sig", // no name
+ "sig INT TERM", // two
+ "sig SIGINT",
+ "sig int", // lower case
+ "sig 9", // a number is one platform's number
+ "sig USR1", // a real signal, deliberately not offered
+ "exec /bin/sh", // the effect carries no argv; refused, never ignored
+ "raw",
+ "cooked",
+ "winsize 80 24\nbogus", // a good verb beside a bad one
+ "bogus\nwinsize 80 24",
+ "look x", // a `ctl` verb; the two files share no vocabulary
+ "exec Del",
+ }) |bad| {
+ const a = wr(p, ctl, bad);
+ try testing.expectEqual(E.INVAL, a.errno());
+ try testing.expect(a.winsize == null);
+ try testing.expect(a.signal == null);
+ try testing.expect(!a.spawned);
+ }
+
+ const spaced = wr(p, ctl, "\n winsize 90 20 \n\n");
+ try testing.expectEqual(Status.ok, spaced.reply.status);
+ try testing.expectEqual(@as(u16, 90), spaced.winsize.?.cols);
+ try testing.expectEqual(Status.ok, wr(p, ctl, "").reply.status);
+}
+
+test "owned cwd pty exec rejects long paths before applying its batch" {
+ const p = try withTerm(testing.allocator);
+ defer p.deinit();
+ const pane = p.panes[0].?;
+ const ctl = Node.of(pane.serial, .pty_ctl);
+ var path: [1025]u8 = @splat('d');
+ path[0] = '/';
+ p.setCwd(0, &path);
+ for ([_][]const u8{ "exec\n", "winsize 100 30\nexec\n", "sig TERM\nexec\n" }) |command| {
+ const result = wr(p, ctl, command);
+ try testing.expectEqual(E.INVAL, result.errno());
+ try testing.expect(!result.spawned);
+ try testing.expect(result.signal == null and result.winsize == null);
+ }
+ try testing.expectEqualStrings(&path, pane.cwdSlice());
+ p.setCwd(0, path[0..pardes.effect_path_cap]);
+ const accepted = wr(p, ctl, "exec\n");
+ try testing.expectEqual(Status.ok, accepted.reply.status);
+ try testing.expect(accepted.spawned);
+}
+
+test "pty/status reports the grid and who holds the tty" {
+ const gpa = testing.allocator;
+ const p = try withTerm(gpa);
+ defer p.deinit();
+ const pane = p.panes[0].?;
+ const status = Node.of(pane.serial, .pty_status);
+
+ const a = rd(p, status, 0, 256);
+ try testing.expectEqual(Status.ok, a.reply.status);
+ var want: [64]u8 = undefined;
+ const whole = try std.fmt.bufPrint(&want, "{d:>11} {d:>11} {d:>11} ", .{ pane.cols, pane.rows, 0 });
+ try testing.expectEqualStrings(whole, a.bytes);
+ try testing.expectEqual(@as(usize, 3 * 12), a.bytes.len);
+ try testing.expectEqualStrings(whole[12..], rd(p, status, 12, 256).bytes);
+
+ var probe: th.FakeTty = .{ .taken = true };
+ p.host = .{ .ctx = &probe, .vtable = &th.FakeTty.vtable };
+ const held = rd(p, status, 0, 256);
+ try testing.expectEqualStrings(
+ try std.fmt.bufPrint(&want, "{d:>11} {d:>11} {d:>11} ", .{ pane.cols, pane.rows, 1 }),
+ held.bytes,
+ );
+}
+
+test "pty/data writes at the shell and reads the raw stream" {
+ const gpa = testing.allocator;
+ const p = try withTerm(gpa);
+ defer p.deinit();
+ const serial = serialOf(p);
+ const data = Node.of(serial, .pty_data);
+
+ const w = call(p, .{ .tag = 2, .op = .write, .node = data, .off = 999, .data = "ls -l\r" });
+ try testing.expectEqual(@as(u32, 6), w.reply.written);
+ try testing.expectEqualStrings("ls -l\r", w.pty());
+ try testing.expectEqual(@as(u32, 1), wr(p, data, "a\xC3").reply.written);
+ try testing.expectEqual(@as(u32, 0), wr(p, data, "").reply.written);
+
+ try testing.expectEqual(Status.again, rd(p, data, 0, 64).reply.status);
+
+ p.update(.{ .output = .{ .pane = 0, .bytes = "unwatched" } });
+ while (p.nextEffect()) |_| {}
+ try testing.expectEqual(@as(usize, 0), p.fs.panes[0].pty_out.buf.items.len);
+ try testing.expectEqual(Status.again, rd(p, data, 0, 64).reply.status);
+
+ _ = call(p, .{ .tag = 5, .op = .open, .node = data });
+ try testing.expectEqual(@as(u16, 1), p.fs.panes[0].pty_readers);
+ try testing.expectEqual(@as(u16, 0), p.fs.listeners);
+ try testing.expect(!p.fs.scripted(0));
+
+ p.update(.{ .output = .{ .pane = 0, .bytes = "hello" } });
+ while (p.nextEffect()) |_| {}
+ try testing.expectEqualStrings("hello", rd(p, data, 0, 64).bytes);
+ try testing.expectEqual(Status.again, rd(p, data, 0, 64).reply.status);
+
+ p.update(.{ .output = .{ .pane = 0, .bytes = "abcdef" } });
+ while (p.nextEffect()) |_| {}
+ try testing.expectEqualStrings("ab", rd(p, data, 0, 2).bytes);
+ try testing.expectEqualStrings("cd", rd(p, data, 0, 2).bytes);
+ p.update(.{ .output = .{ .pane = 0, .bytes = "ghi" } });
+ while (p.nextEffect()) |_| {}
+ try testing.expectEqualStrings("efghi", rd(p, data, 0, 64).bytes);
+
+ p.update(.{ .output = .{ .pane = 0, .bytes = "orphan" } });
+ while (p.nextEffect()) |_| {}
+ _ = call(p, .{ .tag = 6, .op = .release, .node = data });
+ try testing.expectEqual(@as(u16, 0), p.fs.panes[0].pty_readers);
+ try testing.expectEqual(@as(usize, 0), p.fs.panes[0].pty_out.buf.capacity);
+ try testing.expectEqual(Status.again, rd(p, data, 0, 64).reply.status);
+
+ _ = call(p, .{ .tag = 7, .op = .open, .node = data });
+ _ = call(p, .{ .tag = 8, .op = .open, .node = data });
+ _ = call(p, .{ .tag = 9, .op = .release, .node = data });
+ try testing.expectEqual(@as(u16, 1), p.fs.panes[0].pty_readers);
+ p.update(.{ .output = .{ .pane = 0, .bytes = "still" } });
+ while (p.nextEffect()) |_| {}
+ try testing.expectEqualStrings("still", rd(p, data, 0, 64).bytes);
+}
+
+test "the pty queue drops the oldest at its cap" {
+ const gpa = testing.allocator;
+ const p = try withTerm(gpa);
+ defer p.deinit();
+ const data = Node.of(serialOf(p), .pty_data);
+ _ = call(p, .{ .tag = 5, .op = .open, .node = data });
+
+ const oldest: [4096]u8 = @splat('A');
+ const rest: [4096]u8 = @splat('B');
+ events.notePtyOutput(p, 0, &oldest);
+ for (0..events.queue_cap / rest.len + 4) |_| events.notePtyOutput(p, 0, &rest);
+ const q = &p.fs.panes[0].pty_out;
+ try testing.expect(q.buf.items.len - q.head <= events.queue_cap);
+
+ var seen: usize = 0;
+ while (true) {
+ const a = rd(p, data, 0, 1 << 16);
+ if (a.reply.status == .again) break;
+ try testing.expect(std.mem.indexOfScalar(u8, a.bytes, 'A') == null);
+ if (a.bytes.len == 0) break;
+ seen += a.bytes.len;
+ }
+ try testing.expect(seen > 0 and seen <= events.queue_cap);
+}
diff --git a/src/ninep/screen.zig b/src/ninep/screen.zig
new file mode 100644
index 00000000..5b68b34c
--- /dev/null
+++ b/src/ninep/screen.zig
@@ -0,0 +1,351 @@
+//! /screen, /listeners and the snapshot handles that freeze a screen or a
+//! terminal history between open and close.
+const std = @import("std");
+const pardes = @import("../pardes.zig");
+const panes = @import("../panes.zig");
+const tree = @import("tree.zig");
+
+const Pardes = pardes.Pardes;
+const Pane = pardes.Pane;
+const Req = tree.Req;
+const Reply = tree.Reply;
+const E = tree.E;
+
+pub const Snapshot = struct { node: u64 = 0, bytes: ?[]const u8 = null };
+pub const snapshot_slots = 32;
+
+pub fn render(p: *Pardes) ![]u8 {
+ var arena: std.heap.ArenaAllocator = .init(p.gpa);
+ defer arena.deinit();
+ const surface = try p.render(arena.allocator());
+ var styles: std.ArrayList(pardes.CellStyle) = .empty;
+ var indices: std.ArrayList(usize) = .empty;
+ for (surface.cells) |cell| {
+ const style: pardes.CellStyle = if (cell.default) .{} else cell.style;
+ const index = for (styles.items, 0..) |previous, i| {
+ if (std.meta.eql(style, previous)) break i;
+ } else new: {
+ try styles.append(arena.allocator(), style);
+ break :new styles.items.len - 1;
+ };
+ try indices.append(arena.allocator(), index);
+ }
+ var out: std.Io.Writer.Allocating = .init(p.gpa);
+ errdefer out.deinit();
+ const writer = &out.writer;
+ try writer.print("{{\"cols\":{d},\"rows\":{d},\"cursor\":", .{ surface.cols, surface.rows });
+ try std.json.Stringify.value(surface.cursor, .{}, writer);
+ try writer.writeAll(",\"styles\":");
+ try std.json.Stringify.value(styles.items, .{ .emit_strings_as_arrays = true }, writer);
+ try writer.writeAll(",\"cells\":[");
+ for (surface.cells, indices.items, 0..) |cell, index, i| {
+ if (i != 0) try writer.writeByte(',');
+ try std.json.Stringify.value(.{ if (cell.default) " " else cell.grapheme(), index }, .{}, writer);
+ }
+ try writer.writeAll("]}\n");
+ return out.toOwnedSlice();
+}
+
+/// A screen freezes at open; a terminal body freezes at its first read.
+pub fn openSnapshot(p: *Pardes, req: Req, of_screen: bool) Reply {
+ for (&p.fs.snapshots, 0..) |*slot, i| {
+ if (slot.node != 0) continue;
+ const bytes = if (of_screen) render(p) catch return Reply.fail(req.tag, E.NOMEM) else null;
+ slot.* = .{ .node = req.node, .bytes = bytes };
+ return .{ .tag = req.tag, .handle = @intCast(i + 1) };
+ }
+ return Reply.fail(req.tag, E.NFILE);
+}
+
+pub fn readSnapshot(p: *Pardes, req: Req, pane: ?*Pane) Reply {
+ if (req.handle == 0 or req.handle > p.fs.snapshots.len) return Reply.fail(req.tag, E.INVAL);
+ const snapshot = &p.fs.snapshots[req.handle - 1];
+ if (snapshot.node == 0 or snapshot.node != req.node) return Reply.fail(req.tag, E.INVAL);
+ if (snapshot.bytes == null) {
+ const terminal = pane orelse return Reply.fail(req.tag, E.INVAL);
+ snapshot.bytes = panes.Terminal.screenTextAlloc(terminal, p.gpa) catch return Reply.fail(req.tag, E.NOMEM);
+ }
+ const bytes = snapshot.bytes.?;
+ const off = @min(req.off, bytes.len);
+ const len = @min(bytes.len - off, req.size);
+ const out = p.fs.stage(p.gpa);
+ out.appendSlice(p.gpa, bytes[off..][0..len]) catch return Reply.fail(req.tag, E.NOMEM);
+ return .{ .tag = req.tag, .payload = .{ .staged = @intCast(len) } };
+}
+
+/// True when the handle named a snapshot, which is now released.
+pub fn releaseSnapshot(p: *Pardes, req: Req) bool {
+ if (req.handle == 0 or req.handle > p.fs.snapshots.len) return false;
+ const snapshot = &p.fs.snapshots[req.handle - 1];
+ if (snapshot.node != req.node) return false;
+ if (snapshot.bytes) |bytes| p.gpa.free(bytes);
+ snapshot.* = .{};
+ return true;
+}
+
+pub fn readListeners(p: *Pardes, req: Req) Reply {
+ var buf: [512]u8 = undefined;
+ var text = std.Io.Writer.fixed(&buf);
+ if (p.fs.socket_path.len != 0)
+ text.print("unix!{s}\n", .{p.fs.socket_path}) catch return Reply.fail(req.tag, E.IO);
+ for ([_]?std.Io.net.IpAddress{ p.fs.tcp_address, p.fs.quic_address }, [_][]const u8{ "tcp", "quic" }) |maybe, transport| {
+ const address = maybe orelse continue;
+ switch (address) {
+ .ip4 => |ip| text.print("{s}!{d}.{d}.{d}.{d}!{d}\n", .{ transport, ip.bytes[0], ip.bytes[1], ip.bytes[2], ip.bytes[3], ip.port }) catch
+ return Reply.fail(req.tag, E.IO),
+ .ip6 => |ip| text.print("{s}!{f}!{d}\n", .{ transport, std.Io.net.Ip6Address.Unresolved{ .bytes = ip.bytes, .interface_name = null }, ip.port }) catch
+ return Reply.fail(req.tag, E.IO),
+ }
+ }
+ const out = p.fs.stage(p.gpa);
+ out.appendSlice(p.gpa, text.buffered()) catch return Reply.fail(req.tag, E.NOMEM);
+ return tree.stagedReply(p, req);
+}
+
+// ---- tests ----
+
+const testing = std.testing;
+const th = @import("testing.zig");
+const fs = @import("../fs.zig");
+const call = th.call;
+const rd = th.rd;
+const wr = th.wr;
+const withFile = th.withFile;
+const withTerm = th.withTerm;
+const serialOf = th.serialOf;
+const Node = tree.Node;
+const Status = tree.Status;
+const screen_node = @intFromEnum(tree.TopFile.screen);
+
+test "listener addresses are readable canonical dials with bounded partial reads" {
+ const gpa = testing.allocator;
+ const p = try Pardes.init(gpa, .{ .tty_only = true });
+ defer p.deinit();
+ p.fs.socket_path = "/tmp/a socket.sock";
+ p.fs.tcp_address = .{ .ip4 = .loopback(5640) };
+ p.fs.quic_address = .{ .ip6 = .loopback(5641) };
+ const expected = "unix!/tmp/a socket.sock\ntcp!127.0.0.1!5640\nquic!::1!5641\n";
+ for ([_][]const u8{ "/virtual/listeners", "/n/self/listeners" }) |path| {
+ const bytes = try fs.read(p, path);
+ defer gpa.free(bytes);
+ try testing.expectEqualStrings(expected, bytes);
+ try testing.expectError(error.ReadOnlyFilesystem, fs.write(p, path, ""));
+ }
+ const node = @intFromEnum(tree.TopFile.listeners);
+ var off: usize = 0;
+ while (off < expected.len) {
+ const part = rd(p, node, off, 3);
+ try testing.expectEqual(Status.ok, part.reply.status);
+ try testing.expectEqualStrings(expected[off..][0..@min(3, expected.len - off)], part.bytes);
+ off += part.bytes.len;
+ }
+ try testing.expectEqual(@as(usize, 0), rd(p, node, off, 3).bytes.len);
+}
+
+test "terminal body handles keep one history snapshot across fragmented reads" {
+ const gpa = testing.allocator;
+ const p = try withTerm(gpa);
+ defer p.deinit();
+ const node = Node.of(serialOf(p), .body);
+ p.update(.{ .output = .{ .pane = 0, .bytes = "old caf\xc3\xa9\r\nold tail" } });
+ while (p.nextEffect()) |_| {}
+ const original = try panes.Terminal.screenTextAlloc(p.panes[0].?, gpa);
+ defer gpa.free(original);
+ const opened = call(p, .{ .tag = 1, .op = .open, .node = node });
+ try testing.expectEqual(Status.ok, opened.reply.status);
+ const first = call(p, .{ .tag = 2, .op = .read, .node = node, .handle = opened.reply.handle, .size = 3 });
+ try testing.expectEqualStrings(original[0..3], first.bytes);
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[3J\x1b[2J\x1b[Hnew output" } });
+ while (p.nextEffect()) |_| {}
+ var offset: usize = 3;
+ while (offset < original.len) {
+ const part = call(p, .{ .tag = 3, .op = .read, .node = node, .handle = opened.reply.handle, .off = offset, .size = 3 });
+ try testing.expectEqualStrings(original[offset..][0..@min(3, original.len - offset)], part.bytes);
+ offset += part.bytes.len;
+ }
+ try testing.expectEqualStrings("", call(p, .{ .tag = 4, .op = .read, .node = node, .handle = opened.reply.handle, .off = original.len, .size = 3 }).bytes);
+ _ = call(p, .{ .tag = 5, .op = .release, .node = node, .handle = opened.reply.handle });
+ const newer = call(p, .{ .tag = 6, .op = .open, .node = node });
+ try testing.expectEqualStrings("new output", call(p, .{ .tag = 7, .op = .read, .node = node, .handle = newer.reply.handle, .size = 32 }).bytes);
+ _ = call(p, .{ .tag = 8, .op = .release, .node = node, .handle = newer.reply.handle });
+}
+
+test "an empty terminal body snapshot stays empty while output continues" {
+ const p = try withTerm(std.testing.allocator);
+ defer p.deinit();
+ const node = Node.of(serialOf(p), .body);
+ const opened = call(p, .{ .tag = 1, .op = .open, .node = node });
+ try testing.expectEqualStrings("", call(p, .{ .tag = 2, .op = .read, .node = node, .handle = opened.reply.handle, .size = 32 }).bytes);
+ try testing.expect(p.fs.snapshots[opened.reply.handle - 1].bytes != null);
+ p.update(.{ .output = .{ .pane = 0, .bytes = "new output" } });
+ while (p.nextEffect()) |_| {}
+ try testing.expectEqualStrings("", call(p, .{ .tag = 3, .op = .read, .node = node, .handle = opened.reply.handle, .size = 32 }).bytes);
+ _ = call(p, .{ .tag = 4, .op = .release, .node = node, .handle = opened.reply.handle });
+ const newer = call(p, .{ .tag = 5, .op = .open, .node = node });
+ try testing.expectEqualStrings("new output", call(p, .{ .tag = 6, .op = .read, .node = node, .handle = newer.reply.handle, .size = 32 }).bytes);
+ _ = call(p, .{ .tag = 7, .op = .release, .node = node, .handle = newer.reply.handle });
+}
+
+test "terminal body snapshots are lazy bounded and released after the pane closes" {
+ const gpa = testing.allocator;
+ const p = try withTerm(gpa);
+ defer p.deinit();
+ const node = Node.of(serialOf(p), .body);
+ var handles: [snapshot_slots]u32 = undefined;
+ for (&handles) |*opened_handle| {
+ const opened = call(p, .{ .tag = 1, .op = .open, .node = node });
+ try testing.expectEqual(Status.ok, opened.reply.status);
+ opened_handle.* = opened.reply.handle;
+ }
+ for (p.fs.snapshots) |snapshot| {
+ try testing.expectEqual(node, snapshot.node);
+ try testing.expect(snapshot.bytes == null);
+ }
+ try testing.expectEqual(E.NFILE, call(p, .{ .tag = 2, .op = .open, .node = node }).errno());
+ const scratch = try th.newPane(p);
+ const scratch_id = p.paneBySerial(scratch).?;
+ try testing.expect(p.panes[scratch_id].?.file != null);
+ p.update(.{ .output = .{ .pane = 0, .bytes = "latest output" } });
+ while (p.nextEffect()) |_| {}
+ var failing = std.testing.FailingAllocator.init(gpa, .{ .fail_index = 0 });
+ p.gpa = failing.allocator();
+ const failed = call(p, .{ .tag = 3, .op = .read, .node = node, .handle = handles[0], .size = 32 });
+ p.gpa = gpa;
+ try testing.expectEqual(E.NOMEM, failed.errno());
+ try testing.expect(p.fs.snapshots[handles[0] - 1].bytes == null);
+ const first = call(p, .{ .tag = 4, .op = .read, .node = node, .handle = handles[0], .size = 32 });
+ try testing.expectEqualStrings("latest output", first.bytes);
+ const saved = p.fs.snapshots[handles[0] - 1].bytes.?;
+ failing = std.testing.FailingAllocator.init(gpa, .{ .fail_index = 0 });
+ p.gpa = failing.allocator();
+ const second = call(p, .{ .tag = 5, .op = .read, .node = node, .handle = handles[0], .off = 7, .size = 32 });
+ p.gpa = gpa;
+ try testing.expectEqualStrings("output", second.bytes);
+ try testing.expect(!failing.has_induced_failure);
+ try testing.expectEqual(saved.ptr, p.fs.snapshots[handles[0] - 1].bytes.?.ptr);
+ try testing.expectEqual(E.INVAL, call(p, .{ .tag = 6, .op = .read, .node = screen_node, .handle = handles[0], .size = 32 }).errno());
+ _ = call(p, .{ .tag = 7, .op = .release, .node = screen_node, .handle = handles[0] });
+ try testing.expectEqual(node, p.fs.snapshots[handles[0] - 1].node);
+ try p.removePane(0);
+ for (handles) |opened_handle| _ = call(p, .{ .tag = 8, .op = .release, .node = node, .handle = opened_handle });
+ for (p.fs.snapshots) |snapshot| {
+ try testing.expectEqual(@as(u64, 0), snapshot.node);
+ try testing.expect(snapshot.bytes == null);
+ }
+}
+
+test "screen snapshots preserve rendered cells and styles until their handle is released" {
+ const gpa = testing.allocator;
+ const p = try withFile(gpa, "const value = 1;\n");
+ defer p.deinit();
+ const opened = call(p, .{ .tag = 1, .op = .open, .node = screen_node });
+ try testing.expectEqual(Status.ok, opened.reply.status);
+ const snapshot = p.fs.snapshots[opened.reply.handle - 1].bytes.?;
+ const original = try gpa.dupe(u8, snapshot);
+ defer gpa.free(original);
+ const parsed = try std.json.parseFromSlice(std.json.Value, gpa, original, .{});
+ defer parsed.deinit();
+ const data = parsed.value.object;
+ try testing.expectEqual(@as(i64, p.screen_w), data.get("cols").?.integer);
+ try testing.expectEqual(@as(i64, p.screen_h), data.get("rows").?.integer);
+ try testing.expectEqual(@as(usize, p.screen_w) * p.screen_h, data.get("cells").?.array.items.len);
+ try testing.expect(data.get("styles").?.array.items.len > 0);
+ _ = wr(p, Node.of(serialOf(p), .body), "changed\n");
+ const newer = call(p, .{ .tag = 2, .op = .open, .node = screen_node });
+ try testing.expectEqual(Status.ok, newer.reply.status);
+ try testing.expect(!std.mem.eql(u8, original, p.fs.snapshots[newer.reply.handle - 1].bytes.?));
+ var off: usize = 0;
+ while (off < original.len) {
+ const result = call(p, .{ .tag = 3, .op = .read, .node = screen_node, .handle = opened.reply.handle, .off = off, .size = 13 });
+ try testing.expectEqual(Status.ok, result.reply.status);
+ try testing.expectEqualSlices(u8, original[off..][0..@min(13, original.len - off)], result.bytes);
+ off += result.bytes.len;
+ }
+ _ = call(p, .{ .tag = 4, .op = .release, .node = screen_node, .handle = opened.reply.handle });
+ _ = call(p, .{ .tag = 5, .op = .release, .node = screen_node, .handle = newer.reply.handle });
+ for (p.fs.snapshots) |slot| try testing.expect(slot.node == 0);
+}
+
+test "screen inspection preserves acknowledged presentation and the next real frame" {
+ const gpa = testing.allocator;
+ for (std.enums.values(pardes.layout.Transition)) |transition| {
+ errdefer std.debug.print("screen inspection during {s}\n", .{@tagName(transition)});
+ const control = try withFile(gpa, "const value = 1;\n");
+ defer control.deinit();
+ const inspected = try withFile(gpa, "const value = 1;\n");
+ defer inspected.deinit();
+ var arena: std.heap.ArenaAllocator = .init(gpa);
+ defer arena.deinit();
+ for ([_]*Pardes{ control, inspected }) |p| {
+ p.settings.panel_transition = .off;
+ p.update(.tick);
+ p.acknowledgePanelPresentation((try p.render(arena.allocator())).panelTracks());
+ p.settings.panel_transition = transition;
+ }
+ for (0..4) |phase| {
+ for ([_]*Pardes{ control, inspected }) |p| switch (phase) {
+ 0 => {},
+ 1 => p.update(.{ .command = "New" }),
+ 2 => {
+ p.update(.tick);
+ try testing.expectEqual(Status.ok, wr(p, Node.of(p.panes[p.active].?.serial, .body), "changed cells\n").reply.status);
+ },
+ 3 => {
+ for (0..transition.frames() + 1) |_| p.update(.tick);
+ p.acknowledgePanelPresentation((try p.render(arena.allocator())).panelTracks());
+ p.update(.{ .command = "Del" });
+ },
+ else => unreachable,
+ };
+ for (0..2) |_| {
+ const opened = call(inspected, .{ .tag = 1, .op = .open, .node = screen_node });
+ try testing.expectEqual(Status.ok, opened.reply.status);
+ const captured = call(inspected, .{ .tag = 2, .op = .read, .node = screen_node, .handle = opened.reply.handle, .size = 32 });
+ try testing.expectEqual(Status.ok, captured.reply.status);
+ try testing.expect(captured.bytes.len > 0);
+ try testing.expectEqual(Status.ok, call(inspected, .{
+ .tag = 3,
+ .op = .release,
+ .node = screen_node,
+ .handle = opened.reply.handle,
+ }).reply.status);
+ }
+ const a = &control.presentation;
+ const b = &inspected.presentation;
+ try testing.expectEqualDeep(a.shown, b.shown);
+ try testing.expectEqualDeep(a.shown_tracks, b.shown_tracks);
+ try testing.expectEqualDeep(a.shown_closing[0..a.shown_closing_len], b.shown_closing[0..b.shown_closing_len]);
+ try testing.expectEqual(a.pending, b.pending);
+ try testing.expectEqual(a.acknowledged, b.acknowledged);
+ try testing.expectEqual(a.previous_valid, b.previous_valid);
+ try testing.expectEqual(a.previous_cols, b.previous_cols);
+ try testing.expectEqual(a.previous_rows, b.previous_rows);
+ try testing.expectEqualDeep(a.previous_layout, b.previous_layout);
+ try testing.expectEqual(a.previous_cells.len, b.previous_cells.len);
+ for (a.previous_cells, b.previous_cells) |*expected, *actual|
+ try testing.expect(expected.visuallyEqual(actual));
+
+ const expected = try control.render(arena.allocator());
+ const actual = try inspected.render(arena.allocator());
+ try testing.expectEqual(expected.cols, actual.cols);
+ try testing.expectEqual(expected.rows, actual.rows);
+ try testing.expectEqualDeep(expected.cursor, actual.cursor);
+ try testing.expectEqualDeep(expected.panelTracks(), actual.panelTracks());
+ try testing.expectEqualDeep(expected.cell_diffs, actual.cell_diffs);
+ try testing.expectEqual(expected.previous_cells.len, actual.previous_cells.len);
+ try testing.expectEqual(expected.cells.len, actual.cells.len);
+ try testing.expectEqual(@as(usize, 0), expected.nimages);
+ try testing.expectEqual(@as(usize, 0), actual.nimages);
+ for (expected.cells, actual.cells) |*left, *right| try testing.expect(left.visuallyEqual(right));
+ for (expected.previous_cells, actual.previous_cells) |*left, *right| try testing.expect(left.visuallyEqual(right));
+ if (phase == 1 and transition != .off) try testing.expect(expected.panelTracks().len > 0);
+ if (phase == 1 and transition.needsPreviousGrid()) try testing.expect(expected.hasPanelDiff());
+ if (phase == 3 and transition == .vertical) try testing.expect(a.closing_len > 0);
+ control.acknowledgePanelPresentation(expected.panelTracks());
+ inspected.acknowledgePanelPresentation(actual.panelTracks());
+ try testing.expectEqualDeep(a.shown, b.shown);
+ try testing.expectEqualDeep(a.previous_layout, b.previous_layout);
+ _ = arena.reset(.retain_capacity);
+ }
+ }
+}
diff --git a/src/ninep/sources.zig b/src/ninep/sources.zig
new file mode 100644
index 00000000..b0408594
--- /dev/null
+++ b/src/ninep/sources.zig
@@ -0,0 +1,152 @@
+//! The editor's embedded sources, served read-only from the root when the
+//! build carries them (-Dembed-sources=true).
+const std = @import("std");
+const pardes = @import("../pardes.zig");
+const fs = @import("../fs.zig");
+const tree = @import("tree.zig");
+
+const Pardes = pardes.Pardes;
+const Req = tree.Req;
+const Reply = tree.Reply;
+const E = tree.E;
+const sources = fs.sources;
+
+pub const archive_node: u64 = 1 << 61;
+
+/// The node of an archive path, or null when nothing embedded lives there.
+/// The empty path is the archive root, which is never listed by itself.
+pub fn node(path: []const u8) ?u64 {
+ if (path.len == 0) return archive_node;
+ if (path.len >= 4096) return null;
+ for (sources, 0..) |source, i| {
+ if (std.mem.eql(u8, source.path, path) or
+ (source.path.len > path.len and source.path[path.len] == '/' and std.mem.startsWith(u8, source.path, path)))
+ return archive_node | (@as(u64, i) << 12) | path.len;
+ }
+ return null;
+}
+
+fn info(n: u64) ?struct { path: []const u8, contents: []const u8, dir: bool } {
+ if (n == archive_node) return .{ .path = "", .contents = "", .dir = true };
+ const index = (n & ~archive_node) >> 12;
+ if (index >= sources.len) return null;
+ const source = sources[@intCast(index)];
+ const len: usize = @intCast(n & 4095);
+ if (len > source.path.len) return null;
+ return .{ .path = source.path[0..len], .contents = source.contents, .dir = len < source.path.len };
+}
+
+/// Stages the children of one archive directory; "" lists the top-level
+/// directories the served root shows.
+pub fn stage(p: *Pardes, out: *std.ArrayList(u8), path: []const u8, skip: *u64) void {
+ var seen: [sources.len][]const u8 = undefined;
+ var count: usize = 0;
+ for (sources, 0..) |source, i| {
+ if (path.len != 0 and
+ (source.path.len <= path.len or source.path[path.len] != '/' or !std.mem.startsWith(u8, source.path, path))) continue;
+ const start = if (path.len == 0) 0 else path.len + 1;
+ const rest = source.path[start..];
+ const len = std.mem.indexOfScalar(u8, rest, '/') orelse rest.len;
+ const name = rest[0..len];
+ var duplicate = false;
+ for (seen[0..count]) |previous| if (std.mem.eql(u8, name, previous)) {
+ duplicate = true;
+ break;
+ };
+ if (duplicate) continue;
+ seen[count] = name;
+ count += 1;
+ if (skip.* > 0) {
+ skip.* -= 1;
+ continue;
+ }
+ const child = archive_node | (@as(u64, i) << 12) | (start + len);
+ tree.stageDirent(out, p.gpa, child, len < rest.len, name);
+ }
+}
+
+pub fn handle(p: *Pardes, req: Req) Reply {
+ const found = info(req.node) orelse return Reply.fail(req.tag, E.NOENT);
+ const attr: Reply.Attr = .{
+ .node = req.node,
+ .name = std.fs.path.basename(found.path),
+ .dir = found.dir,
+ .size = if (found.dir) 0 else found.contents.len,
+ .mode = if (found.dir) 0o555 else 0o444,
+ .mtime = p.fs.started,
+ };
+ switch (req.op) {
+ .getattr => return .{ .tag = req.tag, .attr = attr },
+ .open => return .{ .tag = req.tag, .handle = 1 },
+ .release => return .{ .tag = req.tag },
+ .lookup => {
+ if (!found.dir) return Reply.fail(req.tag, E.NOTDIR);
+ if (std.mem.eql(u8, req.data, "..")) {
+ const parent = std.fs.path.dirname(found.path) orelse "";
+ if (parent.len == 0) return tree.handle(p, .{ .tag = req.tag, .op = .getattr, .node = tree.root });
+ const parent_node = node(parent) orelse return Reply.fail(req.tag, E.NOENT);
+ return handle(p, .{ .tag = req.tag, .op = .getattr, .node = parent_node });
+ }
+ if (req.data.len == 0 or std.mem.indexOfAny(u8, req.data, "/\x00") != null) return Reply.fail(req.tag, E.NOENT);
+ var buf: [4096]u8 = undefined;
+ const path = if (found.path.len == 0) req.data else std.fmt.bufPrint(&buf, "{s}/{s}", .{ found.path, req.data }) catch return Reply.fail(req.tag, E.NOENT);
+ const child = node(path) orelse return Reply.fail(req.tag, E.NOENT);
+ return handle(p, .{ .tag = req.tag, .op = .getattr, .node = child });
+ },
+ .readdir => {
+ if (!found.dir) return Reply.fail(req.tag, E.NOTDIR);
+ const out = p.fs.stage(p.gpa);
+ var skip = req.off;
+ stage(p, out, found.path, &skip);
+ return .{ .tag = req.tag, .payload = .{ .staged = @intCast(out.items.len) } };
+ },
+ .read => {
+ if (found.dir) return Reply.fail(req.tag, E.PERM);
+ const off: usize = @intCast(@min(req.off, found.contents.len));
+ const bytes = found.contents[off..][0..@min(req.size, found.contents.len - off)];
+ const out = p.fs.stage(p.gpa);
+ out.appendSlice(p.gpa, bytes) catch return Reply.fail(req.tag, E.NOMEM);
+ return .{ .tag = req.tag, .payload = .{ .staged = @intCast(bytes.len) } };
+ },
+ else => return Reply.fail(req.tag, E.PERM),
+ }
+}
+
+const testing = std.testing;
+const th = @import("testing.zig");
+const limits = @import("../memory.zig").limits;
+
+test "embedded sources appear under the root only when the build carries them" {
+ const p = try th.withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ var buf: [64]th.Dirent = undefined;
+ const top = th.dirents(th.rdir(p, tree.root, 0).bytes, &buf);
+ const listed = th.nameAt(top, "src");
+ try testing.expectEqual(limits.embedded_sources, listed != null);
+ const looked = th.look_up(p, tree.root, "src");
+ try testing.expectEqual(limits.embedded_sources, looked.reply.status == .ok);
+ try testing.expect(tree.resolveSelf(p, "shaders/../src/../") == tree.root);
+ if (!limits.embedded_sources) {
+ try testing.expect(tree.resolveSelf(p, "src/pardes.zig") == null);
+ try testing.expect(tree.resolveSelf(p, "src") == null);
+ try testing.expect(th.nameAt(top, "build.zig") == null);
+ return;
+ }
+ try testing.expect(looked.reply.attr.dir);
+ try testing.expectEqual(@as(u16, 0o555), looked.reply.attr.mode);
+ try testing.expectEqual(listed.?.node, looked.reply.attr.node);
+ try testing.expect(th.nameAt(top, "build.zig") == null);
+ try testing.expect(th.nameAt(top, "build.zig.zon") == null);
+ const file = th.look_up(p, looked.reply.attr.node, "pardes.zig");
+ try testing.expectEqual(tree.Status.ok, file.reply.status);
+ try testing.expectEqual(@as(u16, 0o444), file.reply.attr.mode);
+ try testing.expectEqual(@as(u64, fs.sourceBytes("src/pardes.zig").?.len), file.reply.attr.size);
+ try testing.expectEqual(file.reply.attr.node, tree.resolveSelf(p, "src/pardes.zig").?);
+ const opened = th.call(p, .{ .tag = 1, .op = .open, .node = file.reply.attr.node });
+ const head = th.call(p, .{ .tag = 2, .op = .read, .node = file.reply.attr.node, .handle = opened.reply.handle, .size = 16 });
+ try testing.expectEqualStrings(fs.sourceBytes("src/pardes.zig").?[0..16], head.bytes);
+ const parent = th.look_up(p, looked.reply.attr.node, "..");
+ try testing.expectEqual(tree.root, parent.reply.attr.node);
+ try testing.expectEqual(E.NOENT, th.look_up(p, tree.root, "build.zig").errno());
+ try testing.expectEqual(E.NOENT, th.look_up(p, looked.reply.attr.node, "missing.zig").errno());
+}
diff --git a/src/ninep/testing.zig b/src/ninep/testing.zig
new file mode 100644
index 00000000..ba26cb38
--- /dev/null
+++ b/src/ninep/testing.zig
@@ -0,0 +1,135 @@
+//! Test helpers shared by the control-tree modules: drive one request
+//! through the core and collect the reply and the effects it raised.
+const std = @import("std");
+const pardes = @import("../pardes.zig");
+const tree = @import("tree.zig");
+
+const Pardes = pardes.Pardes;
+pub const Req = tree.Req;
+pub const Reply = tree.Reply;
+pub const E = tree.E;
+
+pub const Answer = struct {
+ reply: Reply = .{ .tag = 0, .status = .err, .errno = E.IO },
+ bytes: []const u8 = "",
+ saved: bool = false,
+ watch: ?bool = null,
+ pty_buf: [256]u8 = undefined,
+ pty_len: usize = 0,
+ winsize: ?struct { cols: u16, rows: u16 } = null,
+ signal: ?pardes.PtySignal = null,
+ spawned: bool = false,
+
+ pub fn pty(a: *const Answer) []const u8 {
+ return a.pty_buf[0..a.pty_len];
+ }
+
+ pub fn errno(a: Answer) u16 {
+ return if (a.reply.status == .err) a.reply.errno else 0;
+ }
+};
+
+pub fn call(p: *Pardes, req: Req) Answer {
+ p.update(.{ .fs_req = req });
+ var ans: Answer = .{};
+ while (p.nextEffect()) |e| switch (e) {
+ .fs_reply => |r| {
+ ans.reply = r;
+ ans.bytes = p.fsPayload(r);
+ },
+ .save_file, .save_text => ans.saved = true,
+ .watch => |w| ans.watch = w.on,
+ .write => |w| {
+ const b = w.bytes.slice();
+ const n = @min(b.len, ans.pty_buf.len - ans.pty_len);
+ @memcpy(ans.pty_buf[ans.pty_len..][0..n], b[0..n]);
+ ans.pty_len += n;
+ },
+ .resize_pty => |r| ans.winsize = .{ .cols = r.cols, .rows = r.rows },
+ .signal_pty => |s| ans.signal = s.sig,
+ .spawn => ans.spawned = true,
+ else => {},
+ };
+ return ans;
+}
+
+pub fn rd(p: *Pardes, node: u64, off: u64, size: u32) Answer {
+ return call(p, .{ .tag = 1, .op = .read, .node = node, .off = off, .size = size });
+}
+
+pub fn wr(p: *Pardes, node: u64, data: []const u8) Answer {
+ return call(p, .{ .tag = 2, .op = .write, .node = node, .data = data });
+}
+
+pub fn rdir(p: *Pardes, node: u64, skip: u64) Answer {
+ return call(p, .{ .tag = 4, .op = .readdir, .node = node, .off = skip, .size = 4096 });
+}
+
+pub fn look_up(p: *Pardes, dir: u64, name: []const u8) Answer {
+ return call(p, .{ .tag = 3, .op = .lookup, .node = dir, .data = name });
+}
+
+/// Opens /new once, as a client would, and returns the pane it made.
+pub fn newPane(p: *Pardes) !u32 {
+ const new = @intFromEnum(tree.TopFile.new);
+ const opened = call(p, .{ .tag = 9, .op = .open, .node = new });
+ if (opened.reply.status != .ok or opened.reply.handle == 0) return error.NoPane;
+ _ = call(p, .{ .tag = 9, .op = .release, .node = new, .handle = opened.reply.handle });
+ return opened.reply.handle;
+}
+
+pub fn withFile(gpa: std.mem.Allocator, text: []const u8) !*Pardes {
+ const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 });
+ errdefer p.deinit();
+ while (p.nextEffect()) |_| {}
+ _ = try p.setTestFile(text);
+ while (p.nextEffect()) |_| {}
+ return p;
+}
+
+pub fn withTerm(gpa: std.mem.Allocator) !*Pardes {
+ const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 });
+ errdefer p.deinit();
+ while (p.nextEffect()) |_| {}
+ std.debug.assert(p.panes[0].?.isTerminal());
+ return p;
+}
+
+pub fn serialOf(p: *Pardes) u32 {
+ return p.panes[0].?.serial;
+}
+
+pub const Dirent = struct { node: u64, dir: bool, name: []const u8 };
+
+pub fn dirents(bytes: []const u8, out: []Dirent) []Dirent {
+ var n: usize = 0;
+ var i: usize = 0;
+ while (i + 10 <= bytes.len and n < out.len) {
+ const node = std.mem.readInt(u64, bytes[i..][0..8], .little);
+ const kind = bytes[i + 8];
+ const len = bytes[i + 9];
+ i += 10;
+ if (i + len > bytes.len) break;
+ out[n] = .{ .node = node, .dir = kind == 1, .name = bytes[i .. i + len] };
+ i += len;
+ n += 1;
+ }
+ return out[0..n];
+}
+
+pub fn nameAt(list: []const Dirent, want: []const u8) ?Dirent {
+ for (list) |d| if (std.mem.eql(u8, d.name, want)) return d;
+ return null;
+}
+
+pub const FakeTty = struct {
+ taken: bool,
+
+ pub const vtable: pardes.Host.VTable = .{ .tty_taken = answer };
+
+ fn answer(ctx: ?*anyopaque, pane: u8) bool {
+ _ = pane;
+ const f: *FakeTty = @ptrCast(@alignCast(ctx.?));
+ return f.taken;
+ }
+};
diff --git a/src/ninep/tree.zig b/src/ninep/tree.zig
new file mode 100644
index 00000000..f716bf5f
--- /dev/null
+++ b/src/ninep/tree.zig
@@ -0,0 +1,850 @@
+//! The control tree served over 9P: its nodes, lookup, readdir and the
+//! dispatch that answers the engine in src/9p.zig. The backend contract
+//! (Req, Reply, Op, Status, E) lives here; fs.zig keeps host access and mounts.
+//!
+//! /README /index /ctl /new /log /screen /listeners
+//! /pane/<serial>/{name,body,tag,ctl,addr,data,xdata,sel,errors,event,pty/}
+//! /os/... the host filesystem /src/... embedded sources (opt-in)
+const std = @import("std");
+const pardes = @import("../pardes.zig");
+const fs = @import("../fs.zig");
+pub const events = @import("events.zig");
+pub const pane = @import("pane.zig");
+pub const addr = @import("addr.zig");
+pub const ctl = @import("ctl.zig");
+pub const pty = @import("pty.zig");
+pub const sources = @import("sources.zig");
+pub const screen = @import("screen.zig");
+
+const Pardes = pardes.Pardes;
+const Pane = pardes.Pane;
+const MAX_PANES = pardes.MAX_PANES;
+
+pub const name_capacity: usize = 255;
+
+// ---- the backend contract ----
+
+pub const Op = enum(u8) {
+ lookup,
+ getattr,
+ setattr,
+ open,
+ read,
+ write,
+ release,
+ readdir,
+};
+
+pub const Status = enum(u8) {
+ ok,
+ again,
+ err,
+};
+
+pub const Req = struct {
+ tag: u64,
+ op: Op,
+ node: u64,
+ handle: u32 = 0,
+ off: u64 = 0,
+ size: u32 = 0,
+ data: []const u8 = &.{},
+ truncate: bool = false,
+
+ pub fn changesPane(req: Req) bool {
+ return switch (req.op) {
+ .write, .setattr => true,
+ .open => req.node == @intFromEnum(TopFile.new),
+ .lookup, .getattr, .read, .release, .readdir => false,
+ };
+ }
+};
+
+pub const Reply = struct {
+ tag: u64,
+ status: Status = .ok,
+ errno: u16 = 0,
+ attr: Attr = .{},
+ handle: u32 = 0,
+ payload: Payload = .none,
+ written: u32 = 0,
+
+ pub const Attr = struct {
+ name: []const u8 = "",
+ node: u64 = 0,
+ dir: bool = false,
+ size: u64 = 0,
+ mode: u16 = 0o644,
+ mtime: u32 = 0,
+ };
+
+ pub const Payload = union(enum) {
+ none,
+ staged: u32,
+ region: struct { pane: u8, serial: u32, off: u32, len: u32 },
+ };
+
+ pub fn fail(tag: u64, e: u16) Reply {
+ return .{ .tag = tag, .status = .err, .errno = e };
+ }
+};
+
+pub const E = struct {
+ pub const PERM: u16 = 1;
+ pub const NOENT: u16 = 2;
+ pub const IO: u16 = 5;
+ pub const NOMEM: u16 = 12;
+ pub const NOTDIR: u16 = 20;
+ pub const INVAL: u16 = 22;
+ pub const NFILE: u16 = 23;
+ pub const NOSPC: u16 = 28;
+ pub const NOSYS: u16 = 38;
+};
+
+pub const out_reserve = 4 * 1024;
+
+// ---- nodes ----
+
+pub const TopFile = enum(u4) {
+ root = 1,
+ README,
+ index,
+ ctl,
+ new,
+ log,
+ screen,
+ listeners,
+ pane,
+
+ pub fn fileName(f: TopFile) []const u8 {
+ return if (f == .root) "/" else @tagName(f);
+ }
+
+ pub fn mode(f: TopFile) u16 {
+ return switch (f) {
+ .root, .pane => 0o755,
+ .ctl => 0o666,
+ .README, .index, .new, .log, .screen, .listeners => 0o444,
+ };
+ }
+
+ pub fn dir(f: TopFile) bool {
+ return f == .root or f == .pane;
+ }
+};
+
+pub const root: u64 = @intFromEnum(TopFile.root);
+
+pub const PaneFile = enum(u4) {
+ dir = 0,
+ name,
+ body,
+ tag,
+ ctl,
+ addr,
+ data,
+ xdata,
+ sel,
+ errors,
+ event,
+ pty,
+ pty_ctl,
+ pty_status,
+ pty_data,
+
+ pub fn fileName(f: PaneFile) []const u8 {
+ return switch (f) {
+ .dir => ".",
+ .pty_ctl => "ctl",
+ .pty_status => "status",
+ .pty_data => "data",
+ else => @tagName(f),
+ };
+ }
+
+ pub fn mode(f: PaneFile) u16 {
+ return switch (f) {
+ .dir, .pty => 0o755,
+ .errors, .pty_ctl => 0o222,
+ .pty_status => 0o444,
+ else => 0o666,
+ };
+ }
+
+ pub fn isDir(f: PaneFile) bool {
+ return f == .dir or f == .pty;
+ }
+
+ pub fn inPty(f: PaneFile) bool {
+ return switch (f) {
+ .pty, .pty_ctl, .pty_status, .pty_data => true,
+ else => false,
+ };
+ }
+};
+
+pub const Node = packed struct(u64) {
+ file: u4 = 0,
+ serial: u60 = 0,
+
+ pub fn of(serial: u32, file: PaneFile) u64 {
+ std.debug.assert(serial != 0);
+ return @bitCast(Node{ .file = @intFromEnum(file), .serial = serial });
+ }
+
+ pub fn target(node: u64) ?Target {
+ const n: Node = @bitCast(node);
+ if (n.serial == 0) {
+ return .{ .top = std.enums.fromInt(TopFile, n.file) orelse return null };
+ }
+ return .{ .pane = .{
+ .serial = std.math.cast(u32, n.serial) orelse return null,
+ .file = std.enums.fromInt(PaneFile, n.file) orelse return null,
+ } };
+ }
+};
+
+pub const Target = union(enum) {
+ top: TopFile,
+ pane: struct { serial: u32, file: PaneFile },
+};
+
+fn paneFileNamed(name: []const u8) ?PaneFile {
+ const f = std.meta.stringToEnum(PaneFile, name) orelse return null;
+ if (f == .dir) return null;
+ return if (f.inPty() and f != .pty) null else f;
+}
+
+fn topFileNamed(name: []const u8) ?TopFile {
+ const f = std.meta.stringToEnum(TopFile, name) orelse return null;
+ return if (f == .root) null else f;
+}
+
+fn serialNamed(name: []const u8) ?u32 {
+ if (name.len == 0 or name.len > 10) return null;
+ for (name) |c| if (c < '0' or c > '9') return null;
+ const serial = std.fmt.parseInt(u32, name, 10) catch return null;
+ return if (serial == 0) null else serial;
+}
+
+pub fn nextSerialAfter(p: *Pardes, after: u32) ?u32 {
+ var best: ?u32 = null;
+ for (p.panes) |slot| {
+ const pn = slot orelse continue;
+ if (pn.serial <= after) continue;
+ if (best == null or pn.serial < best.?) best = pn.serial;
+ }
+ return best;
+}
+
+/// An editor path below the root (`pane/3/body`, `index`, `src/...`) to its node.
+pub fn resolveSelf(p: *Pardes, path: []const u8) ?u64 {
+ var path_buf: [4096]u8 = undefined;
+ const normalized = fs.normalizeVirtualPath(path, &path_buf) orelse return null;
+ var parts = std.mem.tokenizeScalar(u8, normalized, '/');
+ const first = parts.next() orelse return root;
+ if (!std.mem.eql(u8, first, "pane")) {
+ const top = topFileNamed(first) orelse return sources.node(normalized);
+ if (top == .pane or parts.next() != null) return null;
+ return @intFromEnum(top);
+ }
+ const serial = serialNamed(parts.next() orelse return @intFromEnum(TopFile.pane)) orelse return null;
+ const id = p.paneBySerial(serial) orelse return null;
+ const file = paneFileNamed(parts.next() orelse return Node.of(serial, .dir)) orelse return null;
+ if (file.inPty() and !p.panes[id].?.isTerminal()) return null;
+ if (parts.next()) |name| {
+ if (file != .pty) return null;
+ const child = pty.fileNamed(name) orelse return null;
+ if (parts.next() != null) return null;
+ return Node.of(serial, child);
+ }
+ return Node.of(serial, file);
+}
+
+// ---- staging ----
+
+pub fn stageDirent(out: *std.ArrayList(u8), gpa: std.mem.Allocator, node: u64, dir: bool, name: []const u8) void {
+ if (name.len == 0 or name.len > 255) return;
+ var head: [10]u8 = undefined;
+ std.mem.writeInt(u64, head[0..8], node, .little);
+ head[8] = @intFromBool(dir);
+ head[9] = @intCast(name.len);
+ out.appendSlice(gpa, &head) catch return;
+ out.appendSlice(gpa, name) catch return;
+}
+
+/// Answers a read from whatever the handler staged, windowed by offset and size.
+pub fn stagedReply(p: *Pardes, req: Req) Reply {
+ const out = &p.fs.out;
+ const off = @min(req.off, out.items.len);
+ const n = @min(out.items.len - off, req.size);
+ if (off > 0) std.mem.copyForwards(u8, out.items[0..n], out.items[off..][0..n]);
+ out.shrinkRetainingCapacity(n);
+ return .{ .tag = req.tag, .payload = .{ .staged = @intCast(n) } };
+}
+
+// ---- dispatch ----
+
+pub fn handle(p: *Pardes, req: Req) Reply {
+ if (req.node == fs.os_root or req.node & fs.os_node != 0) return fs.osHandle(p, req);
+ if (req.node & sources.archive_node != 0) return sources.handle(p, req);
+ const target = Node.target(req.node) orelse return Reply.fail(req.tag, E.NOENT);
+ if (req.op == .write or req.op == .setattr) p.fs.origin = switch (target) {
+ .pane => |t| @as(u8, if (t.file == .body or t.file == .tag) 'E' else 'F'),
+ .top => 'F',
+ };
+ return switch (req.op) {
+ .lookup => lookup(p, req, target),
+ .getattr => attrReply(p, req.tag, target),
+ .setattr => setattr(p, req, target),
+ .open => open(p, req, target),
+ .release => release(p, req),
+ .readdir => readdir(p, req, target),
+ .read => read(p, req, target),
+ .write => write(p, req, target),
+ };
+}
+
+fn attrOf(p: *Pardes, target: Target) ?Reply.Attr {
+ switch (target) {
+ .top => |f| return .{
+ .name = f.fileName(),
+ .node = @intFromEnum(f),
+ .dir = f.dir(),
+ .mode = f.mode(),
+ .size = topSize(p, f),
+ .mtime = p.fs.started,
+ },
+ .pane => |t| {
+ const id = p.paneBySerial(t.serial) orelse return null;
+ const pn = p.panes[id].?;
+ if (t.file.inPty() and !pn.isTerminal()) return null;
+ return .{
+ .name = if (t.file == .dir) (std.fmt.bufPrint(&p.fs.node_name, "{d}", .{t.serial}) catch unreachable) else t.file.fileName(),
+ .node = Node.of(t.serial, t.file),
+ .dir = t.file.isDir(),
+ .mode = t.file.mode(),
+ .size = pane.fileSize(p, id, t.file),
+ .mtime = pane.mtimeOf(p, pn),
+ };
+ },
+ }
+}
+
+fn attrReply(p: *Pardes, tag: u64, target: Target) Reply {
+ const a = attrOf(p, target) orelse return Reply.fail(tag, E.NOENT);
+ return .{ .tag = tag, .attr = a };
+}
+
+fn topSize(p: *Pardes, f: TopFile) u64 {
+ return switch (f) {
+ .root, .pane, .new, .log, .screen, .listeners => 0,
+ .index => pane.indexLen(p),
+ .README => fs.help.len,
+ .ctl => ctl: {
+ var buf: [ctl.root_text_cap]u8 = undefined;
+ break :ctl ctl.rootText(p, &buf).len;
+ },
+ };
+}
+
+fn lookup(p: *Pardes, req: Req, target: Target) Reply {
+ const name = req.data;
+ if (std.mem.eql(u8, name, "..")) {
+ const parent: u64 = switch (target) {
+ .top => |top| if (top.dir()) root else return Reply.fail(req.tag, E.NOTDIR),
+ .pane => |t| switch (t.file) {
+ .dir => @intFromEnum(TopFile.pane),
+ .pty => Node.of(t.serial, .dir),
+ else => return Reply.fail(req.tag, E.NOTDIR),
+ },
+ };
+ return handle(p, .{ .tag = req.tag, .op = .getattr, .node = parent });
+ }
+ if (name.len == 0 or std.mem.indexOfAny(u8, name, "/\x00") != null) return Reply.fail(req.tag, E.NOENT);
+ const node: u64 = switch (target) {
+ .top => |f| switch (f) {
+ .root => root: {
+ if (std.mem.eql(u8, name, "os")) return fs.osHandle(p, .{ .tag = req.tag, .op = .getattr, .node = fs.os_root });
+ if (topFileNamed(name)) |t| break :root @intFromEnum(t);
+ if (sources.node(name)) |n| return sources.handle(p, .{ .tag = req.tag, .op = .getattr, .node = n });
+ return Reply.fail(req.tag, E.NOENT);
+ },
+ .pane => pane: {
+ const serial = serialNamed(name) orelse return Reply.fail(req.tag, E.NOENT);
+ break :pane Node.of(serial, .dir);
+ },
+ else => return Reply.fail(req.tag, E.NOTDIR),
+ },
+ .pane => |t| pane: {
+ _ = p.paneBySerial(t.serial) orelse return Reply.fail(req.tag, E.NOENT);
+ const f = switch (t.file) {
+ .dir => paneFileNamed(name),
+ .pty => pty.fileNamed(name),
+ else => return Reply.fail(req.tag, E.NOTDIR),
+ } orelse return Reply.fail(req.tag, E.NOENT);
+ break :pane Node.of(t.serial, f);
+ },
+ };
+ return attrReply(p, req.tag, Node.target(node) orelse return Reply.fail(req.tag, E.NOENT));
+}
+
+fn stagePaneFiles(p: *Pardes, out: *std.ArrayList(u8), serial: u32, terminal: bool, skip: *u64) void {
+ inline for (comptime std.enums.values(PaneFile)) |f| {
+ if (comptime f == .dir or (f.inPty() and f != .pty)) continue;
+ const present = f != .pty or terminal;
+ if (present) {
+ if (skip.* > 0) skip.* -= 1 else stageDirent(out, p.gpa, Node.of(serial, f), f.isDir(), f.fileName());
+ }
+ }
+}
+
+fn stagePtyFiles(p: *Pardes, out: *std.ArrayList(u8), serial: u32, skip: *u64) void {
+ inline for (comptime std.enums.values(PaneFile)) |f| {
+ if (comptime !f.inPty() or f == .pty) continue;
+ if (skip.* > 0) skip.* -= 1 else stageDirent(out, p.gpa, Node.of(serial, f), false, f.fileName());
+ }
+}
+
+fn readdir(p: *Pardes, req: Req, target: Target) Reply {
+ const out = p.fs.stage(p.gpa);
+ var skip = req.off;
+ switch (target) {
+ .top => |f| switch (f) {
+ .root => {
+ inline for (.{ TopFile.README, TopFile.index, TopFile.ctl, TopFile.new, TopFile.log, TopFile.screen, TopFile.listeners, TopFile.pane }) |t| {
+ if (skip > 0) skip -= 1 else stageDirent(out, p.gpa, @intFromEnum(t), t.dir(), t.fileName());
+ }
+ if (skip > 0) skip -= 1 else stageDirent(out, p.gpa, fs.os_root, true, "os");
+ sources.stage(p, out, "", &skip);
+ },
+ .pane => {
+ var last: u32 = 0;
+ while (nextSerialAfter(p, last)) |serial| {
+ last = serial;
+ if (skip > 0) {
+ skip -= 1;
+ continue;
+ }
+ var buf: [16]u8 = undefined;
+ const name = std.fmt.bufPrint(&buf, "{d}", .{serial}) catch unreachable;
+ stageDirent(out, p.gpa, Node.of(serial, .dir), true, name);
+ }
+ },
+ else => return Reply.fail(req.tag, E.NOTDIR),
+ },
+ .pane => |t| {
+ const id = p.paneBySerial(t.serial) orelse return Reply.fail(req.tag, E.NOENT);
+ const terminal = p.panes[id].?.isTerminal();
+ switch (t.file) {
+ .dir => stagePaneFiles(p, out, t.serial, terminal, &skip),
+ .pty => {
+ if (!terminal) return Reply.fail(req.tag, E.NOENT);
+ stagePtyFiles(p, out, t.serial, &skip);
+ },
+ else => return Reply.fail(req.tag, E.NOTDIR),
+ }
+ },
+ }
+ return .{ .tag = req.tag, .payload = .{ .staged = @intCast(out.items.len) } };
+}
+
+fn newPane(p: *Pardes) ?u32 {
+ const slot = p.freeSlot() orelse return null;
+ p.newScratchBelow(p.active);
+ const pn = p.panes[slot] orelse return null;
+ return pn.serial;
+}
+
+fn open(p: *Pardes, req: Req, target: Target) Reply {
+ switch (target) {
+ .top => |f| switch (f) {
+ // The clone idiom: each open makes one empty pane; reads answer its serial.
+ .new => {
+ const serial = newPane(p) orelse return Reply.fail(req.tag, E.NFILE);
+ return .{ .tag = req.tag, .handle = serial };
+ },
+ .screen => return screen.openSnapshot(p, req, true),
+ .log => p.fs.log_readers +|= 1,
+ else => {},
+ },
+ .pane => |t| {
+ const id = p.paneBySerial(t.serial) orelse return Reply.fail(req.tag, E.NOENT);
+ const pn = p.panes[id].?;
+ const pf = &p.fs.panes[id];
+ if (t.file.inPty() and !pn.isTerminal()) return Reply.fail(req.tag, E.NOENT);
+ switch (t.file) {
+ .body => if (pn.isTerminal()) return screen.openSnapshot(p, req, false),
+ .ctl => pf.limit = null,
+ .addr => {
+ pf.addr = .{};
+ pf.limit = null;
+ },
+ .event => {
+ pf.readers +|= 1;
+ p.fs.listeners +|= 1;
+ },
+ .pty_data => pf.pty_readers +|= 1,
+ else => {},
+ }
+ },
+ }
+ return .{ .tag = req.tag, .handle = 1 };
+}
+
+fn release(p: *Pardes, req: Req) Reply {
+ if (screen.releaseSnapshot(p, req)) return .{ .tag = req.tag };
+ const target = Node.target(req.node) orelse return .{ .tag = req.tag };
+ switch (target) {
+ .top => |f| if (f == .log and p.fs.log_readers > 0) {
+ p.fs.log_readers -= 1;
+ if (p.fs.log_readers == 0) p.fs.log.clearAndFree(p.gpa);
+ },
+ .pane => |t| {
+ if (t.file != .event and t.file != .pty_data) return .{ .tag = req.tag };
+ const id = p.paneBySerial(t.serial) orelse return .{ .tag = req.tag };
+ const pf = &p.fs.panes[id];
+ if (t.file == .pty_data) {
+ if (pf.pty_readers == 0) return .{ .tag = req.tag };
+ pf.pty_readers -= 1;
+ if (pf.pty_readers == 0) pf.pty_out.clearAndFree(p.gpa);
+ return .{ .tag = req.tag };
+ }
+ if (pf.readers == 0) return .{ .tag = req.tag };
+ pf.readers -= 1;
+ p.fs.listeners -|= 1;
+ if (pf.readers == 0) pf.tag_snap.clearAndFree(p.gpa);
+ },
+ }
+ return .{ .tag = req.tag };
+}
+
+fn setattr(p: *Pardes, req: Req, target: Target) Reply {
+ if (req.truncate) switch (target) {
+ .pane => |t| {
+ const id = p.paneBySerial(t.serial) orelse return Reply.fail(req.tag, E.NOENT);
+ if (pane.truncate(p, id, p.panes[id].?, t.file) != .ok) return Reply.fail(req.tag, E.NOMEM);
+ },
+ .top => {},
+ };
+ return attrReply(p, req.tag, target);
+}
+
+fn read(p: *Pardes, req: Req, target: Target) Reply {
+ switch (target) {
+ .top => |f| return switch (f) {
+ .README => help: {
+ const off = @min(req.off, fs.help.len);
+ const bytes = fs.help[off..][0..@min(req.size, fs.help.len - off)];
+ p.fs.stage(p.gpa).appendSlice(p.gpa, bytes) catch break :help Reply.fail(req.tag, E.NOMEM);
+ break :help .{ .tag = req.tag, .payload = .{ .staged = @intCast(bytes.len) } };
+ },
+ .index => index: {
+ const out = p.fs.stage(p.gpa);
+ var last: u32 = 0;
+ while (nextSerialAfter(p, last)) |serial| {
+ last = serial;
+ pane.stageIndexLine(p, out, p.panes[p.paneBySerial(serial).?].?);
+ }
+ break :index stagedReply(p, req);
+ },
+ .ctl => ctl.readRoot(p, req),
+ .new => new: {
+ if (req.handle == 0) break :new Reply.fail(req.tag, E.INVAL);
+ p.fs.stage(p.gpa).print(p.gpa, "{d}\n", .{req.handle}) catch break :new Reply.fail(req.tag, E.NOMEM);
+ break :new stagedReply(p, req);
+ },
+ .log => events.readQueue(p, req, &p.fs.log),
+ .screen => screen.readSnapshot(p, req, null),
+ .listeners => screen.readListeners(p, req),
+ .root, .pane => Reply.fail(req.tag, E.PERM),
+ },
+ .pane => |t| {
+ const id = p.paneBySerial(t.serial) orelse return Reply.fail(req.tag, E.NOENT);
+ const pn = p.panes[id].?;
+ if (t.file.inPty() and !pn.isTerminal()) return Reply.fail(req.tag, E.NOENT);
+ return pane.read(p, req, id, pn, t.file);
+ },
+ }
+}
+
+fn write(p: *Pardes, req: Req, target: Target) Reply {
+ switch (target) {
+ .top => |f| return switch (f) {
+ .ctl => ctl.writeRoot(p, req),
+ else => Reply.fail(req.tag, E.PERM),
+ },
+ .pane => |t| {
+ const id = p.paneBySerial(t.serial) orelse return Reply.fail(req.tag, E.NOENT);
+ const pn = p.panes[id].?;
+ if (t.file.inPty() and !pn.isTerminal()) return Reply.fail(req.tag, E.NOENT);
+ return pane.write(p, req, id, pn, t.file);
+ },
+ }
+}
+
+// ---- tests ----
+
+test {
+ _ = events;
+ _ = pane;
+ _ = addr;
+ _ = ctl;
+ _ = pty;
+ _ = sources;
+ _ = screen;
+}
+
+const testing = std.testing;
+const th = @import("testing.zig");
+const config = @import("../config.zig");
+const call = th.call;
+const rd = th.rd;
+const wr = th.wr;
+const rdir = th.rdir;
+const look_up = th.look_up;
+const withFile = th.withFile;
+const serialOf = th.serialOf;
+
+test "filesystem inspection preserves pending and displayed Look hover" {
+ const delay = config.look_preview_delay_frames orelse return;
+ const gpa = testing.allocator;
+ const p = try withFile(gpa, "alpha beta gamma\n");
+ defer p.deinit();
+ var frame: std.heap.ArenaAllocator = .init(gpa);
+ defer frame.deinit();
+ _ = try p.render(frame.allocator());
+ const rect = p.rects[0];
+ p.update(.{ .mouse = .{
+ .button = .none,
+ .kind = .motion,
+ .col = rect.x + config.GUTTER + config.PREFIX_W + 7,
+ .row = if (p.settings.tag_bottom) rect.y else rect.y + pardes.BOX_H,
+ } });
+ try testing.expect(p.look_hover_wait != null);
+ const body = Node.of(serialOf(p), .body);
+ const screen_node = @intFromEnum(TopFile.screen);
+ for (0..2) |phase| {
+ if (phase == 1) {
+ for (0..delay) |_| p.update(.tick);
+ try testing.expect(p.look_hover_preview != null);
+ }
+ const waiting = p.look_hover_wait;
+ const preview = p.look_hover_preview;
+ const requests = [_]Req{
+ .{ .tag = 1, .op = .lookup, .node = root, .data = "screen" },
+ .{ .tag = 1, .op = .lookup, .node = root, .data = "new" },
+ .{ .tag = 1, .op = .getattr, .node = @intFromEnum(TopFile.new) },
+ .{ .tag = 2, .op = .getattr, .node = body },
+ .{ .tag = 3, .op = .open, .node = body },
+ .{ .tag = 4, .op = .read, .node = body, .size = 5 },
+ .{ .tag = 5, .op = .release, .node = body, .handle = 1 },
+ .{ .tag = 6, .op = .readdir, .node = root, .size = 4096 },
+ .{ .tag = 7, .op = .open, .node = screen_node },
+ .{ .tag = 8, .op = .read, .node = screen_node, .handle = 1, .size = 32 },
+ .{ .tag = 9, .op = .release, .node = screen_node, .handle = 1 },
+ };
+ for (requests) |req| {
+ const answer = call(p, req);
+ try testing.expectEqual(Status.ok, answer.reply.status);
+ try testing.expect(std.meta.eql(waiting, p.look_hover_wait));
+ try testing.expect(std.meta.eql(preview, p.look_hover_preview));
+ try testing.expect(p.raw_hover_intent);
+ }
+ }
+ try testing.expectEqual(Status.ok, wr(p, body, "changed").reply.status);
+ try testing.expect(p.look_hover_wait == null);
+ try testing.expect(p.look_hover_preview == null);
+ try testing.expect(!p.raw_hover_intent);
+}
+
+test "filesystem pane creation and truncation cancel Look hover" {
+ const requests = [_]Req{
+ .{ .tag = 2, .op = .open, .node = @intFromEnum(TopFile.new) },
+ .{ .tag = 3, .op = .setattr, .node = 0, .truncate = true },
+ };
+ for (requests) |request| {
+ const p = try withFile(testing.allocator, "word\n");
+ defer p.deinit();
+ p.look_hover_wait = .{ .col = 1, .row = 1, .pane = 0, .serial = serialOf(p) };
+ p.raw_hover_intent = true;
+ var req = request;
+ if (req.op == .setattr) req.node = Node.of(serialOf(p), .body);
+ try testing.expectEqual(Status.ok, call(p, req).reply.status);
+ try testing.expect(p.look_hover_wait == null);
+ try testing.expect(p.look_hover_preview == null);
+ try testing.expect(!p.raw_hover_intent);
+ }
+}
+
+test "readdir lists the root and a pane directory without creating anything" {
+ const gpa = testing.allocator;
+ const p = try withFile(gpa, "hello\n");
+ defer p.deinit();
+ const serial = serialOf(p);
+ var buf: [32]th.Dirent = undefined;
+
+ const before = p.next_serial;
+ const top_reply = rdir(p, root, 0);
+ try testing.expectEqual(Status.ok, top_reply.reply.status);
+ var top_bytes: [4096]u8 = undefined;
+ @memcpy(top_bytes[0..top_reply.bytes.len], top_reply.bytes);
+ const top = th.dirents(top_bytes[0..top_reply.bytes.len], &buf);
+ try testing.expect(top.len >= 9);
+ for ([_][]const u8{ "README", "index", "ctl", "new", "log", "screen", "listeners", "pane", "os" }, 0..) |name, i| {
+ try testing.expectEqualStrings(name, top[i].name);
+ try testing.expectEqual(i >= 7, top[i].dir);
+ }
+ try testing.expectEqual(@intFromEnum(TopFile.pane), top[7].node);
+ try testing.expectEqual(fs.os_root, top[8].node);
+ var idbuf: [16]u8 = undefined;
+ try testing.expect(th.nameAt(top, try std.fmt.bufPrint(&idbuf, "{d}", .{serial})) == null);
+ try testing.expect(th.nameAt(top, "self") == null);
+ try testing.expect(th.nameAt(top, "cons") == null);
+
+ var rest_buf: [32]th.Dirent = undefined;
+ const rest = rdir(p, root, 3);
+ try testing.expectEqual(top.len - 3, th.dirents(rest.bytes, &rest_buf).len);
+ const eof = rdir(p, root, 99);
+ try testing.expectEqual(Status.ok, eof.reply.status);
+ try testing.expectEqual(@as(usize, 0), eof.bytes.len);
+
+ // Walk, stat and open-for-read every top entry a browser would, then every pane file.
+ for (top) |entry| {
+ if (std.mem.eql(u8, entry.name, "os")) continue;
+ const inspected = look_up(p, root, entry.name);
+ try testing.expectEqual(Status.ok, inspected.reply.status);
+ try testing.expectEqual(entry.node, inspected.reply.attr.node);
+ try testing.expectEqual(Status.ok, call(p, .{ .tag = 1, .op = .getattr, .node = entry.node }).reply.status);
+ try testing.expectEqual(before, p.next_serial);
+ }
+ const panes_dir = rdir(p, @intFromEnum(TopFile.pane), 0);
+ const listed = th.dirents(panes_dir.bytes, &buf);
+ try testing.expectEqual(@as(usize, 1), listed.len);
+ try testing.expectEqual(Node.of(serial, .dir), listed[0].node);
+
+ const dir = rdir(p, Node.of(serial, .dir), 0);
+ const files = th.dirents(dir.bytes, &buf);
+ try testing.expectEqual(@as(usize, 10), files.len);
+ for ([_][]const u8{ "name", "body", "tag", "ctl", "addr", "data", "xdata", "sel", "errors", "event" }, 0..) |name, i|
+ try testing.expectEqualStrings(name, files[i].name);
+ try testing.expect(th.nameAt(files, ".") == null);
+ try testing.expect(th.nameAt(files, "rdsel") == null);
+ try testing.expectEqual(Node.of(serial, .body), th.nameAt(files, "body").?.node);
+ for (files) |entry| {
+ const stat = call(p, .{ .tag = 1, .op = .getattr, .node = entry.node });
+ try testing.expectEqual(Status.ok, stat.reply.status);
+ try testing.expectEqual(p.fs.started, stat.reply.attr.mtime);
+ }
+ try testing.expectEqual(before, p.next_serial);
+
+ try testing.expectEqual(E.NOTDIR, rdir(p, Node.of(serial, .body), 0).errno());
+ try testing.expectEqual(E.NOTDIR, rdir(p, @intFromEnum(TopFile.index), 0).errno());
+}
+
+test "lookup resolves top files, pane serials and pane files" {
+ const gpa = testing.allocator;
+ const p = try withFile(gpa, "hello\n");
+ defer p.deinit();
+ const serial = serialOf(p);
+
+ try testing.expectEqual(@as(u64, @intFromEnum(TopFile.index)), look_up(p, root, "index").reply.attr.node);
+ try testing.expect(!look_up(p, root, "new").reply.attr.dir);
+ try testing.expectEqual(@as(u16, 0o444), look_up(p, root, "new").reply.attr.mode);
+ try testing.expect(look_up(p, root, "pane").reply.attr.dir);
+ try testing.expect(look_up(p, root, "os").reply.attr.dir);
+ try testing.expectEqual(E.NOENT, look_up(p, root, "nosuchthing").errno());
+ try testing.expectEqual(E.NOENT, look_up(p, root, "self").errno());
+ try testing.expectEqual(E.NOENT, look_up(p, root, "cons").errno());
+ try testing.expectEqual(E.NOTDIR, look_up(p, @intFromEnum(TopFile.index), "x").errno());
+
+ var idbuf: [16]u8 = undefined;
+ const serial_name = try std.fmt.bufPrint(&idbuf, "{d}", .{serial});
+ try testing.expectEqual(E.NOENT, look_up(p, root, serial_name).errno());
+ const dir = look_up(p, @intFromEnum(TopFile.pane), serial_name);
+ try testing.expectEqual(Node.of(serial, .dir), dir.reply.attr.node);
+ try testing.expect(dir.reply.attr.dir);
+ try testing.expectEqualStrings(serial_name, dir.reply.attr.name);
+ try testing.expectEqual(E.NOENT, look_up(p, @intFromEnum(TopFile.pane), "99999").errno());
+ try testing.expectEqual(E.NOENT, look_up(p, @intFromEnum(TopFile.pane), "0").errno());
+
+ const body = look_up(p, Node.of(serial, .dir), "body");
+ try testing.expectEqual(Node.of(serial, .body), body.reply.attr.node);
+ const stat = call(p, .{ .tag = 4, .op = .getattr, .node = Node.of(serial, .body) });
+ try testing.expectEqual(body.reply.attr.size, stat.reply.attr.size);
+ try testing.expectEqual(@as(u64, "hello\n".len), stat.reply.attr.size);
+ try testing.expectEqual(E.NOENT, look_up(p, Node.of(serial, .dir), "editout").errno());
+ try testing.expectEqual(E.NOENT, look_up(p, Node.of(serial, .dir), "rdsel").errno());
+ try testing.expectEqual(E.NOTDIR, look_up(p, Node.of(serial, .body), "x").errno());
+}
+
+test "each open of new creates exactly one pane whose serial the read answers" {
+ const gpa = testing.allocator;
+ const p = try withFile(gpa, "first\n");
+ defer p.deinit();
+ const before = p.next_serial;
+ const new = @intFromEnum(TopFile.new);
+
+ try testing.expectEqual(Status.ok, look_up(p, root, "new").reply.status);
+ try testing.expectEqual(Status.ok, call(p, .{ .tag = 1, .op = .getattr, .node = new }).reply.status);
+ try testing.expectEqual(before, p.next_serial);
+
+ const opened = call(p, .{ .tag = 1, .op = .open, .node = new });
+ try testing.expectEqual(Status.ok, opened.reply.status);
+ try testing.expectEqual(before + 1, p.next_serial);
+ try testing.expectEqual(before + 1, opened.reply.handle);
+ try testing.expectEqual(@as(u64, 0), opened.reply.attr.node);
+ var expected: [16]u8 = undefined;
+ const line = try std.fmt.bufPrint(&expected, "{d}\n", .{opened.reply.handle});
+ try testing.expectEqualStrings(line, call(p, .{ .tag = 2, .op = .read, .node = new, .handle = opened.reply.handle, .size = 64 }).bytes);
+ try testing.expectEqualStrings(line[1..], call(p, .{ .tag = 2, .op = .read, .node = new, .handle = opened.reply.handle, .off = 1, .size = 64 }).bytes);
+ try testing.expectEqualStrings("", call(p, .{ .tag = 2, .op = .read, .node = new, .handle = opened.reply.handle, .off = line.len, .size = 64 }).bytes);
+ try testing.expectEqual(before + 1, p.next_serial);
+ _ = call(p, .{ .tag = 3, .op = .release, .node = new, .handle = opened.reply.handle });
+
+ const id = p.paneBySerial(opened.reply.handle).?;
+ try testing.expectEqualStrings("", p.panes[id].?.file.?.content);
+ _ = wr(p, Node.of(opened.reply.handle, .body), "hi");
+ const second = call(p, .{ .tag = 4, .op = .open, .node = new });
+ try testing.expectEqual(Status.ok, second.reply.status);
+ try testing.expect(second.reply.handle != opened.reply.handle);
+ try testing.expectEqualStrings("hi", p.panes[id].?.file.?.content);
+ try testing.expectEqual(E.INVAL, call(p, .{ .tag = 5, .op = .read, .node = new, .size = 64 }).errno());
+}
+
+test "directories walk to their parents" {
+ const p = try withFile(testing.allocator, "contents\n");
+ defer p.deinit();
+ for ([_]struct { node: u64, parent: u64 }{
+ .{ .node = root, .parent = root },
+ .{ .node = @intFromEnum(TopFile.pane), .parent = root },
+ .{ .node = Node.of(serialOf(p), .dir), .parent = @intFromEnum(TopFile.pane) },
+ .{ .node = fs.os_root, .parent = root },
+ }) |case| {
+ const result = look_up(p, case.node, "..");
+ try testing.expectEqual(Status.ok, result.reply.status);
+ try testing.expectEqual(case.parent, result.reply.attr.node);
+ }
+ try testing.expectEqual(E.NOTDIR, look_up(p, @intFromEnum(TopFile.index), "..").errno());
+ try testing.expectEqual(E.NOTDIR, look_up(p, Node.of(serialOf(p), .body), "..").errno());
+}
+
+test "editor paths resolve to the same nodes the wire serves" {
+ const p = try withFile(testing.allocator, "contents\n");
+ defer p.deinit();
+ const serial = serialOf(p);
+ var path: [64]u8 = undefined;
+ try testing.expectEqual(root, resolveSelf(p, "").?);
+ try testing.expectEqual(root, resolveSelf(p, "/./pane/../").?);
+ try testing.expectEqual(@intFromEnum(TopFile.index), resolveSelf(p, "index").?);
+ try testing.expectEqual(@intFromEnum(TopFile.new), resolveSelf(p, "new").?);
+ try testing.expectEqual(@intFromEnum(TopFile.pane), resolveSelf(p, "pane").?);
+ try testing.expectEqual(Node.of(serial, .dir), resolveSelf(p, try std.fmt.bufPrint(&path, "pane/{d}", .{serial})).?);
+ try testing.expectEqual(Node.of(serial, .sel), resolveSelf(p, try std.fmt.bufPrint(&path, "pane/{d}/sel", .{serial})).?);
+ try testing.expect(resolveSelf(p, try std.fmt.bufPrint(&path, "pane/{d}/pty/ctl", .{serial})) == null);
+ try testing.expect(resolveSelf(p, try std.fmt.bufPrint(&path, "pane/{d}/rdsel", .{serial})) == null);
+ try testing.expect(resolveSelf(p, "self/index") == null);
+ try testing.expect(resolveSelf(p, "new/ctl") == null);
+ try testing.expect(resolveSelf(p, "cons") == null);
+ try testing.expect(resolveSelf(p, "pane/0") == null);
+}
diff --git a/src/panes.zig b/src/panes.zig
index 49e5d624..edd7038f 100644
--- a/src/panes.zig
+++ b/src/panes.zig
@@ -1014,6 +1014,8 @@ pub const File = struct {
/// Equal means the screen matches disk.
saved_revision: u32 = 0,
watch_after_save: bool = false,
+ /// Seconds of the last content change, for the filesystem's stat; zero until edited.
+ mtime: u32 = 0,
/// Non-null for a generated output buffer rather than an on-disk file.
output: ?Output.State = null,
mini: ?Mini.State = null,
@@ -1702,7 +1704,7 @@ pub const File = struct {
const pane = slot orelse continue;
if (pane.file) |*state| if (state == f) break i;
} else return;
- pardes.filesystem.noteReplace(p, id, false, f.content, new);
+ pardes.ctlfs.events.noteReplace(p, id, false, f.content, new);
}
pub fn setContent(p: *Pardes, f: *State, new: []u8) void {
@@ -1722,6 +1724,7 @@ pub const File = struct {
p.gpa.free(f.content);
f.content = new;
f.revision +%= 1;
+ f.mtime = pardes.ctlfs.events.now();
if (f.line_starts.len > 0) p.gpa.free(f.line_starts);
f.line_starts = &.{};
if (f.highlights.len > 0) p.tree_sitter_gpa.free(f.highlights);
@@ -2876,6 +2879,8 @@ pub const Output = struct {
else => return error.NotAnEffect,
}
if (!setting.enabled(builtins.capabilities)) return error.EffectUnavailable;
+ // Without -Dembed-sources the paths would name nothing this build can open.
+ if (!limits.embedded_sources) return error.EffectUnavailable;
const paths = effect_sources.forSetting(setting) orelse
return error.EffectUnavailable;
diff --git a/src/pardes.zig b/src/pardes.zig
index 5c0ac263..88442542 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -6,6 +6,7 @@ const mvzr = @import("mvzr");
pub const modal = @import("modal.zig");
pub const look = @import("look.zig");
pub const filesystem = @import("fs.zig");
+pub const ctlfs = @import("ninep/tree.zig");
pub const syntax = @import("syntax.zig");
pub const locations_config = @import("locations_config.zig");
const tracy = @import("tracy.zig");
@@ -4711,7 +4712,7 @@ pub const Event = union(enum) {
/// this must not clamp onto and preview the final grid cell.
pointer_leave,
tick,
- fs_req: filesystem.Req,
+ fs_req: ctlfs.Req,
};
pub const attach_name_max = 256;
@@ -4749,7 +4750,7 @@ pub const Effect = union(enum) {
/// Write the build-time theme ring below the per-user config directory.
/// The pane receives the completion/error message from the native host.
dump_themes: struct { pane: u8 },
- fs_reply: filesystem.Reply,
+ fs_reply: ctlfs.Reply,
attach: struct { pane: u8, name: Buf(attach_name_max) },
detach: struct { pane: u8 },
quit,
@@ -6109,6 +6110,7 @@ pub const Pardes = struct {
.fallback = .{ .gpa = gpa },
};
errdefer p.deinit();
+ p.fs.started = ctlfs.events.now();
for (opts.mounts) |mount| try p.fs.mount(gpa, mount.name, mount.dial);
p.opts.mounts = &.{};
p.opts.ninep_identity = .{};
@@ -6335,6 +6337,7 @@ pub const Pardes = struct {
const watched = (if (pane.file) |f| f.output == null else false) or pane.hasPdf();
for (p.panes, 0..) |slot, id| if (slot == pane) {
if (watched) p.emit(.{ .watch = .{ .pane = @intCast(id), .on = false } });
+ ctlfs.events.noteRetire(p, id, pane);
p.fs.forget(p.gpa, id);
// ...and so do bytes still queued for the pty it no longer has.
p.dropPendingWrite(id);
@@ -6380,6 +6383,7 @@ pub const Pardes = struct {
pane.serial = p.next_serial;
p.rects[id] = .{};
p.panes[id] = pane;
+ ctlfs.events.noteInstall(p, id);
}
pub fn newShell(p: *Pardes, id: usize, cwd: []const u8) !*Pane {
@@ -6924,7 +6928,7 @@ pub const Pardes = struct {
return pane.pdfPath();
}
- pub fn fsPayload(p: *const Pardes, r: filesystem.Reply) []const u8 {
+ pub fn fsPayload(p: *const Pardes, r: ctlfs.Reply) []const u8 {
return switch (r.payload) {
.none => &.{},
.staged => |n| p.fs.out.items[0..@min(n, p.fs.out.items.len)],
@@ -6961,6 +6965,7 @@ pub const Pardes = struct {
p.hostWriteFile(sf.pane, f.path, f.content);
const saved_pane = p.panes[sf.pane] orelse return;
if (saved_pane.serial != serial) return;
+ ctlfs.events.noteLog(p, .save, saved_pane);
const saved = if (saved_pane.file) |*file| file else return;
if (saved.saved_revision != saved.revision or !saved.watch_after_save) return;
saved.watch_after_save = false;
@@ -7116,7 +7121,7 @@ pub const Pardes = struct {
},
.output => |o| {
const pane = p.panes[o.pane] orelse return;
- filesystem.notePtyOutput(p, o.pane, o.bytes);
+ ctlfs.events.notePtyOutput(p, o.pane, o.bytes);
panes.Terminal.feedOutput(p, pane, o.bytes);
},
.eof => |e| p.removePane(e.pane) catch |err| {
@@ -7146,7 +7151,7 @@ pub const Pardes = struct {
},
.paste => |bytes| p.applyPaste(bytes),
.command => |line| _ = p.executeBuiltinLine(p.active, line),
- .fs_req => |r| p.emit(.{ .fs_reply = filesystem.handle(p, r) }),
+ .fs_req => |r| p.emit(.{ .fs_reply = ctlfs.handle(p, r) }),
.pinch => |scale| p.ov_pinch_scale = scale,
.touch_scroll => |delta| p.ov_touch_scroll_delta = delta,
.pointer_leave => {
@@ -7163,6 +7168,7 @@ pub const Pardes = struct {
_ = p.scratch.reset(.retain_capacity);
p.sync();
p.fsReport();
+ ctlfs.events.announce(p);
}
fn fsReport(p: *Pardes) void {
@@ -7174,7 +7180,7 @@ pub const Pardes = struct {
const snap = &p.fs.panes[id].tag_snap;
if (std.mem.eql(u8, snap.items, tag)) continue;
if (snap.capacity != 0 or snap.items.len != 0)
- filesystem.noteReplace(p, id, true, snap.items, tag);
+ ctlfs.events.noteReplace(p, id, true, snap.items, tag);
snap.clearRetainingCapacity();
snap.appendSlice(p.gpa, tag) catch {};
}
@@ -7451,7 +7457,7 @@ pub const Pardes = struct {
p.reportError(id, "name", err);
return false;
};
- filesystem.nameBuffer(p, id, full, true) catch |err| {
+ ctlfs.pane.nameBuffer(p, id, full, true) catch |err| {
p.reportError(id, "name", err);
return false;
};
@@ -11673,21 +11679,21 @@ pub const Pardes = struct {
) bool {
if (!p.fs.scripted(id)) return false;
const is_look = cmd == config.look_cmd;
- const action: filesystem.Action = if (is_look)
+ const action: ctlfs.events.Action = if (is_look)
(if (on_tag) .tag_look else .body_look)
else
(if (on_tag) .tag_exec else .body_exec);
const named = std.meta.stringToEnum(Builtin, commandText(text)) != null;
const range = p.gestureRange(id, text, on_tag, operand);
- var flag: u32 = if (named) filesystem.flag_builtin else 0;
- if (range.q0 == range.q1 and text.len > 0) flag |= filesystem.flag_expansion;
+ var flag: u32 = if (named) ctlfs.events.flag_builtin else 0;
+ if (range.q0 == range.q1 and text.len > 0) flag |= ctlfs.events.flag_expansion;
if (is_look) {
- if (!named and std.mem.indexOfAny(u8, text, "/.:") != null) flag |= filesystem.flag_filename;
- } else if (chorded) flag |= filesystem.flag_chorded;
- return filesystem.noteAction(p, id, action, range.q0, range.q1, flag, text);
+ if (!named and std.mem.indexOfAny(u8, text, "/.:") != null) flag |= ctlfs.events.flag_filename;
+ } else if (chorded) flag |= ctlfs.events.flag_chorded;
+ return ctlfs.events.noteAction(p, id, action, range.q0, range.q1, flag, text);
}
- fn gestureRange(p: *Pardes, id: usize, text: []const u8, on_tag: bool, operand: PointerOperand) filesystem.PaneState.Range {
+ fn gestureRange(p: *Pardes, id: usize, text: []const u8, on_tag: bool, operand: PointerOperand) ctlfs.pane.State.Range {
const pane = p.panes[id] orelse return .{};
if (on_tag) {
const tag = p.tagText(p.scratch.allocator(), pane) catch return .{};
diff --git a/src/tutor.txt b/src/tutor.txt
index ae22ede9..394a48a3 100644
--- a/src/tutor.txt
+++ b/src/tutor.txt
@@ -387,29 +387,30 @@ typed
pardes the 9P socket opens by default
- A directory per pane holding `body`, `tag`, `ctl`, `addr`, `data`,
- `event`, `pty/` and the rest, plus `index`, `cons` and `new/` at the
- top under /self. Every pane shell receives `$PARDES_9P` (the socket)
- and `$PARDES_PANE` (its serial). Use a 9P client to read and write:
+ A directory per pane holding `name`, `body`, `tag`, `ctl`, `addr`,
+ `data`, `sel`, `event`, `pty/` and the rest, plus `index`, `ctl`,
+ `new` and `log` at the root. Every pane shell receives `$PARDES_9P`
+ (the socket) and `$PARDES_PANE` (its serial). Use a 9P client:
- /self/pane/<serial>/body
- /self/index
- /self/new/ctl
+ /pane/<serial>/body
+ /index
+ /new reading it makes a pane, answers its serial
+ /ctl `look FILE:12` and `exec Save`, as the mouse does
A terminal pane also has `pty/`:
- /self/pane/3/pty/ctl winsize, sig
- /self/pane/3/pty/data terminal input/output
+ /pane/3/pty/ctl winsize, sig
+ /pane/3/pty/data terminal input/output
Over 9P the same tree answers plan9port, from anywhere:
9p -a $XDG_RUNTIME_DIR/pardes-9p-work.sock ls /
- 9p -a $XDG_RUNTIME_DIR/pardes-9p-work.sock read /self/index
+ 9p -a $XDG_RUNTIME_DIR/pardes-9p-work.sock read index
...and pardes is a 9P CLIENT too, so one session can read another's:
pardes --mount=peer=work
- /n/peer/self/pane/1/body Look opens the other session's body
+ /n/peer/pane/1/body Look opens the other session's body
TWO THINGS WORTH KNOWING. While a program holds a pane's `event` file
open, MIDDLE AND RIGHT CLICKS IN THAT PANE BELONG TO IT: pardes reports
@@ -467,7 +468,7 @@ typed
SPC is the leader (SPC ? lists every path)
SCRIPTING 9P is served by default on $PARDES_9P
- /self/pane/<id>/{body,tag,ctl,addr,data,event,pty/}
+ /pane/<id>/{name,body,tag,ctl,addr,data,sel,event,pty/}
a script holding `event` owns that pane's middle and right
clicks; writing a record back runs it
diff --git a/test/agent_session.py b/test/agent_session.py
index b920b006..aa87c845 100644
--- a/test/agent_session.py
+++ b/test/agent_session.py
@@ -52,21 +52,21 @@ def run(args, command, status):
client, _ = context.__enter__()
try:
try:
- while not client.read('/self/pane/1/body').strip():
+ while not client.read('/pane/1/body').strip():
time.sleep(.01)
started = time.monotonic()
- client.write('/self/pane/1/pty/data', shlex.join(['/bin/sh', str(script)]).encode() + b'\r')
+ client.write('/pane/1/pty/data', shlex.join(['/bin/sh', str(script)]).encode() + b'\r')
status['phase'] = 'readiness'
wait_ready(client, args.ready)
if args.prepare_key is not None:
status['phase'] = 'preparation'
- client.write('/self/pane/1/pty/data', args.prepare_key)
+ client.write('/pane/1/pty/data', args.prepare_key)
wait_ready(client, args.ready)
ready_ms = (time.monotonic() - started) * 1000
status.update(phase='history', required_body_rows=args.min_rows)
while True:
started = time.monotonic()
- body = client.read('/self/pane/1/body')
+ body = client.read('/pane/1/body')
body_read_ms = (time.monotonic() - started) * 1000
rows = body.count(b'\n') + int(bool(body) and not body.endswith(b'\n'))
status.update(body_bytes=len(body), body_rows=rows)
@@ -81,13 +81,13 @@ def run(args, command, status):
raise AssertionError('probe is already visible')
status['phase'] = 'probe-visible'
started = time.monotonic()
- client.write('/self/pane/1/pty/data', probe.encode())
+ client.write('/pane/1/pty/data', probe.encode())
while probe not in screen_text(client).replace('\n', ''):
time.sleep(.01)
write_ms = (time.monotonic() - started) * 1000
status['phase'] = 'probe-cleared'
started = time.monotonic()
- client.write('/self/pane/1/pty/data', b'\x15')
+ client.write('/pane/1/pty/data', b'\x15')
while True:
observed = screen_text(client)
status['observed_visible_text_sha256'] = hashlib.sha256(observed.encode()).hexdigest()
@@ -109,7 +109,7 @@ def run(args, command, status):
client.socket.settimeout(.25)
for _ in range(2):
try:
- client.write('/self/pane/1/pty/data', b'\x03')
+ client.write('/pane/1/pty/data', b'\x03')
except (OSError, EOFError):
pass
time.sleep(.05)
diff --git a/test/appearance_gallery.py b/test/appearance_gallery.py
index 7779efa9..0fa12136 100644
--- a/test/appearance_gallery.py
+++ b/test/appearance_gallery.py
@@ -80,21 +80,21 @@ def run(args):
before = set(panes(client))
command(client, source, 'Newcol')
right = require(wait_for(lambda: next((s for s in panes(client) if s not in before), None)), 'right column missing')
- client.write(f'/self/pane/{right}/ctl', f'name {root / "Review"}\n'.encode())
- client.write(f'/self/pane/{right}/body', PREFERENCE_NOTES if args.theme_selector else NOTES, truncate=True)
- client.write(f'/self/pane/{right}/ctl', b'clean\n')
+ client.write(f'/pane/{right}/name', f'{root / "Review"}\n'.encode())
+ client.write(f'/pane/{right}/body', PREFERENCE_NOTES if args.theme_selector else NOTES, truncate=True)
+ client.write(f'/pane/{right}/ctl', b'clean\n')
before = set(panes(client))
command(client, right, 'Tty')
terminal = require(wait_for(lambda: next((s for s in panes(client) if s not in before), None)), 'terminal missing')
- client.write(f'/self/pane/{terminal}/pty/data', b"PS1='$ '; printf '\\033[2J\\033[H\\033[1;36mPardes workspace\\033[0m\\n\\n'; printf 'Agave source: read-only\\nTerminal: ready\\n\\n'; printf '\\033[32mSmall touches. Same workflow.\\033[0m\\n'\r")
- require(wait_for(lambda: b'Small touches. Same workflow.' in client.read(f'/self/pane/{terminal}/body')), 'terminal output missing')
+ client.write(f'/pane/{terminal}/pty/data', b"PS1='$ '; printf '\\033[2J\\033[H\\033[1;36mPardes workspace\\033[0m\\n\\n'; printf 'Agave source: read-only\\nTerminal: ready\\n\\n'; printf '\\033[32mSmall touches. Same workflow.\\033[0m\\n'\r")
+ require(wait_for(lambda: b'Small touches. Same workflow.' in client.read(f'/pane/{terminal}/body')), 'terminal output missing')
def config_report():
before = set(panes(client))
command(client, right, 'Config')
pane = require(wait_for(lambda: next((s for s in panes(client) if s not in before), None)), 'Config output missing')
- text = client.read(f'/self/pane/{pane}/body').decode()
- client.write(f'/self/pane/{pane}/ctl', b'del\n')
+ text = client.read(f'/pane/{pane}/body').decode()
+ client.write(f'/pane/{pane}/ctl', b'exec Del\n')
return text
settings = config_report()
@@ -135,7 +135,7 @@ def run(args):
command(client, right, 'Theme ' + theme)
if args.theme_selector:
# Keep both quiet active gutter ink and vivid comments in view.
- client.write(f'/self/pane/{source}/ctl', f'look {source_path}:117\n'.encode())
+ client.write(f'/pane/{source}/ctl', f'look {source_path}:117\n'.encode())
take_frame('classic-' + theme)
if args.theme_selector:
frame = client.screen()
@@ -162,7 +162,7 @@ def run(args):
command(client, right, 'ThemeSel')
selector = require(wait_for(lambda: next((s for s in panes(client) if s not in before), None)),
'ThemeSel missing')
- body = client.read(f'/self/pane/{selector}/body').decode()
+ body = client.read(f'/pane/{selector}/body').decode()
native, legacy = body.split('# Legacy and imported themes', 1)
for theme in ('forge_black', 'forge_soft', 'orchard_black'):
require('Theme ' + theme + '\n' in native, 'native contrast variant below legacy heading')
@@ -171,14 +171,14 @@ def run(args):
'ThemeSel did not open at native first entry')
take_frame('theme-selector')
report['native_themes_first'] = True
- client.write(f'/self/pane/{selector}/ctl', b'del\n')
+ client.write(f'/pane/{selector}/ctl', b'exec Del\n')
command(client, terminal, 'Collapse')
for pet in (() if args.skip_pets else ('cat', 'frog', 'off')):
command(client, right, 'Pet ' + pet)
settings = config_report()
require('Pet: ' + pet + '\n' in settings, 'pet setting not applied')
# Returning focus to the source closes all transient report state.
- client.write(f'/self/pane/{source}/ctl', f'look {source_path}:1\n'.encode())
+ client.write(f'/pane/{source}/ctl', f'look {source_path}:1\n'.encode())
take_frame('pet-' + pet)
if pet != 'off':
frame = client.screen()
@@ -194,9 +194,9 @@ def run(args):
require(band_digest('pet-' + pet) != band_digest('pet-' + pet + '-later'),
'pet did not animate in workspace tag')
report['pets'].append(pet)
- require(client.read(f'/self/pane/{source}/body') == original == source_path.read_bytes(), 'Agave source changed')
+ require(client.read(f'/pane/{source}/body') == original == source_path.read_bytes(), 'Agave source changed')
report['source_unchanged'] = True
- client.write(f'/self/pane/{terminal}/pty/data', b'exit\r')
+ client.write(f'/pane/{terminal}/pty/data', b'exit\r')
(root / 'report.json').write_text(json.dumps(report, indent=2))
print(json.dumps(report, indent=2))
diff --git a/test/collapse_layout.py b/test/collapse_layout.py
index e549bd37..244ee57a 100644
--- a/test/collapse_layout.py
+++ b/test/collapse_layout.py
@@ -29,7 +29,7 @@ def geometry(client, names):
def toggle(client, serial):
"""Execute the default tag's Collapse word without changing any body or tag."""
- pane = f'/self/pane/{serial}'
+ pane = f'/pane/{serial}'
tag = client.read(pane + '/tag')
at = tag.rfind(b' Collapse')
require(at >= 0, 'default pane tag lacks Collapse')
@@ -57,8 +57,8 @@ def run(args):
for index, label in enumerate(names):
if index:
serials.append(new_pane(client, b''))
- pane = f'/self/pane/{serials[index]}'
- client.write(pane + '/ctl', ('name ' + label + '\n').encode())
+ pane = f'/pane/{serials[index]}'
+ client.write(pane + '/name', (label + '\n').encode())
client.write(pane + '/body', contents, truncate=True)
original = require(wait_for(lambda: geometry(client, names)), 'four pane tags not visible')
require(all(height > 1 for _, height in original), f'fixture has no body room: {original}')
@@ -82,7 +82,7 @@ def run(args):
require(wait_for(lambda: geometry(client, names) == original),
f'expansion did not restore original geometry: {geometry(client, names)}')
for serial in serials:
- require(client.read(f'/self/pane/{serial}/body') == contents, 'Collapse changed pane contents')
+ require(client.read(f'/pane/{serial}/body') == contents, 'Collapse changed pane contents')
require((root / (name + '.txt')).read_bytes() == b'initial\n', 'fixture source file changed')
print(f'{"TTY" if args.tty else "SDL"}: 7 local collapse/expand pairs, exact unrelated pane geometry, and content preservation passed')
diff --git a/test/column_tags.py b/test/column_tags.py
index 6173d38a..80b623bd 100644
--- a/test/column_tags.py
+++ b/test/column_tags.py
@@ -70,7 +70,7 @@ def await_text(client, row, text):
def tag(client, serial):
- return client.read(f'/self/pane/{serial}/tag').decode().rstrip('\n')
+ return client.read(f'/pane/{serial}/tag').decode().rstrip('\n')
def new_serial(client, previous):
@@ -79,10 +79,10 @@ def new_serial(client, previous):
def exec_tag_without_cleanup(client, serial, text):
"""Restore can replace the whole core, so never touch the old pane after it."""
- client.write(f'/self/pane/{serial}/tag', (' ' + text).encode())
- whole = client.read(f'/self/pane/{serial}/tag')
+ client.write(f'/pane/{serial}/tag', (' ' + text).encode())
+ whole = client.read(f'/pane/{serial}/tag')
start = whole.rfind(text.encode())
- client.write(f'/self/pane/{serial}/event', f'Mx{start} {start + len(text.encode())}\n'.encode())
+ client.write(f'/pane/{serial}/event', f'Mx{start} {start + len(text.encode())}\n'.encode())
def reconnect(address):
@@ -140,10 +140,10 @@ def run(args):
keys.click(*require(locate(client, 'Newcol', row=0), 'Newcol missing'), button=1)
right = require(wait_for(lambda: new_serial(client, before)), 'Newcol did not create its pane')
right_name = root / 'right.rs'
- client.write(f'/self/pane/{right}/ctl', f'name {right_name}\n'.encode())
+ client.write(f'/pane/{right}/name', f'{right_name}\n'.encode())
right_body = b'// Column-local scratch\nfn main() {\n let ready = true;\n}\n'
- client.write(f'/self/pane/{right}/body', right_body)
- client.write(f'/self/pane/{right}/ctl', b'clean\n')
+ client.write(f'/pane/{right}/body', right_body)
+ client.write(f'/pane/{right}/ctl', b'clean\n')
require(wait_for(lambda: locate(client, 'New Tty', row=1, minimum_col=75)), 'right column tag missing')
require(locate(client, 'New Tty', row=1, minimum_col=75) == (75, 1),
'right column tag retains an inset')
@@ -171,9 +171,9 @@ def run(args):
before = panes(client)
keys.click(col_start, 1, button=1)
extra = require(wait_for(lambda: new_serial(client, before)), 'right-column New did not create a pane')
- client.write(f'/self/pane/{extra}/body', b'RIGHT-COLUMN-NEW\n')
+ client.write(f'/pane/{extra}/body', b'RIGHT-COLUMN-NEW\n')
require(wait_for(lambda: locate(client, 'RIGHT-COLUMN-NEW', minimum_col=75)), 'right-column command targeted another column')
- client.write(f'/self/pane/{extra}/ctl', b'delete\n')
+ client.write(f'/pane/{extra}/ctl', b'exec Del\n')
checks.append('middle-click New targets the inactive right column')
# An owned short-path file makes its complete tag visible. Pane-tag
@@ -197,7 +197,7 @@ def run(args):
keys.escape()
require(wait_for(lambda: tag(client, right).startswith(str(right_name) + ' ')), 'Escape did not cancel filename draft')
require(not (root / 'Xright.rs').exists(), 'editing a filename wrote a file')
- require(client.read(f'/self/pane/{right}/body') == right_body, 'tag editing changed file body')
+ require(client.read(f'/pane/{right}/body') == right_body, 'tag editing changed file body')
checks.append('filename draft cancels without disk writes or body changes')
# Keep a real embedded shell in the right column for useful captures.
@@ -205,9 +205,9 @@ def run(args):
before = panes(client)
keys.click(*require(locate(client, 'Tty', row=1, minimum_col=75), 'Tty missing'), button=1)
terminal = require(wait_for(lambda: new_serial(client, before)), 'column Tty did not create a pane')
- require('pty' in client.list(f'/self/pane/{terminal}'), 'Tty created a non-terminal pane')
- client.write(f'/self/pane/{terminal}/pty/data', b"printf 'Column-local terminal ready\\n'; pwd\r")
- require(wait_for(lambda: b'\nColumn-local terminal ready\n' in client.read(f'/self/pane/{terminal}/body').replace(b'\r', b'')), 'terminal command output missing')
+ require('pty' in client.list(f'/pane/{terminal}'), 'Tty created a non-terminal pane')
+ client.write(f'/pane/{terminal}/pty/data', b"printf 'Column-local terminal ready\\n'; pwd\r")
+ require(wait_for(lambda: b'\nColumn-local terminal ready\n' in client.read(f'/pane/{terminal}/body').replace(b'\r', b'')), 'terminal command output missing')
checks.append('right-column embedded terminal executes harmless commands')
# Persistence is checked through real Dump/Restore dispatch and the
@@ -285,11 +285,11 @@ def run(args):
capture(client, root, 'columns-tag-reveal', not args.tty)
keys.escape()
checks.append('long-path pane tag reveals its command tail when the caret moves to End')
- require(client.read(f'/self/pane/{source_serial}/body') == original == source.read_bytes(), 'Agave source changed')
+ require(client.read(f'/pane/{source_serial}/body') == original == source.read_bytes(), 'Agave source changed')
checks.append('Agave source bytes unchanged')
for serial in panes(client):
- if 'pty' in client.list(f'/self/pane/{serial}'):
- client.write(f'/self/pane/{serial}/pty/data', b'exit\r')
+ if 'pty' in client.list(f'/pane/{serial}'):
+ client.write(f'/pane/{serial}/pty/data', b'exit\r')
report = {'checks': checks, 'cols': 150, 'rows': 48, 'host': 'tty' if args.tty else 'sdl',
'input': 'keyboard and SGR mouse through the host PTY parser',
'source': str(source), 'artifacts': str(root)}
diff --git a/test/font_size.py b/test/font_size.py
index e40b7b3a..3a0a9a28 100644
--- a/test/font_size.py
+++ b/test/font_size.py
@@ -29,7 +29,7 @@ def run(args):
def ready():
for serial, tag in panes(client).items():
if '/+Config ' in tag:
- body = client.read(f'/self/pane/{serial}/body').decode()
+ body = client.read(f'/pane/{serial}/body').decode()
if f'Font effective size: {size:.2f} pixels' in body:
return body
return None
diff --git a/test/fs.py b/test/fs.py
index 2ffd5098..819a78d6 100644
--- a/test/fs.py
+++ b/test/fs.py
@@ -72,7 +72,7 @@ def session(binary, root, name, *options, socket_name=None, file=None, tty=False
time.sleep(.005)
with Client(address) as client:
if network is not None:
- for dial in client.read('/self/listeners').decode().splitlines():
+ for dial in client.read('/listeners').decode().splitlines():
parts = dial.split('!')
if parts[0] in ['tcp', 'quic']:
assert len(parts) == 3 and 0 < int(parts[2]) < 65536, dial
@@ -101,60 +101,136 @@ def session(binary, root, name, *options, socket_name=None, file=None, tty=False
def new_pane(client, contents):
- serial = int(client.read('/self/new/ctl').split()[0])
- client.write(f'/self/pane/{serial}/body', contents)
+ serial = int(client.read('/new').split()[0])
+ if contents:
+ client.write(f'/pane/{serial}/body', contents)
return serial
def look(client, path, source=None):
+ """A right click on `path` from a pane (a fresh scratch by default)."""
serial = source if source is not None else new_pane(client, b'')
- client.write(f'/self/pane/{serial}/body', path.encode(), truncate=True)
- client.write(f'/self/pane/{serial}/event', f'ML0 {len(path.encode())}\n'.encode())
- rows = client.read('/self/index').splitlines()
- opened = int(rows[-1].split()[0])
- assert opened != serial, f'Look did not open {path}'
- return opened
+ client.write(f'/pane/{serial}/ctl', f'look {path}\n'.encode())
+ results = [int(row) for row in client.read('/ctl').split()]
+ assert len(results) == 1 and results[0] != serial, (path, results)
+ return results[0]
def execute(client, serial, command):
- text = command.encode()
- client.write(f'/self/pane/{serial}/body', text, truncate=True)
- client.write(f'/self/pane/{serial}/event', f'MX0 {len(text)}\n'.encode())
+ """A middle click on `command` from a pane."""
+ client.write(f'/pane/{serial}/ctl', f'exec {command}\n'.encode())
-def discovery(binary):
+def walk_tree(client, path='/', skip=('/new', '/log', '/os', '/screen')):
+ """Stat every entry below `path`, as `find` does, without opening anything."""
+ seen = []
+ for name in client.list(path):
+ child = (path.rstrip('/') + '/' + name)
+ info = client.stat(child)
+ seen.append((child, info))
+ if info['dir'] and child not in skip:
+ seen.extend(walk_tree(client, child, skip))
+ return seen
+
+
+def discovery(binary, embedded=False):
with tempfile.TemporaryDirectory(prefix='pardes-discovery-') as directory:
- with session(binary, Path(directory), 'discovery') as (client, _):
- before = client.read('/self/index')
- entries = client.list('/self/new')
- assert {'README', 'ctl', 'body', 'addr', 'data', 'event'} <= set(entries)
- guide = client.read('/self/README')
- assert guide == client.read('/self/new/README')
- assert b'Each open creates a separate pane' in guide
- # Walk and stat each entry, as a filesystem browser does, without open.
- from ninep import string
- for name in entries:
- client.fid += 1
- fid = client.fid
- client.rpc(110, struct.pack('<IIH', 1, fid, 3) +
- string('self') + string('new') + string(name))
+ root = Path(directory)
+ with session(binary, root, 'discovery') as (client, _):
+ top = client.list('/')
+ assert top[:9] == ['README', 'index', 'ctl', 'new', 'log', 'screen', 'listeners', 'pane', 'os'], top
+ assert 'self' not in top and 'cons' not in top, top
+ assert ('src' in top) == embedded, (top, embedded)
+ before = client.read('/index')
+ guide = client.read('/README')
+ assert guide.count(b'\n') <= 45 and b'exec Msg' in guide and b'/new' in guide, guide
+ assert client.stat('/README')['length'] == len(guide)
+ # ls/stat/find over the whole tree, without opening, creates nothing.
+ seen = walk_tree(client)
+ names = {path for path, _ in seen}
+ assert {'/index', '/ctl', '/new', '/log', '/pane/1', '/pane/1/body', '/pane/1/name',
+ '/pane/1/sel', '/pane/1/ctl', '/pane/1/tag', '/pane/1/event'} <= names, sorted(names)
+ assert '/pane/1/pty' not in names, 'the fixture file pane grew a pty/'
+ assert not any(name.startswith('/self') or name.endswith('/rdsel') or name.endswith('/wrsel')
+ for name in names), sorted(names)
+ for path, info in seen:
+ assert info['mode'] in (0o755, 0o644, 0o666, 0o444, 0o222, 0o555), (path, info)
+ assert info['mtime'] > 0, (path, info)
+ assert client.read('/index') == before, 'browsing created a pane'
+ assert client.stat('/index')['length'] == len(before) > 0
+ index = client.stat('/index')
+ assert index['mode'] == 0o444 and client.stat('/ctl')['mode'] == 0o666
+ assert client.stat('/pane/1/body')['mode'] == 0o666 and client.stat('/pane')['mode'] == 0o755
+ fixture = int(before.split()[0])
+ assert before.split()[1:3] == [b'text', b'0'] and before.endswith(b'/discovery.txt\n'), before
+ # Before any command, /ctl reports the editor.
+ status = dict(line.split(maxsplit=1) for line in client.read('/ctl').decode().splitlines())
+ assert int(status['pid']) > 0 and status['version'] and int(status['panes']) == 1, status
+ # /new: one pane per open, its serial is the file's content.
+ log = client.open('/log')
+ first = int(client.read('/new').split()[0])
+ second = int(client.read('/new').split()[0])
+ assert first != second and first != fixture, (first, second)
+ assert client.read_fid(log) == f'new {first} {root}/+New\n'.encode()
+ assert client.read_fid(log) == f'new {second} {root}/+New\n'.encode()
+ assert set(client.list('/pane')) == {str(fixture), str(first), str(second)}
+ client.write(f'/pane/{first}/body', b'first pane', truncate=True)
+ assert client.read(f'/pane/{first}/body') == b'first pane'
+ assert client.read(f'/pane/{second}/body') == b''
+ assert client.stat(f'/pane/{first}/body')['length'] == len(b'first pane')
+ rows = {int(row.split()[0]): row.split() for row in client.read('/index').splitlines()}
+ assert rows[first][1:3] == [b'text', b'1'] and rows[second][1:3] == [b'text', b'0'], rows
+ # name: read the file name, write to rename; sel: the selection.
+ assert client.read(f'/pane/{first}/name') == f'{root}/+New\n'.encode()
+ client.write(f'/pane/{first}/name', b'notes.txt\n')
+ assert client.read(f'/pane/{first}/name') == f'{root}/notes.txt\n'.encode()
+ assert client.read_fid(log) == f'rename {first} {root}/notes.txt\n'.encode()
+ assert client.stat(f'/pane/{first}/name')['length'] == len(f'{root}/notes.txt\n')
+ client.write(f'/pane/{first}/addr', b'#0,#5')
+ client.write(f'/pane/{first}/ctl', b'dot=addr\n')
+ assert client.read(f'/pane/{first}/sel') == b'first'
+ assert client.stat(f'/pane/{first}/sel')['length'] == 5
+ client.write(f'/pane/{first}/sel', b'FIRST')
+ assert client.read(f'/pane/{first}/body') == b'FIRST pane'
+ assert client.read(f'/pane/{first}/sel') == b''
+ # exec Msg shows text; exec Save writes the named file; exec Del closes.
+ client.write(f'/pane/{first}/ctl', b'exec Msg hello from 9P\n')
+ assert 'hello from 9P' in ''.join(cell[0] for cell in client.screen()['cells'])
+ client.write(f'/pane/{first}/ctl', b'exec Save\n')
+ assert (root / 'notes.txt').read_bytes() == b'FIRST pane'
+ assert client.read_fid(log) == f'save {first} {root}/notes.txt\n'.encode()
+ assert client.read('/ctl') == f'{first}\n'.encode()
+ client.write('/ctl', b'exec Del\n')
+ assert client.read_fid(log) == f'del {second} {root}/+New\n'.encode()
+ assert str(second) not in client.list('/pane')
+ # look through /ctl opens a file; the serial reads back.
+ (root / 'opened.txt').write_bytes(b'one\ntwo\nthree\n')
+ client.write('/ctl', f'look {root}/opened.txt:2\n'.encode())
+ opened = int(client.read('/ctl'))
+ assert opened not in (fixture, first, second), opened
+ assert client.read(f'/pane/{opened}/body') == b'one\ntwo\nthree\n'
+ assert client.read(f'/pane/{opened}/name') == f'{root}/opened.txt\n'.encode()
+ assert client.read_fid(log) == f'new {opened} {root}/opened.txt\n'.encode()
+ client.write(f'/pane/{first}/ctl', f'look {root}/opened.txt:3\n'.encode())
+ assert client.read('/ctl') == f'{opened}\n'.encode(), 'a repeated look targets the open pane'
+ assert set(client.list('/pane')) == {str(fixture), str(first), str(opened)}
+ # Writing an event record back still performs the action it names.
+ client.write(f'/pane/{first}/body', b'Msg via event', truncate=True)
+ client.write(f'/pane/{first}/event', b'FX0 13\n')
+ assert 'via event' in ''.join(cell[0] for cell in client.screen()['cells'])
+ for bad in [b'Look x\n', b'Exec Msg x\n', b'put\n', b'name x\n', b'del\n']:
try:
- client.rpc(124, struct.pack('<I', fid))
- finally:
- client.close(fid)
- assert client.read('/self/index') == before, 'browsing created a pane'
- first = int(client.read('/self/new/ctl').split()[0])
- second = int(client.read('/self/new/ctl').split()[0])
- assert first != second
- client.write(f'/self/pane/{first}/body', b'first pane', truncate=True)
- assert client.read(f'/self/pane/{first}/body') == b'first pane'
- assert client.read(f'/self/pane/{second}/body') == b''
- before_ids = set(client.list('/self/pane'))
- client.write('/self/new/body', b'created with text', truncate=True)
- made = set(client.list('/self/pane')) - before_ids
- assert len(made) == 1
- assert client.read('/self/pane/' + made.pop() + '/body') == b'created with text'
- print('9P discovery: listing/stat/README are inert; opens create independent panes')
+ client.write('/ctl', bad)
+ except OSError:
+ pass
+ else:
+ raise AssertionError(f'{bad!r} was accepted')
+ client.close(log)
+ if embedded:
+ assert client.read('/src/pardes.zig').startswith(b'const std')
+ assert client.stat('/src/pardes.zig')['mode'] == 0o444
+ assert b'pub const Pardes' in client.read(f'/pane/{look(client, "/virtual/src/pardes.zig")}/body')
+ print('9P discovery: listing/stat/find are inert; new, ctl, name, sel and log behave')
def test(binary, quic=False):
@@ -188,14 +264,14 @@ def test(binary, quic=False):
with session(binary, root, 'renamed', '--9p=explicit', socket_name='explicit') as (named, named_address):
assert named_address.name == 'pardes-9p-explicit.sock'
assert not (root / 'pardes-9p-renamed.sock').exists()
- assert named.read('/self/pane/1/body') == b'initial\n'
+ assert named.read('/pane/1/body') == b'initial\n'
with session(binary, root, 'first') as (client, address):
- assert set(client.list('/')) == {'os', 'self'}
- assert {'pane', 'index', 'new'} <= set(client.list('/self'))
- assert client.read('/self/pane/1/body') == b'initial\n'
+ assert {'pane', 'index', 'new', 'ctl', 'log', 'os'} <= set(client.list('/'))
+ assert 'self' not in client.list('/')
+ assert client.read('/pane/1/body') == b'initial\n'
assert client.read('/os' + str(root / 'first.txt')) == b'initial\n'
assert 'first.txt' in client.list('/os' + str(root))
- assert client.read('/self/pane/1/../1/body') == b'initial\n'
+ assert client.read('/pane/1/../1/body') == b'initial\n'
assert client.read('/os' + str(root) + '/../' + root.name + '/first.txt') == b'initial\n'
listing = root / 'listing'
@@ -207,9 +283,9 @@ def test(binary, quic=False):
assert set(small.list('/os' + str(listing))) == {
f'entry-{number:02}' for number in range(24)}
- frozen = client.open('/self/screen')
+ frozen = client.open('/screen')
before = bytearray(client.read_fid(frozen, count=31))
- client.write('/self/pane/1/body', b'screen changed\n')
+ client.write('/pane/1/body', b'screen changed\n')
while chunk := client.read_fid(frozen, len(before)):
before.extend(chunk)
client.close(frozen)
@@ -220,12 +296,12 @@ def test(binary, quic=False):
payload = ('const value = "λ";\n' * 3000).encode()
serial = new_pane(client, payload)
- body = f'/self/pane/{serial}/body'
+ body = f'/pane/{serial}/body'
assert client.read(body) == payload
client.write(body, b'replaced\n', truncate=True)
assert client.read(body) == b'replaced\n'
- event = client.open(f'/self/pane/{serial}/event')
+ event = client.open(f'/pane/{serial}/event')
client.write(body, b'event\n')
record = client.read_fid(event)
assert record.startswith(b'EI') and b'event\n' in record, record
@@ -242,82 +318,83 @@ def test(binary, quic=False):
missing = False
try:
- client.read('/self/pane/4294967295/body')
+ client.read('/pane/4294967295/body')
except OSError:
missing = True
assert missing
assert client.read(body) == b'replaced\nevent\n'
- source = look(client, '/virtual/src/pardes.zig')
- assert b'pub const Pardes' in client.read(f'/self/pane/{source}/body')
+ if 'src' in client.list('/'):
+ source = look(client, '/virtual/src/pardes.zig')
+ assert b'pub const Pardes' in client.read(f'/pane/{source}/body')
(root / 'index').write_bytes(b'OS takes precedence\n')
opened = look(client, 'index')
- assert client.read(f'/self/pane/{opened}/body') == b'OS takes precedence\n'
+ assert client.read(f'/pane/{opened}/body') == b'OS takes precedence\n'
opened = look(client, '/n/self/index')
- index = client.read(f'/self/pane/{opened}/body')
+ index = client.read(f'/pane/{opened}/body')
assert index.split()[0] == b'1' and b'first.txt' in index, index
(root / 'explicit.txt').write_bytes(b'explicit OS mount\n')
opened = look(client, '/n/os' + str(root / 'explicit.txt'))
- assert client.read(f'/self/pane/{opened}/body') == b'explicit OS mount\n'
+ assert client.read(f'/pane/{opened}/body') == b'explicit OS mount\n'
own = root / 'pardes-9p-second.sock'
with session(binary, root, 'second', '--mount=peer=' + str(address),
'--mount=own=' + str(own)) as (remote, _):
- opened = look(remote, '/n/own/self/pane/1/body')
- assert remote.read(f'/self/pane/{opened}/body') == b'initial\n'
- opened = look(remote, '/n/peer/self/pane/' + str(serial) + '/body')
- assert remote.read(f'/self/pane/{opened}/body') == b'replaced\nevent\n'
- remote.write(f'/self/pane/{opened}/body', b'saved to peer\n', truncate=True)
- remote.write(f'/self/pane/{opened}/ctl', b'put\n')
+ opened = look(remote, '/n/own/pane/1/body')
+ assert remote.read(f'/pane/{opened}/body') == b'initial\n'
+ opened = look(remote, '/n/peer/pane/' + str(serial) + '/body')
+ assert remote.read(f'/pane/{opened}/body') == b'replaced\nevent\n'
+ remote.write(f'/pane/{opened}/body', b'saved to peer\n', truncate=True)
+ remote.write(f'/pane/{opened}/ctl', b'exec Save\n')
assert client.read(body) == b'saved to peer\n'
large = b'mounted file contents\n' * 60000
large_path = root / 'large.txt'
large_path.write_bytes(large)
opened = look(remote, '/n/peer/os' + str(large_path))
- assert remote.read(f'/self/pane/{opened}/body') == large
+ assert remote.read(f'/pane/{opened}/body') == large
edited = large[:-3] + b'edited\n'
- remote.write(f'/self/pane/{opened}/body', edited, truncate=True)
- remote.write(f'/self/pane/{opened}/ctl', b'put\n')
+ remote.write(f'/pane/{opened}/body', edited, truncate=True)
+ remote.write(f'/pane/{opened}/ctl', b'exec Save\n')
assert large_path.read_bytes() == edited
assert client.read('/os' + str(large_path)) == edited
with session(binary, root, 'runtime') as (remote, own_address):
control = new_pane(remote, b'')
execute(remote, control, 'Mount peer ' + str(address))
- opened = look(remote, '/n/peer/self/pane/1/body', source=control)
- assert remote.read(f'/self/pane/{opened}/body') == client.read('/self/pane/1/body')
+ opened = look(remote, '/n/peer/pane/1/body', source=control)
+ assert remote.read(f'/pane/{opened}/body') == client.read('/pane/1/body')
execute(remote, control, 'Mount peer ' + str(own_address))
execute(remote, control, 'Unmount peer')
- remote.write(f'/self/pane/{opened}/body', b'runtime mounted Save\n', truncate=True)
- remote.write(f'/self/pane/{opened}/ctl', b'put\n')
- assert client.read('/self/pane/1/body') == b'runtime mounted Save\n'
- assert remote.read('/self/pane/1/body') == b'initial\n'
+ remote.write(f'/pane/{opened}/body', b'runtime mounted Save\n', truncate=True)
+ remote.write(f'/pane/{opened}/ctl', b'exec Save\n')
+ assert client.read('/pane/1/body') == b'runtime mounted Save\n'
+ assert remote.read('/pane/1/body') == b'initial\n'
roots = look(remote, '/n', source=control)
- assert b'/n/peer/\n' in remote.read(f'/self/pane/{roots}/body')
- remote.write(f'/self/pane/{opened}/ctl', b'delete\n')
+ assert b'/n/peer/\n' in remote.read(f'/pane/{roots}/body')
+ remote.write(f'/pane/{opened}/ctl', b'exec Del\n')
execute(remote, control, 'Unmount peer')
- remote.write(f'/self/pane/{roots}/ctl', b'get\n')
- assert remote.read(f'/self/pane/{roots}/body') == b'/n/os/\n/n/self/\n'
+ remote.write(f'/pane/{roots}/ctl', b'get\n')
+ assert remote.read(f'/pane/{roots}/body') == b'/n/os/\n/n/self/\n'
execute(remote, control, 'Mount peer ' + str(own_address))
- opened = look(remote, '/n/peer/self/pane/1/body', source=control)
- assert remote.read(f'/self/pane/{opened}/body') == b'initial\n'
+ opened = look(remote, '/n/peer/pane/1/body', source=control)
+ assert remote.read(f'/pane/{opened}/body') == b'initial\n'
with session(binary, root, 'mounted-startup', '--mount=peer=' + str(address),
- file='/n/peer/self/pane/1/body:1') as (remote, _):
- assert remote.read('/self/pane/1/body') == b'runtime mounted Save\n'
+ file='/n/peer/pane/1/body:1') as (remote, _):
+ assert remote.read('/pane/1/body') == b'runtime mounted Save\n'
- client.write(f'/self/pane/{serial}/ctl', b'delete\n')
- assert str(serial) not in client.list('/self/pane')
+ client.write(f'/pane/{serial}/ctl', b'exec Del\n')
+ assert str(serial) not in client.list('/pane')
for tty in [False, True]:
name = 'restore tty space' if tty else 'restore detached space'
with session(binary, root, name, tty=tty) as (old, address):
- old.write('/self/pane/1/body', b'dumped state\n', truncate=True)
+ old.write('/pane/1/body', b'dumped state\n', truncate=True)
control = new_pane(old, b'')
execute(old, control, 'Restore ' + str(root / 'missing dump.zon'))
- assert old.read('/self/pane/1/body') == b'dumped state\n'
+ assert old.read('/pane/1/body') == b'dumped state\n'
execute(old, control, 'Mount own ' + str(address))
execute(old, control, 'Dump')
saved = root / (name + '.dump.zon')
@@ -325,12 +402,12 @@ def test(binary, quic=False):
while not saved.exists():
assert time.monotonic() < deadline, 'Dump did not write its state'
time.sleep(.005)
- held_body = old.open('/self/pane/1/body')
- held_screen = old.open('/self/screen')
- old_ids = {int(row.split()[0]) for row in old.read('/self/index').splitlines()}
+ held_body = old.open('/pane/1/body')
+ held_screen = old.open('/screen')
+ old_ids = {int(row.split()[0]) for row in old.read('/index').splitlines()}
assert old.read_fid(held_body) == b'dumped state\n'
assert old.read_fid(held_screen, count=1) == b'{'
- old.write('/self/pane/1/body', b'changed after dump\n', truncate=True)
+ old.write('/pane/1/body', b'changed after dump\n', truncate=True)
try:
execute(old, control, 'Restore ' + str(saved))
except (EOFError, ConnectionResetError, BrokenPipeError):
@@ -342,15 +419,15 @@ def test(binary, quic=False):
else:
raise AssertionError('Restore left an old body fid usable')
with Client(address) as restored:
- restored_ids = [int(row.split()[0]) for row in restored.read('/self/index').splitlines()]
+ restored_ids = [int(row.split()[0]) for row in restored.read('/index').splitlines()]
assert len(restored_ids) == 2 and old_ids.isdisjoint(restored_ids), restored_ids
file_id, control_id = restored_ids
- contents = restored.read(f'/self/pane/{file_id}/body')
- assert contents == b'dumped state\n', (contents, saved.read_text(), restored.read('/self/index'))
- assert restored.read(f'/self/pane/{control_id}/body') == b'Dump'
+ contents = restored.read(f'/pane/{file_id}/body')
+ assert contents == b'dumped state\n', (contents, saved.read_text(), restored.read('/index'))
+ assert restored.read(f'/pane/{control_id}/body') == b'', 'commands ran through ctl, not the body'
assert restored.screen()['cols'] == 80
opened = look(restored, '/n/own/os' + str(root / (name + '.txt')), source=control_id)
- assert restored.read(f'/self/pane/{opened}/body') == b'initial\n'
+ assert restored.read(f'/pane/{opened}/body') == b'initial\n'
with session(binary, root, 'forwarding') as (client, address):
child_dir = root / 'child-working-directory'
@@ -362,25 +439,25 @@ def test(binary, quic=False):
for word, expected, selected, reuse in [('space name.txt:2:4', spaced.read_bytes(), [14, 14], False),
('/n/self/pane/1/body', b'initial\n', None, False),
('/virtual/pane/1/body:1:2-4', b'initial\n', [1, 4], True)]:
- before = {int(row.split()[0]) for row in client.read('/self/index').splitlines()}
+ before = {int(row.split()[0]) for row in client.read('/index').splitlines()}
result = subprocess.run([binary, '--tty', word], cwd=child_dir, env=env,
capture_output=True, timeout=3)
assert result.returncode == 0, (word, result.stderr)
- after = {int(row.split()[0]) for row in client.read('/self/index').splitlines()}
+ after = {int(row.split()[0]) for row in client.read('/index').splitlines()}
if reuse:
assert after == before
else:
opened, = after - before
- assert client.read(f'/self/pane/{opened}/body') == expected
- assert client.read('/self/pane/1/body') == b'initial\n'
+ assert client.read(f'/pane/{opened}/body') == expected
+ assert client.read('/pane/1/body') == b'initial\n'
if selected is not None:
- addr = client.open(f'/self/pane/{opened}/addr')
- client.write(f'/self/pane/{opened}/ctl', b'addr=dot\n')
+ addr = client.open(f'/pane/{opened}/addr')
+ client.write(f'/pane/{opened}/ctl', b'addr=dot\n')
actual = list(map(int, client.read_fid(addr).split()))
client.close(addr)
assert actual == selected, (word, actual, selected)
- addr = client.open('/self/pane/1/addr', 2)
- ctl = client.open('/self/pane/1/ctl', 1)
+ addr = client.open('/pane/1/addr', 2)
+ ctl = client.open('/pane/1/ctl', 1)
for fid, text in [(addr, b'#0,#3'), (ctl, b'limit=addr\n'), (addr, b'#0')]:
client.rpc(118, struct.pack('<IQI', fid, 0, len(text)) + text)
try:
@@ -398,7 +475,7 @@ def test(binary, quic=False):
finally:
client.close(addr)
client.close(ctl)
- client.read('/self/pane/1/ctl')
+ client.read('/pane/1/ctl')
control = new_pane(client, b'')
execute(client, control, 'Dump')
@@ -407,10 +484,10 @@ def test(binary, quic=False):
while not saved.exists():
assert time.monotonic() < deadline, 'forwarding fixture Dump did not finish'
time.sleep(.005)
- before = client.read('/self/index')
+ before = client.read('/index')
inherited = {key: env[key] for key in ['PARDES_9P', 'PARDES_PANE', 'PARDES_FORWARD_LOOK']}
cases = [
- ('mount', ['--mount=peer=' + str(address), '/n/peer/self/pane/1/body'], None),
+ ('mount', ['--mount=peer=' + str(address), '/n/peer/pane/1/body'], None),
('name', ['--9p=forwarded-name'], 'forwarded-name'),
('tcp', ['--9p-tcp=tcp!127.0.0.1!0'], None),
('load', ['-l', str(saved)], None),
@@ -425,19 +502,19 @@ def test(binary, quic=False):
launch=[*([] if name == 'shells' else ['--tty']), *options],
inherited=inherited) as (local, local_address):
assert local_address != address
- index = local.read('/self/index').splitlines()
+ index = local.read('/index').splitlines()
if name == 'mount':
assert len(index) == 1 and b'/n/peer/' in index[0], index
- assert local.read('/self/pane/1/body') == b'initial\n'
+ assert local.read('/pane/1/body') == b'initial\n'
elif name == 'load':
- assert local.read('/self/index') == before
- assert local.read('/self/pane/1/body') == b'initial\n'
+ assert local.read('/index') == before
+ assert local.read('/pane/1/body') == b'initial\n'
else:
assert len(index) == (3 if name == 'shells' else 1), index
if name in ['tcp', 'quic']:
- assert (name + '!127.0.0.1!').encode() in local.read('/self/listeners')
- assert client.read('/self/index') == before
- assert client.read('/self/pane/1/body') == b'initial\n'
+ assert (name + '!127.0.0.1!').encode() in local.read('/listeners')
+ assert client.read('/index') == before
+ assert client.read('/pane/1/body') == b'initial\n'
for name, identity, word in [
('stale-file', dict(inherited, PARDES_9P=str(root / 'absent.sock')), str(spaced)),
@@ -450,11 +527,11 @@ def test(binary, quic=False):
launch=['--tty', *([word] if word else [])]) as (local, local_address):
assert local_address != address
if word == str(spaced):
- assert local.read('/self/pane/1/body') == spaced.read_bytes()
+ assert local.read('/pane/1/body') == spaced.read_bytes()
elif word:
- assert word.encode() in local.read('/self/index') or word in ''.join(
+ assert word.encode() in local.read('/index') or word in ''.join(
cell[0] for cell in local.screen()['cells'])
- assert client.read('/self/index') == before
+ assert client.read('/index') == before
for tty in [False, True]:
name = 'lsp-tty' if tty else 'lsp-detached'
@@ -463,23 +540,23 @@ def test(binary, quic=False):
execute(client, control, 'Lspinfo')
deadline = time.monotonic() + 3
while True:
- matches = [int(row.split()[0]) for row in client.read('/self/index').splitlines()
+ matches = [int(row.split()[0]) for row in client.read('/index').splitlines()
if b'+Lsp' in row]
if matches:
break
assert time.monotonic() < deadline, (name, 'Lspinfo did not complete')
time.sleep(.005)
assert len(matches) == 1, matches
- result = client.read(f'/self/pane/{matches[0]}/body')
+ result = client.read(f'/pane/{matches[0]}/body')
assert result.startswith(b'backend: zls-inproc+lsp-client\n'), result
assert b'protocol servers (lsp-client):' in result, result
- assert client.read('/self/pane/1/body') == b'initial\n'
+ assert client.read('/pane/1/body') == b'initial\n'
assert '+Lsp' in ''.join(cell[0] for cell in client.screen()['cells'])
with session(binary, root, 'highlight') as (client, _):
- client.write('/self/pane/1/ctl', b'name keywords.zig\n')
+ client.write('/pane/1/name', b'keywords.zig\n')
source = b'// padding\n' * 9999 + b'fn check() void {\n if (true) return;\n}\n'
- client.write('/self/pane/1/body', source, truncate=True)
+ client.write('/pane/1/body', source, truncate=True)
screen = client.screen()
assert len(screen['cells']) == screen['cols'] * screen['rows']
found = set()
@@ -508,9 +585,9 @@ def test(binary, quic=False):
client.write(wire_path, b'written through long filename\n', truncate=True)
assert path.read_bytes() == b'written through long filename\n'
opened = look(client, '/n/os' + str(path))
- assert client.read(f'/self/pane/{opened}/body') == path.read_bytes()
+ assert client.read(f'/pane/{opened}/body') == path.read_bytes()
opened = look(client, '/n/os' + str(directory))
- listing = client.read(f'/self/pane/{opened}/body')
+ listing = client.read(f'/pane/{opened}/body')
for path in directory.iterdir():
assert ('/n/os' + str(path)).encode() in listing
if quic:
@@ -526,66 +603,66 @@ def quic_test(binary):
ports = {}
with session(binary, root, 'server', '--9p-tcp=tcp!' + host + '!0',
'--9p-quic=quic!' + host + '!0', tty=tty, network=ports) as (unix, address):
- assert set(unix.read('/self/listeners').decode().splitlines()) == {
+ assert set(unix.read('/listeners').decode().splitlines()) == {
'unix!' + str(address), 'tcp!' + host + '!' + str(ports['TCP']),
'quic!' + host + '!' + str(ports['QUIC'])}
with Client((host, ports['TCP'])) as tcp:
- unix_body = unix.open('/self/pane/1/body')
- tcp_body = tcp.open('/self/pane/1/body')
+ unix_body = unix.open('/pane/1/body')
+ tcp_body = tcp.open('/pane/1/body')
with session(binary, root, 'client') as (remote, _):
control = new_pane(remote, b'')
dial = 'quic!' + host + '!' + str(ports['QUIC'])
execute(remote, control, 'Mount peer ' + dial)
- opened = look(remote, '/n/peer/self/pane/1/body', source=control)
- body = f'/self/pane/{opened}/body'
+ opened = look(remote, '/n/peer/pane/1/body', source=control)
+ body = f'/pane/{opened}/body'
assert remote.read(body) == b'initial\n'
payload = 'saved café λ\n'.encode() + b'x' * (3 * 8192 + 27) + b'\n'
remote.write(body, payload, truncate=True)
- remote.write(f'/self/pane/{opened}/ctl', b'put\n')
+ remote.write(f'/pane/{opened}/ctl', b'exec Save\n')
assert unix.read_fid(unix_body) == payload[:unix.msize - 11]
- assert tcp.read('/self/pane/1/body') == payload
- tcp.write('/self/pane/1/body', b'TCP to QUIC Get\n', truncate=True)
- remote.write(f'/self/pane/{opened}/ctl', b'get\n')
+ assert tcp.read('/pane/1/body') == payload
+ tcp.write('/pane/1/body', b'TCP to QUIC Get\n', truncate=True)
+ remote.write(f'/pane/{opened}/ctl', b'get\n')
assert remote.read(body) == b'TCP to QUIC Get\n'
assert unix.read_fid(unix_body) == b'TCP to QUIC Get\n'
assert tcp.read_fid(tcp_body) == b'TCP to QUIC Get\n'
- screen_pane = look(remote, '/n/peer/self/screen', source=control)
- screen = json.loads(remote.read(f'/self/pane/{screen_pane}/body'))
+ screen_pane = look(remote, '/n/peer/screen', source=control)
+ screen = json.loads(remote.read(f'/pane/{screen_pane}/body'))
assert len(screen['cells']) == screen['cols'] * screen['rows']
assert 'TCP to QUIC Get' in ''.join(cell[0] for cell in screen['cells'])
large = ('normal λ line\n' * 20000).encode()
fixture = root / 'large.txt'
fixture.write_bytes(large)
large_pane = look(remote, '/n/peer/os' + str(fixture), source=control)
- assert remote.read(f'/self/pane/{large_pane}/body') == large
+ assert remote.read(f'/pane/{large_pane}/body') == large
edited = b'QUIC file saved\n'
- remote.write(f'/self/pane/{large_pane}/body', edited, truncate=True)
- remote.write(f'/self/pane/{large_pane}/ctl', b'put\n')
+ remote.write(f'/pane/{large_pane}/body', edited, truncate=True)
+ remote.write(f'/pane/{large_pane}/ctl', b'exec Save\n')
assert fixture.read_bytes() == edited
for pane in [opened, screen_pane, large_pane]:
- remote.write(f'/self/pane/{pane}/ctl', b'delete\n')
+ remote.write(f'/pane/{pane}/ctl', b'exec Del\n')
execute(remote, control, 'Unmount peer')
roots = look(remote, '/n', source=control)
- assert remote.read(f'/self/pane/{roots}/body') == b'/n/os/\n/n/self/\n'
+ assert remote.read(f'/pane/{roots}/body') == b'/n/os/\n/n/self/\n'
assert unix.read_fid(unix_body) == b'TCP to QUIC Get\n'
assert tcp.read_fid(tcp_body) == b'TCP to QUIC Get\n'
control = new_pane(unix, b'')
execute(unix, control, 'Mount own ' + dial)
- own = look(unix, '/n/own/self/pane/1/body', source=control)
- assert unix.read(f'/self/pane/{own}/body') == b'TCP to QUIC Get\n'
- unix.write(f'/self/pane/{own}/body', b'same-session QUIC Save\n', truncate=True)
- unix.write(f'/self/pane/{own}/ctl', b'put\n')
+ own = look(unix, '/n/own/pane/1/body', source=control)
+ assert unix.read(f'/pane/{own}/body') == b'TCP to QUIC Get\n'
+ unix.write(f'/pane/{own}/body', b'same-session QUIC Save\n', truncate=True)
+ unix.write(f'/pane/{own}/ctl', b'exec Save\n')
assert unix.read_fid(unix_body) == b'same-session QUIC Save\n'
assert tcp.read_fid(tcp_body) == b'same-session QUIC Save\n'
- unix.write(f'/self/pane/{own}/ctl', b'delete\n')
+ unix.write(f'/pane/{own}/ctl', b'exec Del\n')
execute(unix, control, 'Unmount own')
unix.close(unix_body)
tcp.close(tcp_body)
if __name__ == '__main__':
- if len(sys.argv) == 3 and sys.argv[2] == '--discovery':
- discovery(str(Path(sys.argv[1]).resolve()))
+ if len(sys.argv) in [3, 4] and sys.argv[2] == '--discovery':
+ discovery(str(Path(sys.argv[1]).resolve()), embedded=sys.argv[3:] == ['--embedded'])
raise SystemExit(0)
if len(sys.argv) not in [2, 3] or (len(sys.argv) == 3 and sys.argv[2] != '--quic'):
raise SystemExit('usage: fs.py <pardes> [--quic | --discovery]')
diff --git a/test/fs_bench.zig b/test/fs_bench.zig
index 7fe49734..f9735ee2 100644
--- a/test/fs_bench.zig
+++ b/test/fs_bench.zig
@@ -1,6 +1,7 @@
const std = @import("std");
const pardes = @import("pardes");
const filesystem = pardes.filesystem;
+const ctlfs = pardes.ctlfs;
pub const std_options: std.Options = .{ .log_level = .err };
@@ -73,8 +74,8 @@ const Session = struct {
s.core.deinit();
}
- fn node(s: *const Session, file: filesystem.PaneFile) u64 {
- return filesystem.Node.of(s.file_serial, file);
+ fn node(s: *const Session, file: ctlfs.PaneFile) u64 {
+ return ctlfs.Node.of(s.file_serial, file);
}
};
@@ -90,9 +91,9 @@ fn checkNumbers(bytes: []const u8, expected: []const usize) ![]const u8 {
return std.mem.trimStart(u8, rest, " ");
}
-fn checkReply(s: *Session, req: filesystem.Req, reply: filesystem.Reply) !void {
+fn checkReply(s: *Session, req: ctlfs.Req, reply: ctlfs.Reply) !void {
const event_read = req.op == .read and req.node == s.node(.event);
- const expected_status: filesystem.Status = if (event_read) .again else .ok;
+ const expected_status: ctlfs.Status = if (event_read) .again else .ok;
if (reply.tag != req.tag or reply.errno != 0 or reply.status != expected_status)
return error.IncorrectReply;
const body = s.core.panes[s.file_id].?.file.?.content;
@@ -112,16 +113,18 @@ fn checkReply(s: *Session, req: filesystem.Req, reply: filesystem.Reply) !void {
if (reply.payload != .staged or reply.payload.staged != bytes.len or bytes.len > req.size)
return error.IncorrectReply;
const pane = s.core.panes[s.file_id].?;
+ if (req.node == @intFromEnum(ctlfs.TopFile.index)) {
+ var expected_buf: [4200]u8 = undefined;
+ const expected = try std.fmt.bufPrint(&expected_buf, "{d} text {d} {s}\n", .{ s.file_serial, @intFromBool(pane.file.?.revision != pane.file.?.saved_revision), pane.file.?.path });
+ if (!std.mem.eql(u8, expected, bytes)) return error.IncorrectReply;
+ return;
+ }
const rest = try checkNumbers(bytes, &.{ s.file_serial, s.tag.len, body.len, 0, @intFromBool(pane.file.?.revision != pane.file.?.saved_revision) });
if (req.node == s.node(.ctl)) {
const tail = try checkNumbers(rest, &.{pane.cols});
if (!std.mem.startsWith(u8, tail, "default ")) return error.IncorrectReply;
if ((try checkNumbers(tail[8..], &.{pardes.config.tab_width})).len != 0)
return error.IncorrectReply;
- } else if (req.node == @intFromEnum(filesystem.SelfFile.index)) {
- const tag = s.tag[0 .. std.mem.indexOfScalar(u8, s.tag, '\n') orelse s.tag.len];
- if (rest.len != tag.len + 1 or rest[rest.len - 1] != '\n' or !std.mem.eql(u8, rest[0..tag.len], tag))
- return error.IncorrectReply;
} else return error.UnmeasuredOperation;
}
},
@@ -129,8 +132,8 @@ fn checkReply(s: *Session, req: filesystem.Req, reply: filesystem.Reply) !void {
const bytes = s.core.fs.out.items;
if (reply.payload != .staged or reply.payload.staged != bytes.len or bytes.len > req.size)
return error.IncorrectReply;
- const names = [_][]const u8{ "index", "cons", "new", "pane", "screen", "listeners" };
- const nodes = [_]u64{ @intFromEnum(filesystem.SelfFile.index), @intFromEnum(filesystem.SelfFile.cons), @intFromEnum(filesystem.SelfFile.new), filesystem.namespace_panes, @intFromEnum(filesystem.SelfFile.screen), @intFromEnum(filesystem.SelfFile.listeners) };
+ const names = [_][]const u8{ "README", "index", "ctl", "new", "log", "screen", "listeners", "pane", "os" };
+ const nodes = [_]u64{ @intFromEnum(ctlfs.TopFile.README), @intFromEnum(ctlfs.TopFile.index), @intFromEnum(ctlfs.TopFile.ctl), @intFromEnum(ctlfs.TopFile.new), @intFromEnum(ctlfs.TopFile.log), @intFromEnum(ctlfs.TopFile.screen), @intFromEnum(ctlfs.TopFile.listeners), @intFromEnum(ctlfs.TopFile.pane), filesystem.os_root };
var off: usize = 0;
var entry: usize = 0;
while (off < bytes.len) : (entry += 1) {
@@ -140,7 +143,7 @@ fn checkReply(s: *Session, req: filesystem.Req, reply: filesystem.Reply) !void {
const size: usize = bytes[off + 9];
off += 10;
if (dir > 1 or size == 0 or size > bytes.len - off) return error.IncorrectReply;
- if (entry < names.len and (node != nodes[entry] or dir != @intFromBool(entry == 2 or entry == 3) or
+ if (entry < names.len and (node != nodes[entry] or dir != @intFromBool(entry >= 7) or
!std.mem.eql(u8, names[entry], bytes[off..][0..size]))) return error.IncorrectReply;
off += size;
}
@@ -151,21 +154,21 @@ fn checkReply(s: *Session, req: filesystem.Req, reply: filesystem.Reply) !void {
}
}
-fn bench(s: *Session, name: []const u8, reps: usize, req: filesystem.Req, note: []const u8) !void {
+fn bench(s: *Session, name: []const u8, reps: usize, req: ctlfs.Req, note: []const u8) !void {
std.debug.assert(reps > 0);
const initial_bytes = s.core.panes[s.file_id].?.file.?.content.len;
const initial_hash = if (req.op == .write) std.hash.Wyhash.hash(0, s.core.panes[s.file_id].?.file.?.content) else 0;
for (0..64) |_| {
- try checkReply(s, req, filesystem.handle(s.core, req));
+ try checkReply(s, req, ctlfs.handle(s.core, req));
_ = s.core.scratch.reset(.retain_capacity);
}
s.counting.allocations = 0;
- const expected_status: filesystem.Status = if (req.op == .read and req.node == s.node(.event)) .again else .ok;
+ const expected_status: ctlfs.Status = if (req.op == .read and req.node == s.node(.event)) .again else .ok;
var valid = true;
- var reply: filesystem.Reply = undefined;
+ var reply: ctlfs.Reply = undefined;
const start = nowNs();
for (0..reps) |_| {
- reply = filesystem.handle(s.core, req);
+ reply = ctlfs.handle(s.core, req);
valid = valid and reply.tag == req.tag and reply.status == expected_status and reply.errno == 0;
std.mem.doNotOptimizeAway(reply);
_ = s.core.scratch.reset(.retain_capacity);
@@ -223,7 +226,7 @@ pub fn main(init: std.process.Init) !void {
try bench(&s, "lookup ctl", reps, .{
.tag = 2,
.op = .lookup,
- .node = filesystem.Node.of(s.file_serial, .dir),
+ .node = ctlfs.Node.of(s.file_serial, .dir),
.data = "ctl",
}, "name -> node");
try bench(&s, "read body 4K", reps, .{
@@ -249,13 +252,13 @@ pub fn main(init: std.process.Init) !void {
try bench(&s, "read index", reps, .{
.tag = 6,
.op = .read,
- .node = @intFromEnum(filesystem.SelfFile.index),
+ .node = @intFromEnum(ctlfs.TopFile.index),
.size = 4096,
}, "one line per pane");
- try bench(&s, "readdir self", reps, .{
+ try bench(&s, "readdir root", reps, .{
.tag = 7,
.op = .readdir,
- .node = @intFromEnum(filesystem.SelfFile.root),
+ .node = ctlfs.root,
.size = 4096,
}, "staged dirents");
try bench(&s, "read event (empty)", reps, .{
diff --git a/test/fs_namespace.zig b/test/fs_namespace.zig
index c856148d..8b6f245e 100644
--- a/test/fs_namespace.zig
+++ b/test/fs_namespace.zig
@@ -13,13 +13,13 @@ test "runtime Mount and Unmount copy command arguments and change Look resolutio
try std.testing.expectEqualStrings("peer", p.fs.mounts.items[0].name);
try std.testing.expectEqualStrings("/tmp/a socket.sock", p.fs.mounts.items[0].dial);
var path: [4096]u8 = undefined;
- try std.testing.expectEqualStrings("/n/peer/self/index", filesystem.resolve(p, "/n/peer/self/index", "/", &path).?.path);
+ try std.testing.expectEqualStrings("/n/peer/index", filesystem.resolve(p, "/n/peer/index", "/", &path).?.path);
try std.testing.expect(p.executeBuiltinLine(0, "Mount peer /tmp/other.sock"));
try std.testing.expectEqual(@as(usize, 1), p.fs.mounts.items.len);
try std.testing.expectEqualStrings("/tmp/a socket.sock", p.fs.mounts.items[0].dial);
try std.testing.expect(p.executeBuiltinLine(0, "Unmount peer"));
try std.testing.expectEqual(@as(usize, 0), p.fs.mounts.items.len);
- try std.testing.expect(filesystem.resolve(p, "/n/peer/self/index", "/", &path) == null);
+ try std.testing.expect(filesystem.resolve(p, "/n/peer/index", "/", &path) == null);
for ([_][]const u8{ "Mount", "Mount name", "Mount os /tmp/peer.sock", "Mount peer tcp!127.0.0.1!0", "Unmount", "Unmount name extra" }) |invalid| {
try std.testing.expect(p.executeBuiltinLine(0, invalid));
try std.testing.expectEqual(@as(usize, 0), p.fs.mounts.items.len);
@@ -185,6 +185,7 @@ test "filesystem resolves OS first then virtual with explicit mounts and parent
const fallback = filesystem.resolve(p, "index", directory, &resolved_buffer) orelse return error.MissingVirtualFallback;
try std.testing.expectEqualStrings("/virtual/index", fallback.path);
+ if (!pardes.memory.limits.embedded_sources) return;
for ([_][]const u8{ "src/pardes.zig", "/virtual/src/pardes.zig", "/virtual/src/../src/pardes.zig" }) |word| {
const found = filesystem.resolve(p, word, directory, &resolved_buffer) orelse return error.MissingEmbeddedSource;
const bytes = try filesystem.read(p, found.path);
@@ -235,6 +236,7 @@ test "explicit OS file opens and restores keep their names and request watches"
}
test "virtual file opens do not request OS watches" {
+ if (!pardes.memory.limits.embedded_sources) return error.SkipZigTest;
const p = try pardes.Pardes.init(std.testing.allocator, .{ .tty_only = true });
defer p.deinit();
while (p.nextEffect()) |_| {}
@@ -243,16 +245,16 @@ test "virtual file opens do not request OS watches" {
while (p.nextEffect()) |effect| if (effect == .watch) return error.VirtualFileWatch;
}
-test "Look at the self factory reserves its own pane and failed opens release it" {
+test "Look at the clone file reserves its own pane and failed opens release it" {
const p = try pardes.Pardes.init(std.testing.allocator, .{ .tty_only = true });
defer p.deinit();
- p.lookAt(0, "/virtual/new/ctl");
+ p.lookAt(0, "/virtual/new");
var control_count: usize = 0;
var scratch_count: usize = 0;
for (p.panes) |slot| {
const pane = slot orelse continue;
const file = pane.file orelse continue;
- if (std.mem.eql(u8, file.path, "/virtual/new/ctl")) control_count += 1;
+ if (std.mem.eql(u8, file.path, "/virtual/new")) control_count += 1;
if (file.output) |output| if (output.from == .cmd and output.from.cmd == .New) {
scratch_count += 1;
};
diff --git a/test/fs_soak.py b/test/fs_soak.py
index 1201fda2..5569a7c7 100644
--- a/test/fs_soak.py
+++ b/test/fs_soak.py
@@ -56,7 +56,7 @@ def read_fid(client, fid, limit, count):
def check_body(client, pane, count=4096):
- fid = client.open(f'/self/pane/{pane["serial"]}/body')
+ fid = client.open(f'/pane/{pane["serial"]}/body')
try:
actual = read_fid(client, fid, BODY_LIMIT, count)
finally:
@@ -127,7 +127,7 @@ def run(args):
address = unix_address
if args.transport == 'tcp':
listeners = [line.split('!') for line in
- control.read('/self/listeners').decode().splitlines()
+ control.read('/listeners').decode().splitlines()
if line.startswith('tcp!')]
assert len(listeners) == 1, listeners
transport, host, port = listeners[0]
@@ -140,11 +140,12 @@ def run(args):
for slot in range(4):
body = payload(rng)
serial = 1 if slot == 0 else new_pane(client, body)
- base = f'/self/pane/{serial}'
+ base = f'/pane/{serial}'
if slot == 0:
client.write(base + '/body', body, truncate=True)
path = root / f'pane-{slot}-0.zig'
- client.write(base + '/ctl', f'name {path}\nput\n'.encode())
+ client.write(base + '/name', f'{path}\n'.encode())
+ client.write(base + '/ctl', b'exec Save\n')
panes.append({'serial': serial, 'body': body, 'path': path, 'variant': 0})
emit(kind='start', seed=args.seed, session_pid=pid,
transport=args.transport, host='tty' if args.tty else 'detached',
@@ -161,7 +162,7 @@ def run(args):
for operation in operations:
slot = rng.randrange(len(panes))
pane = panes[slot]
- base = f'/self/pane/{pane["serial"]}'
+ base = f'/pane/{pane["serial"]}'
if operation == 'append':
addition = payload(rng, min(256, BODY_LIMIT - len(pane['body'])))
client.write(base + '/body', addition)
@@ -185,7 +186,8 @@ def run(args):
elif operation == 'save':
pane['variant'] ^= 1
pane['path'] = root / f'pane-{slot}-{pane["variant"]}.zig'
- client.write(base + '/ctl', f'name {pane["path"]}\nput\n'.encode())
+ client.write(base + '/name', f'{pane["path"]}\n'.encode())
+ client.write(base + '/ctl', b'exec Save\n')
check_bytes(pane['path'].read_bytes(), pane['body'], 'saved file')
check_bytes(client.read('/os' + str(pane['path'])), pane['body'], 'OS mount')
elif operation == 'reload':
@@ -194,19 +196,20 @@ def run(args):
client.write(base + '/ctl', b'get\n')
elif operation == 'cycle':
old_serial = pane['serial']
- client.write(base + '/ctl', b'delete\n')
+ client.write(base + '/ctl', b'exec Del\n')
pane['body'] = payload(rng)
pane['serial'] = new_pane(client, pane['body'])
assert pane['serial'] != old_serial
- base = f'/self/pane/{pane["serial"]}'
- client.write(base + '/ctl', f'name {pane["path"]}\nput\n'.encode())
+ base = f'/pane/{pane["serial"]}'
+ client.write(base + '/name', f'{pane["path"]}\n'.encode())
+ client.write(base + '/ctl', b'exec Save\n')
elif operation == 'reconnect':
client.socket.close()
client = Client(address)
check_session(client, control, pid)
check_body(control, pane)
elif operation == 'screen':
- frozen = client.open('/self/screen')
+ frozen = client.open('/screen')
try:
before = read_fid(client, frozen, SCREEN_LIMIT, 4096)
check_screen(before)
@@ -217,7 +220,7 @@ def run(args):
check_bytes(after, before, 'frozen screen')
finally:
client.close(frozen)
- fresh = client.open('/self/screen')
+ fresh = client.open('/screen')
try:
check_screen(read_fid(client, fresh, SCREEN_LIMIT, 4096))
finally:
@@ -225,8 +228,8 @@ def run(args):
operation_count += 1
check_body(client, pane)
expected = {str(pane['serial']) for pane in panes}
- assert set(client.list('/self/pane')) == expected
- assert set(control.list('/self/pane')) == expected
+ assert set(client.list('/pane')) == expected
+ assert set(control.list('/pane')) == expected
for pane in panes:
check_body(client, pane)
check_body(control, pane)
diff --git a/test/look_placement.py b/test/look_placement.py
index e9918c5d..1341e776 100644
--- a/test/look_placement.py
+++ b/test/look_placement.py
@@ -70,7 +70,7 @@ def run_case(args, root, source_index):
require(len(serials) == 3, 'classic fixture did not boot three terminals')
markers = {}
for index, serial in enumerate(serials):
- pane = f'/self/pane/{serial}'
+ pane = f'/pane/{serial}'
require('pty' in client.list(pane), 'fixture contains an existing document')
marker = f'LP_{index}'
markers[serial] = marker
@@ -89,7 +89,7 @@ def run_case(args, root, source_index):
require(before[serials[0]][:2] == (0, 2) and before[serials[1]][0] == 0 and
before[serials[1]][1] > 2 and before[serials[2]][:2] == (75, 2),
f'unexpected initial classic layout: {before}')
- client.write(f'/self/pane/{source}/ctl', f'look {target}\n'.encode())
+ client.write(f'/pane/{source}/ctl', f'look {target}\n'.encode())
opened = require(wait_for(lambda: next((serial for serial in panes(client)
if serial not in serials), None)),
'Look did not open the first document')
@@ -111,9 +111,9 @@ def run_case(args, root, source_index):
require(rectangle_text(before_frame, before[serial]) ==
rectangle_text(after_frame, after[serial]),
f'Look changed unrelated terminal {serial} rendered text')
- require(client.read(f'/self/pane/{opened}/body') == contents,
+ require(client.read(f'/pane/{opened}/body') == contents,
'opened document contents differ')
- client.write(f'/self/pane/{source}/ctl', f'look {target}:2\n'.encode())
+ client.write(f'/pane/{source}/ctl', f'look {target}:2\n'.encode())
require(list(panes(client)) == serials + [opened], 'repeated Look created a duplicate pane')
require(wait_for(lambda: geometry(client, markers)[1] == after),
'repeated Look changed layout')
diff --git a/test/ninep.py b/test/ninep.py
index b128cc8a..6c625951 100644
--- a/test/ninep.py
+++ b/test/ninep.py
@@ -116,7 +116,44 @@ class Client:
self.close(fid)
def screen(self):
- return json.loads(self.read('/self/screen'))
+ return json.loads(self.read('/screen'))
+
+ def walk(self, path):
+ names = [part for part in os.fspath(path).split('/') if part]
+ self.fid += 1
+ fid = self.fid
+ oldfid = 1
+ try:
+ for start in range(0, max(1, len(names)), 16):
+ part = names[start:start + 16]
+ reply = self.rpc(110, struct.pack('<IIH', oldfid, fid, len(part)) +
+ b''.join(string(name) for name in part))
+ if struct.unpack_from('<H', reply)[0] != len(part):
+ raise FileNotFoundError(path)
+ oldfid = fid
+ return fid
+ except BaseException:
+ try:
+ self.close(fid)
+ except OSError:
+ pass
+ raise
+
+ def stat(self, path):
+ """Tstat without an open: {'name', 'mode', 'length', 'mtime', 'qid'}."""
+ fid = self.walk(path)
+ try:
+ reply = self.rpc(124, struct.pack('<I', fid))
+ finally:
+ self.close(fid)
+ size = struct.unpack_from('<H', reply, 2)[0]
+ record = reply[4:4 + size]
+ qid = record[6:19]
+ mode, _, mtime, length = struct.unpack_from('<IIIQ', record, 19)
+ name_len = struct.unpack_from('<H', record, 39)[0]
+ name = record[41:41 + name_len].decode()
+ return {'name': name, 'mode': mode & 0o777, 'dir': bool(mode & 0x80000000),
+ 'length': length, 'mtime': mtime, 'qid': qid}
def write(self, path, data, truncate=False):
fid = self.open(path, 1 | (16 if truncate else 0))
diff --git a/test/output.zig b/test/output.zig
index 21e9f04c..07a1ce23 100644
--- a/test/output.zig
+++ b/test/output.zig
@@ -575,6 +575,12 @@ test "EffectCode lists readable virtual sources used by this backend" {
const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true });
defer p.deinit();
+ if (!pardes.memory.limits.embedded_sources) {
+ const before = p.active;
+ try std.testing.expect(p.executeBuiltinLine(0, "EffectCode PanelSlide"));
+ try std.testing.expectEqual(before, p.active);
+ return error.SkipZigTest;
+ }
try std.testing.expect(p.executeBuiltinLine(0, "EffectCode PanelSlide"));
const out = p.panes[p.active].?.file.?;
try std.testing.expectEqualStrings(config.effect_code_buffer, std.fs.path.basename(out.path));
@@ -1035,10 +1041,10 @@ test "identical result refresh keeps warm content caches without allocation" {
const style_ptr = file.highlights.ptr;
const highlight_start = file.highlight_start;
const selected = result.vsel;
- const event_node = pardes.filesystem.Node.of(result.serial, .event);
- const opened = pardes.filesystem.handle(p, .{ .tag = 0, .op = .open, .node = event_node });
+ const event_node = pardes.ctlfs.Node.of(result.serial, .event);
+ const opened = pardes.ctlfs.handle(p, .{ .tag = 0, .op = .open, .node = event_node });
try std.testing.expectEqual(.ok, opened.status);
- defer _ = pardes.filesystem.handle(p, .{ .tag = 0, .op = .release, .node = event_node, .handle = opened.handle });
+ defer _ = pardes.ctlfs.handle(p, .{ .tag = 0, .op = .release, .node = event_node, .handle = opened.handle });
for (0..32) |_| {
const incoming = try p.gpa.dupe(u8, text);
diff --git a/test/panes.zig b/test/panes.zig
index 81015356..d3c29856 100644
--- a/test/panes.zig
+++ b/test/panes.zig
@@ -834,10 +834,10 @@ const ClickCursorTests = struct {
for (0..2) |_| p.update(.{ .mouse = .{ .button = .wheel_down, .kind = .press, .col = x + 2, .row = y + 4 } });
try std.testing.expectEqual(@as(usize, 2), pane.file.?.scroll);
_ = try p.render(frame.allocator());
- const event_node = filesystem.Node.of(pane.serial, .event);
- const opened = filesystem.handle(p, .{ .tag = 0, .op = .open, .node = event_node });
+ const event_node = pardes.ctlfs.Node.of(pane.serial, .event);
+ const opened = pardes.ctlfs.handle(p, .{ .tag = 0, .op = .open, .node = event_node });
try std.testing.expectEqual(.ok, opened.status);
- defer _ = filesystem.handle(p, .{ .tag = 0, .op = .release, .node = event_node, .handle = opened.handle });
+ defer _ = pardes.ctlfs.handle(p, .{ .tag = 0, .op = .release, .node = event_node, .handle = opened.handle });
for ([_]pardes.Mouse.Button{ .middle, .right }) |button| {
const events = &p.fs.panes[0].events;
@@ -1907,7 +1907,7 @@ test "file history allocation precedes a filesystem read with side effects" {
const original = p.panes[0].?;
const serial = p.next_serial;
failing.fail_index = failing.alloc_index + 2;
- try std.testing.expectError(error.OutOfMemory, panes.File.open(p, 1, "/n/self/new/ctl", 0));
+ try std.testing.expectError(error.OutOfMemory, panes.File.open(p, 1, "/n/self/new", 0));
try std.testing.expect(failing.has_induced_failure);
try std.testing.expectEqual(original, p.panes[0].?);
try std.testing.expectEqual(serial, p.next_serial);
diff --git a/test/pdf_scroll_bench.zig b/test/pdf_scroll_bench.zig
index 31aab270..33dfe70a 100644
--- a/test/pdf_scroll_bench.zig
+++ b/test/pdf_scroll_bench.zig
@@ -547,9 +547,9 @@ fn verifyIdentity(
" expected frames={d} images={d} visual={x:0>16} transport={x:0>16}\n" ++
" got frames={d} images={d} visual={x:0>16} transport={x:0>16}\n",
.{
- name, round, want.frames, want.images,
- want.visual, want.transport, got.frames, got.images,
- got.visual, got.transport,
+ name, round, want.frames, want.images,
+ want.visual, want.transport, got.frames, got.images,
+ got.visual, got.transport,
},
);
return error.UnstableIdentity;
@@ -569,8 +569,8 @@ fn reportText(config: Config, travel: Travel, results: []const Result) void {
std.debug.print(
"{s}: {d} pages, {d}px strip at {d}x{d} cells of {d}x{d}px\n",
.{
- config.path, travel.pages, travel.strip_px, bench_cols,
- bench_rows, bench_cell_pixels.w, bench_cell_pixels.h,
+ config.path, travel.pages, travel.strip_px, bench_cols,
+ bench_rows, bench_cell_pixels.w, bench_cell_pixels.h,
},
);
std.debug.print(
@@ -578,9 +578,9 @@ fn reportText(config: Config, travel: Travel, results: []const Result) void {
.{ config.warmup, config.reps },
);
std.debug.print("{s:<16} {s:>6} {s:>7} {s:>10} {s:>10} {s:>10} {s:>10} {s:>10} {s:>11} {s:>7} {s:>5} {s:<16} {s}\n", .{
- "scenario", "frames", "notches", "min",
- "median", "p90", "p99", "max",
- "total_us", "rasters", "kept", "visual",
+ "scenario", "frames", "notches", "min",
+ "median", "p90", "p99", "max",
+ "total_us", "rasters", "kept", "visual",
"transport",
});
std.debug.print("{s}\n", .{"-" ** 160});
@@ -618,7 +618,7 @@ fn reportJson(io: std.Io, config: Config, travel: Travel, results: []const Resul
"{{\"benchmark\":\"pardes-pdf-scroll\",\"build\":\"{s}\",\"path\":\"{s}\"," ++
"\"pages\":{d},\"strip_px\":{d},\"warmup\":{d},\"reps\":{d},\"results\":[",
.{
- bench_config.core_optimize, config.path, travel.pages,
+ bench_config.core_optimize, config.path, travel.pages,
travel.strip_px, config.warmup, config.reps,
},
) catch return;
diff --git a/test/snapshot.zig b/test/snapshot.zig
index a7a1ba48..7fa84a86 100644
--- a/test/snapshot.zig
+++ b/test/snapshot.zig
@@ -1151,7 +1151,7 @@ const Screen9p = struct {
fn snap9p(arena: std.mem.Allocator, h: *eh.Harness, label: []const u8, prev: *Prev) ![]u8 {
var dial_buf: [24]u8 = undefined;
const dial = try std.fmt.bufPrint(&dial_buf, "{d}", .{h.pid});
- const bytes = try Client.read(gpa, dial, "/self/screen", "/self/screen");
+ const bytes = try Client.read(gpa, dial, "/screen", "/screen");
defer gpa.free(bytes);
const parsed = try std.json.parseFromSlice(Screen9p, gpa, bytes, .{});
defer parsed.deinit();
diff --git a/test/tag_cleanup.py b/test/tag_cleanup.py
index 52ee8e9d..7b7d8bbf 100644
--- a/test/tag_cleanup.py
+++ b/test/tag_cleanup.py
@@ -28,7 +28,7 @@ def run(args):
launch=launch, inherited=inherited, terminal=terminal) as (client, _):
keys = HostInput(terminal['input_fd'])
pdf = next(serial for serial, tag in panes(client).items() if str(source) in tag)
- pdf_tag = client.read(f'/self/pane/{pdf}/tag').decode().strip()
+ pdf_tag = client.read(f'/pane/{pdf}/tag').decode().strip()
require(pdf_tag.startswith(str(source) + ' [1/'), 'PDF name/page prefix missing')
require(pdf_tag.endswith('] Tty Del PdfSections PdfTint Collapse'), 'PDF commands missing')
require(all(word not in pdf_tag for word in ['PdfFit', 'filtered', 'full', 'disabled']), 'PDF tag contains fit or tint-state clutter')
@@ -36,7 +36,7 @@ def run(args):
before = set(panes(client))
execute(client, control, 'Tty')
tty = require(wait_for(lambda: next((s for s in panes(client) if s not in before), None)), 'terminal missing')
- client.write(f'/self/pane/{control}/ctl', b'delete\n') # owned command-only scratch
+ client.write(f'/pane/{control}/ctl', b'exec Del\n') # owned command-only scratch
def tag_position():
frame = client.screen()
@@ -64,11 +64,11 @@ def run(args):
keys.send(b'\t')
require(wait_for(lambda: (p := tag_position()) and p[2] == ' '), 'Mode from edited tag ignored parked raw mode')
keys.send(b'\x02')
- client.write(f'/self/pane/{tty}/pty/data', b"printf 'Mode toggle ready\\n'\r")
- require(wait_for(lambda: b'Mode toggle ready' in client.read(f'/self/pane/{tty}/body')), 'terminal input failed')
+ client.write(f'/pane/{tty}/pty/data', b"printf 'Mode toggle ready\\n'\r")
+ require(wait_for(lambda: b'Mode toggle ready' in client.read(f'/pane/{tty}/body')), 'terminal input failed')
capture(client, root, 'pdf-and-tty-tags', not args.tty)
require(source.read_bytes() == original, 'PDF file changed')
- client.write(f'/self/pane/{tty}/pty/data', b'exit\r')
+ client.write(f'/pane/{tty}/pty/data', b'exit\r')
print('PDF tag, Mode click, Ctrl-B, edited-tag execution and source preservation passed')
diff --git a/test/ui_review.py b/test/ui_review.py
index df492eac..2b6faa29 100644
--- a/test/ui_review.py
+++ b/test/ui_review.py
@@ -21,7 +21,7 @@ from fs import session
def panes(client):
return {int(row.split(maxsplit=5)[0]): row.split(maxsplit=5)[5]
- for row in client.read('/self/index').decode().splitlines()}
+ for row in client.read('/index').decode().splitlines()}
def wait_for(fn, seconds=12):
deadline = time.monotonic() + seconds
@@ -34,7 +34,7 @@ def wait_for(fn, seconds=12):
def command(client, serial, text):
"""Execute a temporary tag command without changing source contents."""
- tag = f'/self/pane/{serial}/tag'
+ tag = f'/pane/{serial}/tag'
before = client.read(tag).rstrip(b'\n')
tail_start = before.index(b'Save Tty Del')
while tail_start and before[tail_start - 1:tail_start] == b' ':
@@ -42,13 +42,13 @@ def command(client, serial, text):
client.write(tag, b' ' + text.encode())
current = client.read(tag)
start = current.rfind(text.encode())
- client.write(f'/self/pane/{serial}/event', f'Mx{start} {start + len(text.encode())}\n'.encode())
- client.write(f'/self/pane/{serial}/ctl', b'cleartag\n')
+ client.write(f'/pane/{serial}/event', f'Mx{start} {start + len(text.encode())}\n'.encode())
+ client.write(f'/pane/{serial}/ctl', b'cleartag\n')
client.write(tag, before[tail_start:])
def new_output(client, before):
return next((serial for serial in panes(client) if serial not in before and
- client.read(f'/self/pane/{serial}/body').strip()), None)
+ client.read(f'/pane/{serial}/body').strip()), None)
def require(value, description):
if not value:
@@ -63,7 +63,7 @@ def capture(client, root, label, gui):
text = '\n'.join(''.join(cell[0] for cell in screen['cells'][at:at+cols])
for at in range(0, len(screen['cells']), cols))
(root / (label + '.txt')).write_text(text)
- (root / (label + '-index.txt')).write_text(client.read('/self/index').decode())
+ (root / (label + '-index.txt')).write_text(client.read('/index').decode())
if gui:
require(wait_for(lambda: (root / 'latest.ppm').exists(), 8), 'GPU capture missing')
def convert_frame():
@@ -106,28 +106,28 @@ def run(args):
before = panes(client)
command(client, source, 'Find crds_value')
find_pane = require(wait_for(lambda: new_output(client, before)), 'Find did not return results')
- require(b'crds_value.rs' in client.read(f'/self/pane/{find_pane}/body'), 'Find result missing source')
+ require(b'crds_value.rs' in client.read(f'/pane/{find_pane}/body'), 'Find result missing source')
report['find'] = True
capture(client, root, '02-find', not args.tty)
if find_pane:
- (root / 'find-results.txt').write_bytes(client.read(f'/self/pane/{find_pane}/body'))
- client.write(f'/self/pane/{find_pane}/ctl', b'del\n')
+ (root / 'find-results.txt').write_bytes(client.read(f'/pane/{find_pane}/body'))
+ client.write(f'/pane/{find_pane}/ctl', b'exec Del\n')
before = panes(client)
command(client, source, 'Grep CrdsValueLabel')
grep_pane = require(wait_for(lambda: new_output(client, before)), 'Grep did not return results')
- require(b'CrdsValueLabel' in client.read(f'/self/pane/{grep_pane}/body'), 'Grep result missing match')
+ require(b'CrdsValueLabel' in client.read(f'/pane/{grep_pane}/body'), 'Grep result missing match')
report['grep'] = True
if grep_pane:
- (root / 'grep-results.txt').write_bytes(client.read(f'/self/pane/{grep_pane}/body'))
+ (root / 'grep-results.txt').write_bytes(client.read(f'/pane/{grep_pane}/body'))
capture(client, root, '03-grep', not args.tty)
command(client, source, 'Tty')
terminal = require(wait_for(lambda: next((serial for serial in panes(client)
- if 'pty' in client.list(f'/self/pane/{serial}')), None)),
+ if 'pty' in client.list(f'/pane/{serial}')), None)),
'Tty did not create a terminal')
if terminal:
- client.write(f'/self/pane/{terminal}/pty/data', b"printf '\\033[1;36mAgave workspace\\033[0m\\n'; pwd; printf '\\033[32mRead-only UI review ready\\033[0m\\n'\r")
+ client.write(f'/pane/{terminal}/pty/data', b"printf '\\033[1;36mAgave workspace\\033[0m\\n'; pwd; printf '\\033[32mRead-only UI review ready\\033[0m\\n'\r")
require(wait_for(lambda: b'\nRead-only UI review ready\n' in
- client.read(f'/self/pane/{terminal}/body').replace(b'\r', b'')),
+ client.read(f'/pane/{terminal}/body').replace(b'\r', b'')),
'Terminal command output was not observed')
report['terminal'] = True
capture(client, root, '04-terminal', not args.tty)
@@ -137,46 +137,46 @@ def run(args):
symbols = wait_for(lambda: new_output(client, before), args.lsp_wait)
report['symbols'] = 'returned results' if symbols else 'no output before deadline'
if symbols:
- (root / 'symbols-results.txt').write_bytes(client.read(f'/self/pane/{symbols}/body'))
+ (root / 'symbols-results.txt').write_bytes(client.read(f'/pane/{symbols}/body'))
capture(client, root, '05-symbols', not args.tty)
if symbols:
- client.write(f'/self/pane/{symbols}/ctl', b'del\n')
+ client.write(f'/pane/{symbols}/ctl', b'exec Del\n')
offset = original.index(b'CrdsValue {')
- client.write(f'/self/pane/{source}/addr', f'#{offset},#{offset}'.encode())
- client.write(f'/self/pane/{source}/ctl', b'dot=addr\nshow\n')
+ client.write(f'/pane/{source}/addr', f'#{offset},#{offset}'.encode())
+ client.write(f'/pane/{source}/ctl', b'dot=addr\nshow\n')
before = panes(client)
command(client, source, 'Hover')
hover = wait_for(lambda: new_output(client, before), args.lsp_wait)
report['hover'] = 'returned results' if hover else 'no output before deadline'
if hover:
- (root / 'hover-results.txt').write_bytes(client.read(f'/self/pane/{hover}/body'))
+ (root / 'hover-results.txt').write_bytes(client.read(f'/pane/{hover}/body'))
capture(client, root, '06-hover', not args.tty)
# Look focuses the source without changing it; gd then travels through
# the host's real input parser, unlike tag builtin dispatch above.
line = original[:original.index(b'impl Sanitize for CrdsValue')].count(b'\n') + 1
- client.write(f'/self/pane/{source}/ctl', f'look {source_path}:{line}:19\n'.encode())
+ client.write(f'/pane/{source}/ctl', f'look {source_path}:{line}:19\n'.encode())
os.write(terminal_handle['input_fd'], b'gd')
time.sleep(1)
capture(client, root, '07-definition', not args.tty)
command(client, source, 'Lspinfo')
lsp = wait_for(lambda: next((serial for serial, tag in panes(client).items() if '+Lsp' in tag), None))
if lsp:
- info = client.read(f'/self/pane/{lsp}/body')
+ info = client.read(f'/pane/{lsp}/body')
(root / 'lsp-info.txt').write_bytes(info)
report['lsp'] = info.decode()
capture(client, root, '08-lsp-info', not args.tty)
if lsp:
- client.write(f'/self/pane/{lsp}/ctl', b'del\n')
+ client.write(f'/pane/{lsp}/ctl', b'exec Del\n')
for theme in args.themes:
command(client, source, 'Theme ' + theme)
capture(client, root, 'theme-' + theme, not args.tty)
for serial, tag in panes(client).items():
if any(word in tag for word in ['+Hover', '+Search']):
- (root / f'pane-{serial}.txt').write_bytes(client.read(f'/self/pane/{serial}/body'))
- assert client.read(f'/self/pane/{source}/body') == original == source_path.read_bytes()
+ (root / f'pane-{serial}.txt').write_bytes(client.read(f'/pane/{serial}/body'))
+ assert client.read(f'/pane/{source}/body') == original == source_path.read_bytes()
report['source_unchanged'] = True
if terminal:
- client.write(f'/self/pane/{terminal}/pty/data', b'exit\r')
+ client.write(f'/pane/{terminal}/pty/data', b'exit\r')
(root / 'report.json').write_text(json.dumps(report, indent=2))
print('artifacts', root, flush=True)
diff --git a/test/v9fs.py b/test/v9fs.py
index 7dc88195..16916d0b 100644
--- a/test/v9fs.py
+++ b/test/v9fs.py
@@ -42,25 +42,26 @@ def worker(mountpoint, socket, uid, gid, original_namespace):
assert len(mounted) == 1 and ' - 9p ' in mounted[0], mounted
assert not any(field.startswith(('shared:', 'master:')) for field in mounted[0].split()[6:])
- assert set(os.listdir(mountpoint)) == {'os', 'self'}
- tree = mountpoint / 'self'
- assert {'index', 'pane', 'new', 'screen'} <= set(os.listdir(tree))
+ tree = mountpoint
+ assert {'os', 'index', 'pane', 'new', 'ctl', 'log', 'screen', 'README'} <= set(os.listdir(tree))
+ assert 'self' not in os.listdir(tree)
# A direct connection provides independent evidence for VFS reads/writes.
with Client(socket) as client:
- assert (tree / 'index').read_bytes() == client.read('/self/index')
+ assert (tree / 'index').read_bytes() == client.read('/index')
assert (tree / 'pane/1/body').read_bytes() == b'initial\n'
- before = client.read('/self/index')
- subprocess.run(['ls', '-l', str(tree / 'new')], check=True, capture_output=True, timeout=5)
- assert {'README', 'ctl', 'body'} <= set(os.listdir(tree / 'new'))
- assert (tree / 'new/README').read_bytes() == (tree / 'README').read_bytes()
- assert client.read('/self/index') == before, 'browsing created panes'
- serial = int((tree / 'new/ctl').read_bytes().split()[0])
- another = int((tree / 'new/ctl').read_bytes().split()[0])
+ before = client.read('/index')
+ subprocess.run(['ls', '-l', str(tree), str(tree / 'pane' / '1')], check=True, capture_output=True, timeout=5)
+ subprocess.run(['find', str(tree / 'pane'), str(tree / 'new'), '-ls'], check=True, capture_output=True, timeout=5)
+ assert (tree / 'README').read_bytes() == client.read('/README')
+ assert (tree / 'new').stat().st_size == 0
+ assert client.read('/index') == before, 'browsing created panes'
+ serial = int((tree / 'new').read_bytes().split()[0])
+ another = int((tree / 'new').read_bytes().split()[0])
assert serial != another, 'cached factory reused a pane'
- client.write(f'/self/pane/{another}/ctl', b'delete\n')
+ client.write(f'/pane/{another}/ctl', b'exec Del\n')
pane = tree / 'pane' / str(serial)
- wire = f'/self/pane/{serial}'
+ wire = f'/pane/{serial}'
assert str(serial) in os.listdir(tree / 'pane')
assert serial in [int(row.split()[0]) for row in (tree / 'index').read_bytes().splitlines()]
@@ -76,7 +77,7 @@ def worker(mountpoint, socket, uid, gid, original_namespace):
assert client.read(wire + '/body') == b'v9fs update\nappended\n'
# Exercise an actual shell redirection, including its O_TRUNC open.
- subprocess.run(['/bin/sh', '-c', 'printf "name kernel-probe\\n" > "$1/ctl"',
+ subprocess.run(['/bin/sh', '-c', 'printf "kernel-probe\\n" > "$1/name"',
'v9fs-probe', str(pane)], check=True, timeout=5)
tag = client.read(wire + '/tag')
assert tag.split(maxsplit=1)[0] == str(socket.parent / 'kernel-probe').encode(), tag
@@ -100,7 +101,7 @@ def worker(mountpoint, socket, uid, gid, original_namespace):
# Reading OS files through the exported tree does not recurse through
# the mount: the core still lives in the supervisor's namespace.
assert (mountpoint / 'os' / str(socket.parent).lstrip('/') / 'kernel.txt').read_bytes() == b'initial\n'
- write_existing(pane / 'ctl', b'delete\n')
+ write_existing(pane / 'ctl', b'exec Del\n')
assert serial not in [int(row.split()[0]) for row in (tree / 'index').read_bytes().splitlines()]
print('v9fs: namespace isolation, privilege drop, inherited mount, directory refresh, '
@@ -154,7 +155,7 @@ def run(binary, helper):
print(run_helper(command), end='')
assert os.readlink('/proc/self/ns/mnt') == namespace
assert list(target.iterdir()) == [], 'mount escaped its private namespace'
- assert client.read('/self/pane/1/body') == b'initial\n', 'core stopped serving after probe exit'
+ assert client.read('/pane/1/body') == b'initial\n', 'core stopped serving after probe exit'
print('v9fs: supervisor namespace unchanged and session cleanup passed')
diff --git a/test/v9fs_terminal.py b/test/v9fs_terminal.py
index cc273a40..b49246d1 100644
--- a/test/v9fs_terminal.py
+++ b/test/v9fs_terminal.py
@@ -74,7 +74,7 @@ sys.exit(1)
execute(client, control, 'Tty9p')
until(report_path.exists, 'Tty9p did not reach the sudo stand-in')
report = json.loads(report_path.read_text())
- pane = f"/self/pane/{report['pane']}"
+ pane = f"/pane/{report['pane']}"
until(lambda: b'V9FS_AUTH_READY' in client.read(pane + '/body'), 'prompt not visible')
assert report['socket'] == str(address)
assert report['argv'][:2] == ['-E', '--'], report
@@ -85,7 +85,7 @@ sys.exit(1)
assert report['path'] == path
assert report['shell'] == str(Path(shell).resolve()), report
# The detached core remains responsive during authentication.
- assert client.read('/self/pane/1/body') == b'initial\n'
+ assert client.read('/pane/1/body') == b'initial\n'
target = Path(report['mount'])
assert target.is_dir() and list(target.iterdir()) == []
if interrupted:
@@ -100,7 +100,7 @@ sys.exit(1)
client.write(pane + '/pty/data', b'printf "OUTER_READY:%s\\n" "$PARDES_PANE"\r')
expected = ('OUTER_READY:' + report['pane']).encode()
until(lambda: expected in client.read(pane + '/body'), 'original shell did not remain usable')
- assert client.read('/self/pane/1/body') == b'initial\n'
+ assert client.read('/pane/1/body') == b'initial\n'
print('Tty9p: shell-first startup, quoted paths, hidden password input, failure recovery and cleanup passed')
diff --git a/test/window_opacity.py b/test/window_opacity.py
index 8abe8677..b9529373 100644
--- a/test/window_opacity.py
+++ b/test/window_opacity.py
@@ -49,9 +49,9 @@ def run(args):
command(client, source, 'Tty')
terminal = require(wait_for(lambda: next((serial for serial in panes(client)
if serial not in before), None)), 'terminal missing')
- client.write(f'/self/pane/{terminal}/pty/data',
+ client.write(f'/pane/{terminal}/pty/data',
b"PS1='$ '; printf '\\033[2J\\033[H\\033[36mTerminal opacity sample\\033[0m\\n'\r")
- require(wait_for(lambda: b'Terminal opacity sample' in client.read(f'/self/pane/{terminal}/body')),
+ require(wait_for(lambda: b'Terminal opacity sample' in client.read(f'/pane/{terminal}/body')),
'terminal output missing')
def output(builtin):
@@ -59,8 +59,8 @@ def run(args):
command(client, source, builtin)
pane = require(wait_for(lambda: next((serial for serial in panes(client)
if serial not in before), None)), builtin + ' output missing')
- text = client.read(f'/self/pane/{pane}/body').decode()
- client.write(f'/self/pane/{pane}/ctl', b'del\n')
+ text = client.read(f'/pane/{pane}/body').decode()
+ client.write(f'/pane/{pane}/ctl', b'exec Del\n')
return text
help_text = output('Help')
@@ -208,9 +208,9 @@ def run(args):
require(all(int(value) == 0xffffffff for value in multipliers),
'whole-window alpha modifier would fade foreground text')
require('WindowOpacity: 100%\n' in output('Config'), '100% opacity not restored')
- require(source_path.read_bytes() == original == client.read(f'/self/pane/{source}/body'),
+ require(source_path.read_bytes() == original == client.read(f'/pane/{source}/body'),
'source contents changed')
- client.write(f'/self/pane/{terminal}/pty/data', b'exit\r')
+ client.write(f'/pane/{terminal}/pty/data', b'exit\r')
report.update(source_unchanged=True, core_rgb_and_text_unchanged=True,
invalid_value_ignored=True, help_updated=True, restored_opaque=True)
(root / 'report.json').write_text(json.dumps(report, indent=2) + '\n')