summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-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
20 files changed, 4243 insertions, 3628 deletions
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