From 9070942b29bd10dddcdecdb0e88ba0fb40608467 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 21 Sep 2026 20:07:43 -0300 Subject: Plan 9 idiom for the control filesystem, and the regressions a624a56 left The 9P tree stops being a command language wearing a filesystem. /new created a pane as a side effect of a *read*; it is now Tcreate in /pane, with Tremove to close, which cloud9's engine has always supported and the editor never declared: tree.zig now says `features = .{ .create = true, .remove = true }`. Eleven pane ctl verbs become files that can be read as well as written -- dot, limit, dirty, mark, scroll, look, exec -- leaving ctl with `get`, the one verb no file would say better. Root /ctl splits into a read-only /status and the /look and /exec files whose write IS the click. stat carries real sizes where it used to answer 0, and qid versions track a pane's revision, so a client can poll for change without re-reading the body. Commit a624a56 moved raw-tty keys to an early-return branch that knew only Ctrl-B and bare Escape, and in the same edit deleted the paste branch below it. That cost Shift-Escape (the unconditional way out of tty mode) and both paste chords: Ctrl-V and Ctrl-Shift-V reached the child as keystrokes, so an agent CLI running in a pane took Ctrl-V for its image-paste binding and answered "No image found in clipboard". Both are restored, with tests. Nested detection was not subtly broken but deleted: 60367d8 removed nested.zig's process-ancestry walk and left "am I inside pardes" derived from PARDES_FORWARD_LOOK, which read "0" both for --nested and for "the listener did not come up". PARDES_PID now answers that question on its own, checked with kill(pid, 0); PARDES_9P and PARDES_PANE answer how to reach it; the flag is gone. The posted-9P registry also self-heals now -- a session that aborts cannot unlink its own socket, so posting sweeps entries whose target refuses a connection, symlinks only and on a definite ECONNREFUSED only. Elsewhere: tty scrolling is sticky-bottom, following new output only from the last row, with typing and entering raw mode snapping back to live; the boot layouts are a Boot enum instead of a chain of ifs, and the bare tty startup (Boot.tty, which main.zig names) opens an empty text pane under the shell while tests keep Boot.tty_shell; builtins announce themselves on the message row under a Verbose setting that is on by default; Config prints each setting the way you would type it back, so WindowOpacity 70 rather than "WindowOpacity: 70%"; LocationsConfig opens its window only when called bare; every tagline puts the word that closes the thing last, and a column now outlives its panes -- closing the last one leaves an empty pane, and only Delcol, newly on the column tagline, takes the column away. Co-Authored-By: Claude Opus 5 (1M context) --- src/ninep/addr.zig | 16 ++- src/ninep/ctl.zig | 319 ++++++++++++++++++++------------------------------ src/ninep/events.zig | 15 ++- src/ninep/pane.zig | 241 +++++++++++++++++++++++++++++++++----- src/ninep/pty.zig | 9 +- src/ninep/screen.zig | 25 ++-- src/ninep/testing.zig | 29 ++++- src/ninep/tree.zig | 207 ++++++++++++++++++++------------ 8 files changed, 545 insertions(+), 316 deletions(-) (limited to 'src/ninep') diff --git a/src/ninep/addr.zig b/src/ninep/addr.zig index f7642b9e..566e2364 100644 --- a/src/ninep/addr.zig +++ b/src/ninep/addr.zig @@ -255,17 +255,25 @@ test "the address language, form by form" { 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); + // limit is its own file: copying addr onto it bounds the search, and + // truncating it lifts the bound again. + const limit = Node.of(serial, .limit); _ = th.wr(p, addr, "1"); - _ = th.wr(p, Node.of(serial, .ctl), "limit=addr\n"); + try testing.expectEqualStrings("", th.rd(p, limit, 0, 64).bytes); + var copied: [64]u8 = undefined; + const pair = th.rd(p, addr, 0, 64).bytes; + @memcpy(copied[0..pair.len], pair); + _ = th.wr(p, limit, copied[0..pair.len]); + try testing.expectEqual(@as(u32, 4), p.fs.panes[0].limit.?.q1); + try testing.expectEqualStrings(" 0 4 ", th.rd(p, limit, 0, 64).bytes); _ = 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) }); + _ = th.call(p, .{ .tag = 6, .op = .setattr, .node = limit, .truncate = true }); 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| { + for ([_][]const u8{ "zzz", "#", "//", "/nomatch/", "1 2 3", "99", "/a\\" }) |bad| { _ = th.wr(p, addr, "#0"); try testing.expectEqual(E.INVAL, th.wr(p, addr, bad).errno()); } diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig index d61ebf75..c213ece5 100644 --- a/src/ninep/ctl.zig +++ b/src/ninep/ctl.zig @@ -1,6 +1,9 @@ -//! 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. +//! The command files and the status they answer. Writing a line to `look` is +//! a right click on it and writing one to `exec` a middle click, at the +//! active pane from the root and at that pane from /pane//; reading either +//! answers the serials the last command made or touched. /status reports the +//! editor, and a pane's ctl its acme status line and the one verb, `get`, +//! that no file of its own would say any better. const std = @import("std"); const pardes = @import("../pardes.zig"); const panes = @import("../panes.zig"); @@ -16,36 +19,6 @@ 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 { @@ -96,37 +69,49 @@ fn run(p: *Pardes, id: usize, exec: bool, text: []const u8) void { 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 ---- +// ---- look and exec ---- -pub fn writeRoot(p: *Pardes, req: Req) Reply { +/// One command per line, checked whole before any line runs: a line is the +/// clicked text itself, so it may not be empty or carry control characters. +/// A null `serial` clicks at whichever pane is active as each line runs, +/// which is what the root's own look and exec mean; a serial stays put. +pub fn command(p: *Pardes, req: Req, serial: ?u32, exec: bool) Reply { for ([2]bool{ false, true }) |apply| { - if (apply) beginResults(p); + if (apply) p.fs.results_len = 0; 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); + const text = std.mem.trim(u8, raw, " \t\r"); + if (text.len == 0) continue; + for (text) |c| if (c < ' ' and c != '\t') 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); + const id = if (serial) |s| p.paneBySerial(s) orelse break else p.active; + if (p.panes[id] == null) return Reply.fail(req.tag, E.NOENT); + run(p, id, 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(); +pub fn resultsLen(p: *Pardes) u64 { + var n: u64 = 0; + for (p.fs.results[0..p.fs.results_len]) |serial| { + var digits: [16]u8 = undefined; + n += (std.fmt.bufPrint(&digits, "{d}\n", .{serial}) catch unreachable).len; } + return n; +} + +pub fn readResults(p: *Pardes, req: Req) Reply { + const out = p.fs.stage(p.gpa); + for (p.fs.results[0..p.fs.results_len]) |serial| + out.print(p.gpa, "{d}\n", .{serial}) catch return Reply.fail(req.tag, E.NOMEM); + return tree.stagedReply(p, req); +} + +// ---- /status ---- + +fn statusText(p: *Pardes, buf: []u8) []const u8 { + var w = std.Io.Writer.fixed(buf); 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; @@ -134,13 +119,17 @@ pub fn rootText(p: *Pardes, buf: []u8) []const u8 { return w.buffered(); } -pub const root_text_cap = @max(MAX_PANES * 11, 64 + pardes.version.len); +pub const status_text_cap = 64 + pardes.version.len; + +pub fn statusLen(p: *Pardes) u64 { + var buf: [status_text_cap]u8 = undefined; + return statusText(p, &buf).len; +} -pub fn readRoot(p: *Pardes, req: Req) Reply { - var buf: [root_text_cap]u8 = undefined; - const text = rootText(p, &buf); +pub fn readStatus(p: *Pardes, req: Req) Reply { + var buf: [status_text_cap]u8 = undefined; const out = p.fs.stage(p.gpa); - out.appendSlice(p.gpa, text) catch return Reply.fail(req.tag, E.NOMEM); + out.appendSlice(p.gpa, statusText(p, &buf)) catch return Reply.fail(req.tag, E.NOMEM); return tree.stagedReply(p, req); } @@ -190,74 +179,35 @@ pub fn readPane(p: *Pardes, req: Req, pane: *Pane) Reply { 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); - } +/// `get` is the one thing here that no file of the pane's own would say: it +/// reloads the buffer from the name it carries, wherever that name resolves. +/// Repeating it in one write would only reload the same bytes, so it runs once. +pub fn writePane(p: *Pardes, req: Req, pane: *Pane) Reply { + var asked = 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; + if (!std.mem.eql(u8, line, "get")) return Reply.fail(req.tag, E.INVAL); + asked = true; + } + if (asked) { + const errno = get(p, pane); + 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; +fn get(p: *Pardes, pane: *Pane) u16 { + const f = pane_files.fileOf(pane) orelse return 0; + if (!panes.Output.fileTraits(f.output).saves) return 0; + const bytes = fs.read(p, f.path) catch |err| return switch (err) { + error.FileNotFound => E.NOENT, + else => E.IO, }; - 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), - } + panes.File.pushUndo(p, pane); + panes.File.setContent(p, f, bytes); + f.saved_revision = f.revision; return 0; } @@ -272,7 +222,9 @@ const withFile = th.withFile; const serialOf = th.serialOf; const Node = tree.Node; const Status = tree.Status; -const root_ctl = @intFromEnum(tree.TopFile.ctl); +const root_status = @intFromEnum(tree.TopFile.status); +const root_look = @intFromEnum(tree.TopFile.look); +const root_exec = @intFromEnum(tree.TopFile.exec); test "pane ctl read is index's five fields plus width in cells, font and tab width" { const gpa = testing.allocator; @@ -299,37 +251,18 @@ test "pane ctl read is index's five fields plus width in cells, font and tab wid try testing.expectEqualStrings("'it''s'", w.buffered()); } -test "every pane ctl verb, and every refusal" { +test "the pane ctl takes get, and nothing that a file of its own now answers" { 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); - + const ctl_node = Node.of(serialOf(p), .ctl); 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", + "menu", "nomenu", "dump echo hi", "font Go Mono", "lock", "bogus", "DEL", + "name x.txt", "put", "del", "delete", "Look x", "Exec Save", "clean", + "dirty", "cleartag", "dot=addr", "addr=dot", "show", "mark", "nomark", + "scroll", "limit=addr", "get x", "look /tmp", "exec Del", }) |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)); + try testing.expect(p.paneBySerial(serialOf(p)) != null); } test "exec runs a builtin at the pane and records the pane it acted on" { @@ -338,61 +271,69 @@ test "exec runs a builtin at the pane and records the pane it acted on" { 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]); + try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .exec), "Msg from exec\n").reply.status); + try testing.expectEqualStrings("from exec", 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); + const own = try std.fmt.bufPrint(&expected, "{d}\n", .{serial}); + try testing.expectEqualStrings(own, rd(p, root_exec, 0, 4096).bytes); + // Either command file reads back what the last one did. + try testing.expectEqualStrings(own, rd(p, Node.of(serial, .look), 0, 4096).bytes); + try testing.expectEqual(@as(u64, own.len), call(p, .{ .tag = 1, .op = .getattr, .node = root_exec }).reply.attr.size); - try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .ctl), "exec New\n").reply.status); + try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .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.expectEqualStrings(try std.fmt.bufPrint(&expected, "{d}\n", .{made.serial}), rd(p, root_exec, 0, 4096).bytes); - try testing.expectEqual(Status.ok, wr(p, Node.of(made.serial, .ctl), "exec Del\n").reply.status); + try testing.expectEqual(Status.ok, wr(p, Node.of(made.serial, .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); + try testing.expectEqualStrings(try std.fmt.bufPrint(&expected, "{d}\n", .{made.serial}), rd(p, root_exec, 0, 4096).bytes); } -test "the root ctl reads editor status until a command runs and validates whole batches" { +test "status reads the editor, and look and exec validate 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); + const status = rd(p, root_status, 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().?); + var live: usize = 0; + for (p.panes) |slot| live += @intFromBool(slot != null); + try testing.expectEqualStrings(try std.fmt.bufPrint(&expected, "panes {d}", .{live}), lines.next().?); try testing.expect(lines.next() == null); - const stat = call(p, .{ .tag = 1, .op = .getattr, .node = root_ctl }); + const stat = call(p, .{ .tag = 1, .op = .getattr, .node = root_status }); try testing.expectEqual(@as(u64, status.bytes.len), stat.reply.attr.size); - try testing.expectEqual(@as(u16, 0o666), stat.reply.attr.mode); + try testing.expectEqual(@as(u16, 0o444), stat.reply.attr.mode); + try testing.expectEqual(E.PERM, wr(p, root_status, "anything\n").errno()); + try testing.expectEqualStrings("", rd(p, root_exec, 0, 4096).bytes); 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()); + for ([_][]const u8{ "Msg a\x00b", "Msg x\nMsg \x01y\n" }) |invalid| { + try testing.expectEqual(E.INVAL, wr(p, root_exec, invalid).errno()); try testing.expectEqual(serial, p.next_serial); } - try testing.expectEqualStrings(status.bytes, rd(p, root_ctl, 0, 4096).bytes); + // An empty write is a no-op, not a command. + try testing.expectEqual(Status.ok, wr(p, root_exec, "\n \n").reply.status); + try testing.expectEqualStrings("", rd(p, root_exec, 0, 4096).bytes); + try testing.expectEqualStrings(status.bytes, rd(p, root_status, 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.expectEqual(Status.ok, wr(p, root_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(try std.fmt.bufPrint(&expected, "{d}\n", .{pane.serial}), rd(p, root_exec, 0, 4096).bytes); + + try testing.expectEqual(Status.ok, wr(p, root_look, "/virtual/index\n").reply.status); + const opened = p.panes[p.active].?; try testing.expectEqualStrings("/virtual/index", opened.file.?.path); + try testing.expectEqualStrings(try std.fmt.bufPrint(&expected, "{d}\n", .{opened.serial}), rd(p, root_look, 0, 4096).bytes); + + try testing.expectEqual(Status.ok, wr(p, root_exec, "New\n").reply.status); + const made = p.panes[p.active].?; + try testing.expect(made.serial != opened.serial); + try testing.expectEqualStrings(try std.fmt.bufPrint(&expected, "{d}\n", .{made.serial}), rd(p, root_exec, 0, 4096).bytes); } test "look opens spaced paths and locations without editing the source pane" { @@ -406,12 +347,12 @@ test "look opens spaced paths and locations without editing the source pane" { 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 look_node = Node.of(source.serial, .look); 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); + var clicked: [4200]u8 = undefined; + try testing.expectEqual(Status.ok, wr(p, look_node, try std.fmt.bufPrint(&clicked, "{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); @@ -423,14 +364,14 @@ test "look opens spaced paths and locations without editing the source pane" { 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); + try testing.expectEqualStrings(try std.fmt.bufPrint(&expected, "{d}\n", .{opened.serial}), rd(p, root_look, 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.expectEqual(Status.ok, wr(p, root_look, try std.fmt.bufPrint(&clicked, "{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); + try testing.expectEqualStrings(try std.fmt.bufPrint(&expected, "{d}\n", .{opened.serial}), rd(p, root_look, 0, 4096).bytes); } test "look validates the whole batch before opening virtual files" { @@ -438,17 +379,17 @@ test "look validates the whole batch before opening virtual files" { const p = try withFile(gpa, "source\n"); defer p.deinit(); const source = p.panes[0].?; - const ctl_node = Node.of(source.serial, .ctl); + const look_node = Node.of(source.serial, .look); const serial = p.next_serial; for ([_][]const u8{ - "look", "look \t", "look /virtual/index\x00ignored", "look /virtual/index\nbogus\n", + "/virtual/index\x00ignored", "/virtual/index\n/virtual/\x01index\n", }) |invalid| { - try testing.expectEqual(E.INVAL, wr(p, ctl_node, invalid).errno()); + try testing.expectEqual(E.INVAL, wr(p, look_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); + try testing.expectEqual(Status.ok, wr(p, look_node, "/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); @@ -480,7 +421,7 @@ test "get reloads the pane from disk and reports a missing file without losing d _ = 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.expectEqual(E.NOENT, wr(p, ctl_node, "get\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)); @@ -494,7 +435,7 @@ test "exec Save writes the named file through the host" { 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"); + const saved = wr(p, Node.of(serial, .exec), "Save\n"); try testing.expectEqual(Status.ok, saved.reply.status); try testing.expect(saved.saved); p.perform(.{ .save_file = .{ .pane = 0 } }); diff --git a/src/ninep/events.zig b/src/ninep/events.zig index fb49e41b..bd53bcd9 100644 --- a/src/ninep/events.zig +++ b/src/ninep/events.zig @@ -89,6 +89,13 @@ pub const Queue = struct { } }; +/// What the next read would answer, which is the size a stat reports: a +/// client can see there is something waiting without parking on it. +pub fn pending(q: *const Queue) u64 { + const record = q.peek() orelse return 0; + return record.len; +} + /// 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 }; @@ -461,7 +468,7 @@ test "a pane deleted while its event file is open leaves no suppression behind" 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"); + _ = wr(p, Node.of(serial, .exec), "Del\n"); try testing.expect(p.paneBySerial(serial) == null); try testing.expectEqual(@as(u16, 0), p.fs.listeners); @@ -471,7 +478,7 @@ test "a pane deleted while its event file is open leaves no suppression behind" 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, wr(p, Node.of(serial, .dirty), "0\n").errno()); try testing.expectEqual(E.NOENT, call(p, .{ .tag = 22, .op = .open, .node = event }).errno()); } @@ -535,7 +542,7 @@ test "the log parks until a pane is created, renamed, saved or deleted" { 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"); + const saving = wr(p, Node.of(serial, .exec), "Save\n"); try testing.expectEqual(Status.ok, saving.reply.status); try testing.expect(saving.saved); p.perform(.{ .save_file = .{ .pane = @intCast(id) } }); @@ -543,7 +550,7 @@ test "the log parks until a pane is created, renamed, saved or deleted" { 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.expectEqual(Status.ok, wr(p, Node.of(serial, .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, diff --git a/src/ninep/pane.zig b/src/ninep/pane.zig index b37ae645..42c0c48e 100644 --- a/src/ninep/pane.zig +++ b/src/ninep/pane.zig @@ -1,5 +1,6 @@ -//! The files under /pane//: name, body, tag, ctl, addr, data, xdata, -//! sel, errors and event. Terminal panes add pty/ (pty.zig). +//! The files under /pane//: name, body, tag, ctl, the three range +//! files addr, dot and limit, data, xdata, sel, the three flag files dirty, +//! mark and scroll, errors, event, look and exec. Terminals add pty/ (pty.zig). const std = @import("std"); const pardes = @import("../pardes.zig"); const panes = @import("../panes.zig"); @@ -187,10 +188,15 @@ pub fn spliceBody(p: *Pardes, id: usize, pane: *Pane, q0: usize, q1: usize, byte return take; } -// ---- sizes reported by stat ---- +// ---- what stat reports ---- + +/// A range file's text: acme's addr format, two right-aligned offsets, so +/// that copying one range file onto another is the whole of `dot=addr`. +pub const range_len: u64 = 24; pub fn fileSize(p: *Pardes, id: usize, f: PaneFile) u64 { const pane = p.panes[id] orelse return 0; + const pf = &p.fs.panes[id]; return switch (f) { .body, .data, .xdata => bodyOf(pane).len, .tag => tagOf(p, pane).len, @@ -203,7 +209,24 @@ pub fn fileSize(p: *Pardes, id: usize, f: PaneFile) u64 { 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, + .addr, .dot => range_len, + .limit => if (pf.limit == null) 0 else range_len, + .dirty, .mark, .scroll => 2, + .look, .exec => ctl.resultsLen(p), + .event => events.pending(&pf.events), + .pty_status => pty.status_len, + .pty_data => events.pending(&pf.pty_out), + .dir, .errors, .pty, .pty_ctl => 0, + }; +} + +/// The qid version, which a client polls to watch a file for change. Only +/// the pane's text can promise one: its revision counts every edit, and +/// nothing else here changes in step with it. +pub fn versionOf(pane: *const Pane, f: PaneFile) u32 { + return switch (f) { + .body, .data, .xdata => if (pane.file) |*file| file.revision else 0, + else => 0, }; } @@ -246,13 +269,17 @@ pub fn read(p: *Pardes, req: Req, id: usize, pane: *Pane, file: PaneFile) Reply .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); + break :addr readRange(p, req, pf.addr); }, + .dot => readRange(p, req, dotOf(pane)), + .limit => readRange(p, req, pf.limit), .data => readData(req, id, pane, pf, false), .xdata => readData(req, id, pane, pf, true), .sel => readSel(req, id, pane), + .dirty => readFlag(p, req, dirtyOf(pane)), + .mark => readFlag(p, req, !pf.nomark), + .scroll => readFlag(p, req, !pf.noscroll), + .look, .exec => ctl.readResults(p, req), .event => events.readQueue(p, req, &pf.events), .pty_status => pty.readStatus(p, req, id, pane), .pty_data => pty.readData(p, req, pf), @@ -260,6 +287,20 @@ pub fn read(p: *Pardes, req: Req, id: usize, pane: *Pane, file: PaneFile) Reply }; } +/// An unset limit reads empty, which is how a client tells it from one that +/// happens to bound nothing. +fn readRange(p: *Pardes, req: Req, r: ?State.Range) Reply { + const out = p.fs.stage(p.gpa); + if (r) |range| out.print(p.gpa, "{d:>11} {d:>11} ", .{ range.q0, range.q1 }) catch {}; + return tree.stagedReply(p, req); +} + +fn readFlag(p: *Pardes, req: Req, on: bool) Reply { + const out = p.fs.stage(p.gpa); + out.appendSlice(p.gpa, if (on) "1\n" else "0\n") catch {}; + return tree.stagedReply(p, req); +} + fn readBody(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply { if (pane.file != null) { const text = bodyOf(pane); @@ -322,10 +363,12 @@ pub fn write(p: *Pardes, req: Req, id: usize, pane: *Pane, file: PaneFile) Reply .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), + .ctl => ctl.writePane(p, req, pane), + .addr, .dot, .limit => writeRange(p, req, id, pane, file), .data, .xdata => writeData(p, req, id, pane), .sel => writeSel(p, req, id, pane), + .dirty, .mark, .scroll => writeFlag(p, req, id, pane, file), + .look, .exec => ctl.command(p, req, pane.serial, file == .exec), .event => events.writeEvent(p, req, id), .errors => if (appendErrors(p, id, req.data)) |took| .{ .tag = req.tag, .written = @intCast(took) } @@ -389,15 +432,68 @@ fn writeSel(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply { return .{ .tag = req.tag, .written = @intCast(take) }; } -fn writeAddr(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply { +/// The pair of offsets a range file reads back, so that copying `addr` onto +/// `dot` or `limit` does what acme spelled `dot=addr` and `limit=addr`. +fn pairOf(text: []const u8) ?State.Range { + var it = std.mem.tokenizeAny(u8, text, " \t"); + const q0 = std.fmt.parseInt(u32, it.next() orelse return null, 10) catch return null; + const q1 = std.fmt.parseInt(u32, it.next() orelse return null, 10) catch return null; + if (it.next() != null) return null; + return .{ .q0 = q0, .q1 = @max(q0, q1) }; +} + +/// A range file takes an address expression, or that pair of offsets. +fn rangeOf(pf: *State, text: []const u8, data: []const u8) ?State.Range { + const expr = std.mem.trimEnd(u8, data, "\n"); + if (pairOf(expr)) |r| { + const n = clip(text.len); + return .{ .q0 = @min(r.q0, n), .q1 = @min(r.q1, n) }; + } + var a: addressing.Addr = .{ .text = text, .lim = pf.limit, .expr = expr }; + const r = a.address(pf.addr) orelse return null; + return if (a.i < expr.len) null else r; +} + +fn writeRange(p: *Pardes, req: Req, id: usize, pane: *Pane, file: PaneFile) 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; + const r = rangeOf(pf, text, req.data) orelse return Reply.fail(req.tag, E.INVAL); + switch (file) { + .addr => pf.addr = r, + .limit => pf.limit = r, + // Setting dot scrolls to it, which is the whole of acme's `show`. + .dot => { + if (fileOf(pane) == null) return Reply.fail(req.tag, E.INVAL); + setDot(pane, r); + }, + else => unreachable, + } + return .{ .tag = req.tag, .written = @intCast(req.data.len) }; +} + +fn flagOf(data: []const u8) ?bool { + const word = std.mem.trim(u8, data, " \t\r\n"); + if (std.mem.eql(u8, word, "1")) return true; + if (std.mem.eql(u8, word, "0")) return false; + return null; +} + +fn writeFlag(p: *Pardes, req: Req, id: usize, pane: *Pane, file: PaneFile) Reply { + const on = flagOf(req.data) orelse return Reply.fail(req.tag, E.INVAL); + const pf = &p.fs.panes[id]; + switch (file) { + .dirty => { + const f = fileOf(pane) orelse return Reply.fail(req.tag, E.INVAL); + f.saved_revision = if (on) f.revision -% 1 else f.revision; + }, + .mark => { + pf.nomark = !on; + if (on) panes.File.pushUndo(p, pane); + }, + .scroll => pf.noscroll = !on, + else => unreachable, + } return .{ .tag = req.tag, .written = @intCast(req.data.len) }; } @@ -475,14 +571,24 @@ pub fn appendErrors(p: *Pardes, id: usize, text: []const u8) ?usize { return text.len; } -/// Truncation empties a body; every other setattr just answers fresh attributes. +/// Truncation empties whatever the file holds: the body, the tag's editable +/// tail (acme's `cleartag`), a range. Every other setattr just answers fresh +/// attributes. pub fn truncate(p: *Pardes, id: usize, pane: *Pane, file: PaneFile) tree.Status { + const pf = &p.fs.panes[id]; 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 = .{}; + pf.addr = .{}; setDot(pane, .{}); }, + .tag => { + pane.tag_tail_len = 0; + pane.tag_init = true; + }, + .addr => pf.addr = .{}, + .limit => pf.limit = null, + .dot => if (fileOf(pane) != null) setDot(pane, .{}), else => {}, } return .ok; @@ -509,15 +615,19 @@ test "index lists serial kind dirty flag and name and stat reports its length" { 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 clean_row = try std.fmt.bufPrint(&want, "{d} text 0 /test.txt\n", .{pane.serial}); + try testing.expect(std.mem.indexOf(u8, a.bytes, clean_row) != null); + const at = std.mem.indexOf(u8, a.bytes, clean_row).?; 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); + var dirty_want: [64]u8 = undefined; + const dirty_row = try std.fmt.bufPrint(&dirty_want, "{d} text 1 /test.txt\n", .{pane.serial}); + try testing.expect(std.mem.indexOf(u8, rd(p, index, 0, 4096).bytes, dirty_row) != null); + const kind = at + std.mem.indexOfScalar(u8, clean_row, ' ').? + 1; + try testing.expectEqualStrings("text", rd(p, index, kind, 4).bytes); const serial = try th.newPane(p); const both = rd(p, index, 0, 4096); @@ -533,10 +643,11 @@ test "a terminal is listed as term with its directory as name" { 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}), + try testing.expect(std.mem.indexOf( + u8, rd(p, @intFromEnum(tree.TopFile.index), 0, 4096).bytes, - ); + try std.fmt.bufPrint(&want, "{d} term 0 /work/dir\n", .{pane.serial}), + ) != null); 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()); } @@ -566,6 +677,13 @@ test "body reads at any offset and writes append" { 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); + + // The qid version is the pane's revision, so a stat sees an edit land. + try testing.expectEqual(p.panes[0].?.file.?.revision, stat.reply.attr.version); + _ = wr(p, body, "more\n"); + const later = call(p, .{ .tag = 7, .op = .getattr, .node = body }); + try testing.expect(later.reply.attr.version != stat.reply.attr.version); + try testing.expectEqual(@as(u32, 0), call(p, .{ .tag = 8, .op = .getattr, .node = Node.of(serial, .tag) }).reply.attr.version); } test "a body write to a terminal pane types at its shell" { @@ -653,18 +771,29 @@ test "sel reads the selection and writing sel replaces it" { const p = try withFile(gpa, "one\ntwo\n"); defer p.deinit(); const serial = serialOf(p); - const ctl_node = Node.of(serial, .ctl); + const addr_node = Node.of(serial, .addr); + const dot = Node.of(serial, .dot); 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); + // Copying addr onto dot is the whole of acme's `dot=addr`, and back again + // of `addr=dot`; each range file reads exactly what the other takes. + _ = wr(p, addr_node, "#0,#3"); + try testing.expectEqual(Status.ok, wr(p, dot, " 0 3 ").reply.status); + try testing.expectEqualStrings(" 0 3 ", rd(p, dot, 0, 100).bytes); + try testing.expectEqual(range_len, call(p, .{ .tag = 1, .op = .getattr, .node = dot }).reply.attr.size); 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"); + _ = wr(p, addr_node, "#0"); + _ = wr(p, addr_node, " 0 3 "); try testing.expectEqual(@as(u32, 0), p.fs.panes[0].addr.q0); try testing.expectEqual(@as(u32, 3), p.fs.panes[0].addr.q1); + // A truncation empties dot; an address expression addresses it. + _ = call(p, .{ .tag = 9, .op = .setattr, .node = dot, .truncate = true }); + try testing.expectEqualStrings("", rd(p, sel, 0, 100).bytes); + try testing.expectEqual(Status.ok, wr(p, dot, "#0,#3").reply.status); + 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, "!"); @@ -672,6 +801,62 @@ test "sel reads the selection and writing sel replaces it" { try testing.expectEqual(@as(u64, 0), call(p, .{ .tag = 2, .op = .getattr, .node = sel }).reply.attr.size); } +test "the flag files read and write the state acme kept behind ctl verbs" { + const gpa = testing.allocator; + const p = try withFile(gpa, "one\ntwo\n"); + defer p.deinit(); + const serial = serialOf(p); + const pane = p.panes[0].?; + const pf = &p.fs.panes[0]; + const dirty = Node.of(serial, .dirty); + const mark = Node.of(serial, .mark); + const scroll = Node.of(serial, .scroll); + + for ([_]u64{ dirty, mark, scroll }) |node| { + try testing.expectEqual(@as(u64, 2), call(p, .{ .tag = 1, .op = .getattr, .node = node }).reply.attr.size); + for ([_][]const u8{ "", "yes", "2", "0 1", "true" }) |bad| + try testing.expectEqual(E.INVAL, wr(p, node, bad).errno()); + } + + try testing.expectEqualStrings("0\n", rd(p, dirty, 0, 8).bytes); + try testing.expectEqualStrings("1\n", rd(p, mark, 0, 8).bytes); + try testing.expectEqualStrings("1\n", rd(p, scroll, 0, 8).bytes); + + try testing.expectEqual(Status.ok, wr(p, dirty, "1\n").reply.status); + try testing.expect(dirtyOf(pane)); + try testing.expectEqualStrings("1\n", rd(p, dirty, 0, 8).bytes); + _ = wr(p, dirty, "0"); + try testing.expect(!dirtyOf(pane)); + + _ = wr(p, mark, "0"); + _ = wr(p, scroll, "0"); + try testing.expect(pf.nomark and pf.noscroll); + try testing.expectEqualStrings("0\n", rd(p, mark, 0, 8).bytes); + try testing.expectEqualStrings("0\n", rd(p, scroll, 0, 8).bytes); + _ = wr(p, mark, "1"); + _ = wr(p, scroll, "1"); + try testing.expect(!pf.nomark and !pf.noscroll); + + // A terminal has no file, so its dirty flag is readable but not writable. + const term = try th.withTerm(gpa); + defer term.deinit(); + try testing.expectEqualStrings("0\n", rd(term, Node.of(serialOf(term), .dirty), 0, 8).bytes); + try testing.expectEqual(E.INVAL, wr(term, Node.of(serialOf(term), .dirty), "1").errno()); +} + +test "truncating the tag clears its editable tail" { + const p = try withFile(testing.allocator, "x\n"); + defer p.deinit(); + const pane = p.panes[0].?; + const tag = Node.of(pane.serial, .tag); + _ = wr(p, tag, " Mine"); + try testing.expect(pane.tag_tail_len > 0); + const cleared = call(p, .{ .tag = 1, .op = .setattr, .node = tag, .truncate = true }); + try testing.expectEqual(Status.ok, cleared.reply.status); + try testing.expectEqual(@as(usize, 0), pane.tag_tail_len); + try testing.expect(std.mem.indexOf(u8, rd(p, tag, 0, 4096).bytes, " Mine") == null); +} + 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"); diff --git a/src/ninep/pty.zig b/src/ninep/pty.zig index e6696b85..1b71a8d3 100644 --- a/src/ninep/pty.zig +++ b/src/ninep/pty.zig @@ -76,6 +76,9 @@ fn verb(p: *Pardes, id: usize, line: []const u8, apply: bool) bool { return true; } +/// Three right-aligned fields: cols, rows and whether the host holds the tty. +pub const status_len: u64 = 36; + 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} ", .{ @@ -146,7 +149,7 @@ test "a pane that is not a terminal has no pty/ at all" { 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.expectEqual(@as(usize, 17), files.len); try testing.expect(th.nameAt(files, "pty") == null); try testing.expectEqual(E.NOENT, look_up(p, dir, "pty_ctl").errno()); @@ -167,7 +170,7 @@ test "a terminal pane's pty/ holds exactly ctl, status and data" { 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.expectEqual(@as(usize, 18), files.len); try testing.expect(th.nameAt(files, "pty").?.dir); const inside = th.dirents(rdir(p, Node.of(serial, .pty), 0).bytes, &buf); @@ -247,7 +250,7 @@ test "every pty/ctl verb, and every refusal" { "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 + "look x", // a pane command; pty/ctl shares none of that vocabulary "exec Del", }) |bad| { const a = wr(p, ctl, bad); diff --git a/src/ninep/screen.zig b/src/ninep/screen.zig index 5b68b34c..2684aa41 100644 --- a/src/ninep/screen.zig +++ b/src/ninep/screen.zig @@ -83,22 +83,33 @@ pub fn releaseSnapshot(p: *Pardes, req: Req) bool { return true; } -pub fn readListeners(p: *Pardes, req: Req) Reply { - var buf: [512]u8 = undefined; - var text = std.Io.Writer.fixed(&buf); +const listeners_cap = 512; + +fn listenersText(p: *Pardes, buf: []u8) []const u8 { + 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); + text.print("unix!{s}\n", .{p.fs.socket_path}) catch return text.buffered(); 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), + return text.buffered(), .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), + return text.buffered(), } } + return text.buffered(); +} + +pub fn listenersLen(p: *Pardes) u64 { + var buf: [listeners_cap]u8 = undefined; + return listenersText(p, &buf).len; +} + +pub fn readListeners(p: *Pardes, req: Req) Reply { + var buf: [listeners_cap]u8 = undefined; const out = p.fs.stage(p.gpa); - out.appendSlice(p.gpa, text.buffered()) catch return Reply.fail(req.tag, E.NOMEM); + out.appendSlice(p.gpa, listenersText(p, &buf)) catch return Reply.fail(req.tag, E.NOMEM); return tree.stagedReply(p, req); } diff --git a/src/ninep/testing.zig b/src/ninep/testing.zig index ba26cb38..ad1d84e4 100644 --- a/src/ninep/testing.zig +++ b/src/ninep/testing.zig @@ -69,13 +69,30 @@ 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. +/// Creates a pane in /pane, as a client's mkdir does, and returns its serial. 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; + const made = mkdir(p, "scratch"); + if (made.reply.status != .ok) return error.NoPane; + const target = tree.Node.target(made.reply.attr.node) orelse return error.NoPane; + return switch (target) { + .pane => |t| t.serial, + .top => error.NoPane, + }; +} + +pub fn mkdir(p: *Pardes, name: []const u8) Answer { + return call(p, .{ + .tag = 9, + .op = .open, + .node = @intFromEnum(tree.TopFile.pane), + .data = name, + .create = true, + .perm = 0x8000_0000 | 0o755, + }); +} + +pub fn rmdir(p: *Pardes, node: u64) Answer { + return call(p, .{ .tag = 9, .op = .release, .node = node, .remove = true }); } pub fn withFile(gpa: std.mem.Allocator, text: []const u8) !*Pardes { diff --git a/src/ninep/tree.zig b/src/ninep/tree.zig index 56c3112d..46609649 100644 --- a/src/ninep/tree.zig +++ b/src/ninep/tree.zig @@ -4,8 +4,10 @@ //! extended here with the editor's reply payload; fs.zig keeps host access //! and mounts. //! -//! /README /index /ctl /new /log /screen /listeners -//! /pane//{name,body,tag,ctl,addr,data,xdata,sel,errors,event,pty/} +//! /README /index /status /look /exec /log /screen /listeners +//! /pane/ mkdir makes a pane, rmdir closes it +//! /pane//{name,body,tag,ctl,addr,dot,limit,data,xdata,sel, +//! dirty,mark,scroll,errors,event,look,exec,pty/} //! /os/... the host filesystem /src/... embedded sources (opt-in) const std = @import("std"); const cloud9 = @import("cloud9"); @@ -47,11 +49,16 @@ pub const Payload = union(enum) { pub const Reply = cloud9.fs.ReplyWith(Payload); +/// Tcreate opens a pane and Tremove closes one; nothing else in the tree is +/// created or destroyed by the protocol, and wstat stays a truncation. +pub const features: cloud9.fs.Features = .{ .create = true, .remove = true }; + 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, + .open => req.create, + .release => req.remove, + .lookup, .getattr, .read, .readdir => false, }; } @@ -63,8 +70,9 @@ pub const TopFile = enum(u4) { root = 1, README, index, - ctl, - new, + status, + look, + exec, log, screen, listeners, @@ -77,8 +85,8 @@ pub const TopFile = enum(u4) { pub fn mode(f: TopFile) u16 { return switch (f) { .root, .pane => 0o755, - .ctl => 0o666, - .README, .index, .new, .log, .screen, .listeners => 0o444, + .look, .exec => 0o666, + .README, .index, .status, .log, .screen, .listeners => 0o444, }; } @@ -89,18 +97,26 @@ pub const TopFile = enum(u4) { pub const root: u64 = @intFromEnum(TopFile.root); -pub const PaneFile = enum(u4) { +/// Declaration order is readdir order. +pub const PaneFile = enum(u5) { dir = 0, name, body, tag, ctl, addr, + dot, + limit, data, xdata, sel, + dirty, + mark, + scroll, errors, event, + look, + exec, pty, pty_ctl, pty_status, @@ -138,8 +154,8 @@ pub const PaneFile = enum(u4) { }; pub const Node = packed struct(u64) { - file: u4 = 0, - serial: u60 = 0, + file: u5 = 0, + serial: u59 = 0, pub fn of(serial: u32, file: PaneFile) u64 { std.debug.assert(serial != 0); @@ -240,8 +256,13 @@ pub fn stagedReply(p: *Pardes, req: Req) Reply { // ---- 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 host = req.node == fs.os_root or req.node & fs.os_node != 0; + const archive = req.node & sources.archive_node != 0; + // Only /pane is created in and removed from. The host tree and the + // embedded sources say so, rather than quietly doing nothing. + if ((host or archive) and (req.create or req.remove)) return Reply.fail(req.tag, E.PERM); + if (host) return fs.osHandle(p, req); + if (archive) 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'), @@ -280,6 +301,7 @@ fn attrOf(p: *Pardes, target: Target) ?Reply.Attr { .mode = t.file.mode(), .size = pane.fileSize(p, id, t.file), .mtime = pane.mtimeOf(p, pn), + .version = pane.versionOf(pn, t.file), }; }, } @@ -292,13 +314,14 @@ fn attrReply(p: *Pardes, tag: u64, target: Target) Reply { fn topSize(p: *Pardes, f: TopFile) u64 { return switch (f) { - .root, .pane, .new, .log, .screen, .listeners => 0, + // ponytail: /screen has no length until an open renders its frame. + .root, .pane, .screen => 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; - }, + .status => ctl.statusLen(p), + .look, .exec => ctl.resultsLen(p), + .log => events.pending(&p.fs.log), + .listeners => screen.listenersLen(p), }; } @@ -366,7 +389,7 @@ fn readdir(p: *Pardes, req: Req, target: Target) Reply { 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| { + inline for (.{ TopFile.README, TopFile.index, TopFile.status, TopFile.look, TopFile.exec, 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"); @@ -403,21 +426,27 @@ fn readdir(p: *Pardes, req: Req, target: Target) Reply { return .{ .tag = req.tag, .payload = .{ .staged = @intCast(out.items.len) } }; } -fn newPane(p: *Pardes) ?u32 { - const slot = p.freeSlot() orelse return null; +/// Tcreate in /pane opens a pane, the way mkdir opens a directory. The name +/// asked for is ignored: a pane is named by the serial the editor gives it, +/// which the reply carries back and /index lists last. +fn create(p: *Pardes, req: Req, target: Target) Reply { + switch (target) { + .top => |f| if (f != .pane) return Reply.fail(req.tag, E.PERM), + .pane => return Reply.fail(req.tag, E.PERM), + } + if (req.perm & cloud9.dmdir == 0) return Reply.fail(req.tag, E.PERM); + const slot = p.freeSlot() orelse return Reply.fail(req.tag, E.NFILE); p.newScratchBelow(p.active); - const pn = p.panes[slot] orelse return null; - return pn.serial; + const made = p.panes[slot] orelse return Reply.fail(req.tag, E.NFILE); + const attr = attrOf(p, .{ .pane = .{ .serial = made.serial, .file = .dir } }) orelse + return Reply.fail(req.tag, E.NFILE); + return .{ .tag = req.tag, .handle = 1, .attr = attr }; } fn open(p: *Pardes, req: Req, target: Target) Reply { + if (req.create) return create(p, req, target); 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 => {}, @@ -429,11 +458,7 @@ fn open(p: *Pardes, req: Req, target: Target) Reply { 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; - }, + .addr => pf.addr = .{}, .event => { pf.readers +|= 1; p.fs.listeners +|= 1; @@ -446,7 +471,27 @@ fn open(p: *Pardes, req: Req, target: Target) Reply { return .{ .tag = req.tag, .handle = 1 }; } +/// Tremove closes a pane. Nothing else in the tree can be removed, and this +/// is also where an ORCLOSE open lands, so every node is checked here. +fn remove(p: *Pardes, req: Req) Reply { + const target = Node.target(req.node) orelse return Reply.fail(req.tag, E.NOENT); + const t = switch (target) { + .pane => |t| t, + .top => return Reply.fail(req.tag, E.PERM), + }; + if (t.file != .dir) return Reply.fail(req.tag, E.PERM); + const id = p.paneBySerial(t.serial) orelse return Reply.fail(req.tag, E.NOENT); + p.removePane(id) catch return Reply.fail(req.tag, E.IO); + return .{ .tag = req.tag }; +} + fn release(p: *Pardes, req: Req) Reply { + // The handle's bookkeeping runs whether or not the removal is allowed. + const done = releaseHandle(p, req); + return if (req.remove) remove(p, req) else done; +} + +fn releaseHandle(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) { @@ -502,12 +547,8 @@ fn read(p: *Pardes, req: Req, target: Target) Reply { } 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); - }, + .status => ctl.readStatus(p, req), + .look, .exec => ctl.readResults(p, req), .log => events.readQueue(p, req, &p.fs.log), .screen => screen.readSnapshot(p, req, null), .listeners => screen.readListeners(p, req), @@ -525,7 +566,7 @@ fn read(p: *Pardes, req: Req, target: Target) Reply { fn write(p: *Pardes, req: Req, target: Target) Reply { switch (target) { .top => |f| return switch (f) { - .ctl => ctl.writeRoot(p, req), + .look, .exec => ctl.command(p, req, null, f == .exec), else => Reply.fail(req.tag, E.PERM), }, .pane => |t| { @@ -587,8 +628,8 @@ test "filesystem inspection preserves pending and displayed Look hover" { 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 = 1, .op = .lookup, .node = root, .data = "status" }, + .{ .tag = 1, .op = .getattr, .node = @intFromEnum(TopFile.pane) }, .{ .tag = 2, .op = .getattr, .node = body }, .{ .tag = 3, .op = .open, .node = body }, .{ .tag = 4, .op = .read, .node = body, .size = 5 }, @@ -614,7 +655,7 @@ test "filesystem inspection preserves pending and displayed Look hover" { test "filesystem pane creation and truncation cancel Look hover" { const requests = [_]Req{ - .{ .tag = 2, .op = .open, .node = @intFromEnum(TopFile.new) }, + .{ .tag = 2, .op = .open, .node = @intFromEnum(TopFile.pane), .data = "x", .create = true, .perm = cloud9.dmdir | 0o755 }, .{ .tag = 3, .op = .setattr, .node = 0, .truncate = true }, }; for (requests) |request| { @@ -644,13 +685,13 @@ test "readdir lists the root and a pane directory without creating anything" { 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.expect(top.len >= 10); + for ([_][]const u8{ "README", "index", "status", "look", "exec", "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(i >= 8, top[i].dir); } - try testing.expectEqual(@intFromEnum(TopFile.pane), top[7].node); - try testing.expectEqual(fs.os_root, top[8].node); + try testing.expectEqual(@intFromEnum(TopFile.pane), top[8].node); + try testing.expectEqual(fs.os_root, top[9].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); @@ -674,13 +715,18 @@ test "readdir lists the root and a pane directory without creating anything" { } 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); + var idname: [16]u8 = undefined; + const own = th.nameAt(listed, try std.fmt.bufPrint(&idname, "{d}", .{serial})).?; + try testing.expectEqual(Node.of(serial, .dir), own.node); + try testing.expect(own.dir); 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.expectEqual(@as(usize, 17), files.len); + for ([_][]const u8{ + "name", "body", "tag", "ctl", "addr", "dot", "limit", "data", "xdata", + "sel", "dirty", "mark", "scroll", "errors", "event", "look", "exec", + }, 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); @@ -703,8 +749,10 @@ test "lookup resolves top files, pane serials and pane files" { 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, "status").reply.attr.dir); + try testing.expectEqual(@as(u16, 0o444), look_up(p, root, "status").reply.attr.mode); + try testing.expectEqual(@as(u16, 0o666), look_up(p, root, "look").reply.attr.mode); + try testing.expectEqual(E.NOENT, look_up(p, root, "new").errno()); 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()); @@ -732,38 +780,46 @@ test "lookup resolves top files, pane serials and pane files" { 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" { +test "creating in the pane directory opens a pane and removing one closes it" { const gpa = testing.allocator; const p = try withFile(gpa, "first\n"); defer p.deinit(); const before = p.next_serial; - const new = @intFromEnum(TopFile.new); + const panes_dir = @intFromEnum(TopFile.pane); - 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); + // Browsing /pane creates nothing; only a create does. + try testing.expectEqual(Status.ok, rdir(p, panes_dir, 0).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); + const made = th.mkdir(p, "scratch"); + try testing.expectEqual(Status.ok, made.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); + const serial = before + 1; + try testing.expectEqual(Node.of(serial, .dir), made.reply.attr.node); + try testing.expect(made.reply.attr.dir); 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 }); + try testing.expectEqualStrings(try std.fmt.bufPrint(&expected, "{d}", .{serial}), made.reply.attr.name); - const id = p.paneBySerial(opened.reply.handle).?; + const id = p.paneBySerial(serial).?; 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 }); + _ = wr(p, Node.of(serial, .body), "hi"); + const second = th.mkdir(p, "another"); try testing.expectEqual(Status.ok, second.reply.status); - try testing.expect(second.reply.handle != opened.reply.handle); + try testing.expect(second.reply.attr.node != made.reply.attr.node); 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()); + + // A plain file, and a create anywhere else, are refused. + try testing.expectEqual(E.PERM, call(p, .{ .tag = 1, .op = .open, .node = panes_dir, .data = "f", .create = true, .perm = 0o666 }).errno()); + try testing.expectEqual(E.PERM, call(p, .{ .tag = 1, .op = .open, .node = root, .data = "d", .create = true, .perm = cloud9.dmdir | 0o755 }).errno()); + try testing.expectEqual(E.PERM, call(p, .{ .tag = 1, .op = .open, .node = Node.of(serial, .dir), .data = "d", .create = true, .perm = cloud9.dmdir | 0o755 }).errno()); + + // Tremove closes the pane it names, and nothing else in the tree. + try testing.expectEqual(E.PERM, th.rmdir(p, Node.of(serial, .body)).errno()); + try testing.expectEqual(E.PERM, th.rmdir(p, @intFromEnum(TopFile.index)).errno()); + try testing.expect(p.paneBySerial(serial) != null); + try testing.expectEqual(Status.ok, th.rmdir(p, Node.of(serial, .dir)).reply.status); + try testing.expect(p.paneBySerial(serial) == null); + try testing.expectEqual(E.NOENT, th.rmdir(p, Node.of(serial, .dir)).errno()); } test "directories walk to their parents" { @@ -791,14 +847,15 @@ test "editor paths resolve to the same nodes the wire serves" { 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.status), resolveSelf(p, "status").?); + try testing.expect(resolveSelf(p, "new") == null); 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, "status/ctl") == null); try testing.expect(resolveSelf(p, "cons") == null); try testing.expect(resolveSelf(p, "pane/0") == null); } -- cgit v1.3