summaryrefslogtreecommitdiff
path: root/src/ninep/tree.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/ninep/tree.zig')
-rw-r--r--src/ninep/tree.zig850
1 files changed, 850 insertions, 0 deletions
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);
+}