summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/fs-help.txt10
-rw-r--r--src/ninep/ctl.zig2
-rw-r--r--src/ninep/events.zig6
-rw-r--r--src/ninep/pane.zig8
-rw-r--r--src/ninep/pty.zig2
-rw-r--r--src/ninep/testing.zig26
-rw-r--r--src/ninep/tree.zig160
-rw-r--r--src/tutor.txt2
8 files changed, 143 insertions, 73 deletions
diff --git a/src/fs-help.txt b/src/fs-help.txt
index 1e5d64c2..3113590a 100644
--- a/src/fs-help.txt
+++ b/src/fs-help.txt
@@ -8,16 +8,16 @@ exec write a line: a middle click, an editor command word or a shell line
log one line per editor event (new/del/rename/save <serial> <name>); reads wait
screen rendered screen as JSON, frozen from open to close
listeners the session's dial addresses
-pane/ mkdir makes a pane, rmdir <serial> closes it
+pane/new open it to make a pane; the read answers that pane's serial
pane/<n>/ name body tag ctl addr dot limit data xdata sel dirty mark scroll
- errors event look exec, and pty/ for terminals
+ errors event look exec, and pty/ for terminals; rmdir closes it
os/ the host filesystem
src/ the editor's own sources, only in a -Dembed-sources=true build
Below, $m is the mount point (PARDES_MOUNT in a Tty9p shell; 9ns and 9p work too):
cat $m/index which panes exist
- mkdir $m/pane/x; n=$(awk 'END{print $1}' $m/index) make a pane, take its serial
+ n=$(cat $m/pane/new) make a pane, take its serial
echo /etc/hosts:3 > $m/look; cat $m/look open a file, see the pane it went to
printf 'text\n' > $m/pane/$n/body append to a pane (>| truncates first)
cat $m/pane/$n/name; echo notes.txt > $m/pane/$n/name read, then rename
@@ -31,8 +31,8 @@ Below, $m is the mount point (PARDES_MOUNT in a Tty9p shell; 9ns and 9p work too
echo exec > $m/pane/$n/pty/ctl restart a shell; also winsize C R, sig INT
Pitfalls, one each:
- Only mkdir in pane/ makes a pane; ls, stat, find and every read create nothing.
- Panes are named by the serial the editor gives them, not by the name mkdir asked for.
+ Each open of pane/new makes another pane; two reads of one fid name the same one.
+ Only that open creates: ls, stat, find and every other read leave the tree alone.
rmdir closes a pane even when it is dirty; index and pane/<n>/dirty show the flag.
addr, dot and limit read the same pair of offsets they take, so cp between them works.
dirty, mark and scroll read "0" or "1" and take "0" or "1"; truncating limit lifts it.
diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig
index c213ece5..7625f501 100644
--- a/src/ninep/ctl.zig
+++ b/src/ninep/ctl.zig
@@ -188,7 +188,7 @@ pub fn writePane(p: *Pardes, req: Req, pane: *Pane) Reply {
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);
+ if (!std.mem.eql(u8, line, "get")) return tree.failText(req.tag, E.INVAL, tree.e_bad_ctl);
asked = true;
}
if (asked) {
diff --git a/src/ninep/events.zig b/src/ninep/events.zig
index bd53bcd9..d94fb54d 100644
--- a/src/ninep/events.zig
+++ b/src/ninep/events.zig
@@ -317,12 +317,12 @@ pub fn writeEvent(p: *Pardes, req: Req, id: usize) Reply {
while (check.next()) |r| {
switch (r.action) {
.body_look, .tag_look, .body_exec, .tag_exec => {},
- else => return Reply.fail(req.tag, E.INVAL),
+ else => return tree.failText(req.tag, E.INVAL, tree.e_bad_event),
}
const n = if (r.action.onTag()) tag.len else body.len;
- if (r.q0 > r.q1 or r.q1 > n) return Reply.fail(req.tag, E.INVAL);
+ if (r.q0 > r.q1 or r.q1 > n) return tree.failText(req.tag, E.INVAL, tree.e_bad_event);
}
- if (check.i != req.data.len) return Reply.fail(req.tag, E.INVAL);
+ if (check.i != req.data.len) return tree.failText(req.tag, E.INVAL, tree.e_bad_event);
}
var run: EventReader = .{ .data = req.data };
while (run.next()) |r| {
diff --git a/src/ninep/pane.zig b/src/ninep/pane.zig
index 42c0c48e..4b34cf96 100644
--- a/src/ninep/pane.zig
+++ b/src/ninep/pane.zig
@@ -24,6 +24,12 @@ const Node = tree.Node;
/// Filesystem state a pane carries beside its editor state.
pub const State = struct {
+ /// The range `data` and `xdata` read and write through. acme clears it
+ /// when the first client opens `addr` (editors/acme/xfid.c:105), which
+ /// suits a client that holds the fid open and leaves a shell reading back
+ /// `0 0` from the address it just wrote. Here it is the pane's own
+ /// register, cleared by truncating the file, so that `cp addr dot` and
+ /// `cat addr` answer what was written.
addr: Range = .{},
limit: ?Range = null,
readers: u16 = 0,
@@ -458,7 +464,7 @@ fn writeRange(p: *Pardes, req: Req, id: usize, pane: *Pane, file: PaneFile) Repl
const pf = &p.fs.panes[id];
const text = bodyOf(pane);
clampAddr(pf, text.len);
- const r = rangeOf(pf, text, req.data) orelse return Reply.fail(req.tag, E.INVAL);
+ const r = rangeOf(pf, text, req.data) orelse return tree.failText(req.tag, E.INVAL, tree.e_bad_addr);
switch (file) {
.addr => pf.addr = r,
.limit => pf.limit = r,
diff --git a/src/ninep/pty.zig b/src/ninep/pty.zig
index 1b71a8d3..c657d27d 100644
--- a/src/ninep/pty.zig
+++ b/src/ninep/pty.zig
@@ -42,7 +42,7 @@ pub fn writeCtl(p: *Pardes, req: Req, id: usize) Reply {
while (it.next()) |raw| {
const line = std.mem.trim(u8, raw, " \t\r");
if (line.len == 0) continue;
- if (!verb(p, id, line, apply)) return Reply.fail(req.tag, E.INVAL);
+ if (!verb(p, id, line, apply)) return tree.failText(req.tag, E.INVAL, tree.e_bad_ctl);
}
}
return .{ .tag = req.tag, .written = @intCast(req.data.len) };
diff --git a/src/ninep/testing.zig b/src/ninep/testing.zig
index ad1d84e4..44329eee 100644
--- a/src/ninep/testing.zig
+++ b/src/ninep/testing.zig
@@ -69,26 +69,14 @@ pub fn look_up(p: *Pardes, dir: u64, name: []const u8) Answer {
return call(p, .{ .tag = 3, .op = .lookup, .node = dir, .data = name });
}
-/// Creates a pane in /pane, as a client's mkdir does, and returns its serial.
+/// Makes a pane the way a client does, by walking to /pane/new, and answers
+/// the serial of the pane the walk landed on.
+/// The open is what makes the pane; the handle it answers is the serial, which
+/// is also what a read of that fid reports.
pub fn newPane(p: *Pardes) !u32 {
- 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,
- });
+ const made = call(p, .{ .tag = 8, .op = .open, .node = @intFromEnum(tree.TopFile.new) });
+ if (made.reply.status != .ok or made.reply.handle == 0) return error.NoPane;
+ return made.reply.handle;
}
pub fn rmdir(p: *Pardes, node: u64) Answer {
diff --git a/src/ninep/tree.zig b/src/ninep/tree.zig
index 46609649..5a0c1e11 100644
--- a/src/ninep/tree.zig
+++ b/src/ninep/tree.zig
@@ -5,7 +5,8 @@
//! and mounts.
//!
//! /README /index /status /look /exec /log /screen /listeners
-//! /pane/ mkdir makes a pane, rmdir closes it
+//! /pane/new an open makes a pane, a read names it
+//! /pane/<serial> rmdir closes that pane
//! /pane/<serial>/{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)
@@ -49,14 +50,28 @@ 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 };
+/// 9P answers a failure with a string, not a number (man 5 error): the errno
+/// still travels, for clients that map it, but the string is what a person
+/// reads. acme names its refusals the same way, and these are its spellings
+/// (editors/acme/xfid.c:19) for the ones this tree shares.
+pub fn failText(tag: u64, errno: u16, text: []const u8) Reply {
+ return .{ .tag = tag, .status = .err, .errno = errno, .ename = text };
+}
+
+pub const e_bad_addr = "bad address syntax";
+pub const e_bad_ctl = "ill-formed control message";
+pub const e_bad_event = "bad event syntax";
+
+/// Tremove closes a pane; nothing else in the tree is created or destroyed by
+/// the protocol, and wstat stays a truncation. Making a pane is an open of
+/// /pane/new, which needs no feature of the engine's, so Tcreate is refused
+/// everywhere, as it is in acme (editors/acme/fsys.c, fsyscreate).
+pub const features: cloud9.fs.Features = .{ .remove = true };
pub fn changesPane(req: Req) bool {
return switch (req.op) {
.write, .setattr => true,
- .open => req.create,
+ .open => req.node == @intFromEnum(TopFile.new),
.release => req.remove,
.lookup, .getattr, .read, .readdir => false,
};
@@ -77,6 +92,7 @@ pub const TopFile = enum(u4) {
screen,
listeners,
pane,
+ new,
pub fn fileName(f: TopFile) []const u8 {
return if (f == .root) "/" else @tagName(f);
@@ -86,7 +102,7 @@ pub const TopFile = enum(u4) {
return switch (f) {
.root, .pane => 0o755,
.look, .exec => 0o666,
- .README, .index, .status, .log, .screen, .listeners => 0o444,
+ .README, .index, .status, .log, .screen, .listeners, .new => 0o444,
};
}
@@ -187,9 +203,22 @@ fn paneFileNamed(name: []const u8) ?PaneFile {
fn topFileNamed(name: []const u8) ?TopFile {
const f = std.meta.stringToEnum(TopFile, name) orelse return null;
- return if (f == .root) null else f;
+ // `new` is reached in /pane, where the panes it makes are.
+ return if (f == .root or f == .new) null else f;
}
+/// Opening /pane/new makes a pane and reading the open fid answers its
+/// serial, which is /net/tcp/clone's mechanism exactly (kernel/network/ip/
+/// devip.c, `case Qclone` in ipopen, whose Qctl read prints the number it
+/// allocated). acme spells the same idea as a directory made by the walk
+/// (editors/acme/fsys.c:481), which would be the nicer `new/body`, but a
+/// Plan 9 directory read carries every entry's stat and a kernel or FUSE
+/// mount instead walks each name it listed: allocating on the walk would
+/// make a pane every time someone ran `ls -l`. A stat is not an open, so
+/// this file can be listed. Panes are named by their serial, so the name
+/// can never collide with one.
+pub const new_pane = "new";
+
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;
@@ -218,7 +247,12 @@ pub fn resolveSelf(p: *Pardes, path: []const u8) ?u64 {
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 next = parts.next() orelse return @intFromEnum(TopFile.pane);
+ // `new` is served on the wire but deliberately unreachable from an editor
+ // path: this resolves Look targets and hover previews, and a preview that
+ // opened `new` to see what was there would make a pane per hover.
+ if (std.mem.eql(u8, next, new_pane)) return null;
+ const serial = serialNamed(next) 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;
@@ -258,9 +292,9 @@ pub fn stagedReply(p: *Pardes, req: Req) Reply {
pub fn handle(p: *Pardes, req: Req) Reply {
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);
+ // Only a pane directory is removed. The host tree and the embedded
+ // sources say so, rather than quietly doing nothing.
+ if ((host or archive) and 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);
@@ -302,6 +336,10 @@ fn attrOf(p: *Pardes, target: Target) ?Reply.Attr {
.size = pane.fileSize(p, id, t.file),
.mtime = pane.mtimeOf(p, pn),
.version = pane.versionOf(pn, t.file),
+ // A write to body or tag appends whatever its offset says,
+ // which is why acme's dirtab marks both DMAPPEND
+ // (editors/acme/fsys.c:78).
+ .append = t.file == .body or t.file == .tag,
};
},
}
@@ -314,8 +352,9 @@ fn attrReply(p: *Pardes, tag: u64, target: Target) Reply {
fn topSize(p: *Pardes, f: TopFile) u64 {
return switch (f) {
- // ponytail: /screen has no length until an open renders its frame.
- .root, .pane, .screen => 0,
+ // ponytail: /screen has no length until an open renders its frame,
+ // and /pane/new none until an open has a pane to name.
+ .root, .pane, .screen, .new => 0,
.index => pane.indexLen(p),
.README => fs.help.len,
.status => ctl.statusLen(p),
@@ -348,6 +387,7 @@ fn lookup(p: *Pardes, req: Req, target: Target) Reply {
return Reply.fail(req.tag, E.NOENT);
},
.pane => pane: {
+ if (std.mem.eql(u8, name, new_pane)) break :pane @intFromEnum(TopFile.new);
const serial = serialNamed(name) orelse return Reply.fail(req.tag, E.NOENT);
break :pane Node.of(serial, .dir);
},
@@ -396,6 +436,7 @@ fn readdir(p: *Pardes, req: Req, target: Target) Reply {
sources.stage(p, out, "", &skip);
},
.pane => {
+ if (skip > 0) skip -= 1 else stageDirent(out, p.gpa, @intFromEnum(TopFile.new), false, TopFile.new.fileName());
var last: u32 = 0;
while (nextSerialAfter(p, last)) |serial| {
last = serial;
@@ -426,27 +467,19 @@ fn readdir(p: *Pardes, req: Req, target: Target) Reply {
return .{ .tag = req.tag, .payload = .{ .staged = @intCast(out.items.len) } };
}
-/// 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);
+/// Opens a pane for an open of /pane/new, whose serial becomes the handle:
+/// the fid remembers which pane it made, so reading it twice answers the
+/// same one and closing it leaves the pane alone.
+fn makePane(p: *Pardes) ?u32 {
+ const slot = p.freeSlot() orelse return null;
p.newScratchBelow(p.active);
- 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 };
+ return (p.panes[slot] orelse return null).serial;
}
fn open(p: *Pardes, req: Req, target: Target) Reply {
- if (req.create) return create(p, req, target);
switch (target) {
.top => |f| switch (f) {
+ .new => return .{ .tag = req.tag, .handle = makePane(p) orelse return Reply.fail(req.tag, E.NFILE) },
.screen => return screen.openSnapshot(p, req, true),
.log => p.fs.log_readers +|= 1,
else => {},
@@ -458,7 +491,6 @@ 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),
- .addr => pf.addr = .{},
.event => {
pf.readers +|= 1;
p.fs.listeners +|= 1;
@@ -552,6 +584,14 @@ fn read(p: *Pardes, req: Req, target: Target) Reply {
.log => events.readQueue(p, req, &p.fs.log),
.screen => screen.readSnapshot(p, req, null),
.listeners => screen.readListeners(p, req),
+ // The serial the open handed this fid, so that two reads of one
+ // fid answer the same pane: the read observes, the open acted.
+ .new => serial: {
+ if (req.handle == 0) break :serial Reply.fail(req.tag, E.IO);
+ p.fs.stage(p.gpa).print(p.gpa, "{d}\n", .{req.handle}) catch
+ break :serial Reply.fail(req.tag, E.NOMEM);
+ break :serial stagedReply(p, req);
+ },
.root, .pane => Reply.fail(req.tag, E.PERM),
},
.pane => |t| {
@@ -655,7 +695,9 @@ 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.pane), .data = "x", .create = true, .perm = cloud9.dmdir | 0o755 },
+ // Opening `new` is what makes a pane now; walking to it makes none,
+ // and so must leave a pending hover alone.
+ .{ .tag = 2, .op = .open, .node = @intFromEnum(TopFile.new) },
.{ .tag = 3, .op = .setattr, .node = 0, .truncate = true },
};
for (requests) |request| {
@@ -719,6 +761,14 @@ test "readdir lists the root and a pane directory without creating anything" {
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);
+ // `new` is listed, so `ls` shows it. Walking and stat-ing it allocates
+ // nothing — only an open does — which is what keeps `ls -l` and any other
+ // client that stats every name a listing gave it inert.
+ const shown = th.nameAt(listed, new_pane).?;
+ try testing.expect(!shown.dir);
+ try testing.expectEqual(Status.ok, look_up(p, @intFromEnum(TopFile.pane), new_pane).reply.status);
+ try testing.expectEqual(Status.ok, call(p, .{ .tag = 1, .op = .getattr, .node = @intFromEnum(TopFile.new) }).reply.status);
+ try testing.expectEqual(before, p.next_serial);
const dir = rdir(p, Node.of(serial, .dir), 0);
const files = th.dirents(dir.bytes, &buf);
@@ -780,38 +830,58 @@ 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 "creating in the pane directory opens a pane and removing one closes it" {
+test "opening /pane/new makes 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 panes_dir = @intFromEnum(TopFile.pane);
- // Browsing /pane creates nothing; only a create does.
+ // Browsing /pane creates nothing, and neither does walking or stat-ing
+ // `new` itself: only an open does. That is what lets `new` be listed at
+ // all, since `ls -l` stats every name a listing handed it.
try testing.expectEqual(Status.ok, rdir(p, panes_dir, 0).reply.status);
+ try testing.expectEqual(Status.ok, look_up(p, panes_dir, new_pane).reply.status);
+ try testing.expectEqual(Status.ok, call(p, .{ .tag = 1, .op = .getattr, .node = @intFromEnum(TopFile.new) }).reply.status);
try testing.expectEqual(before, p.next_serial);
- const made = th.mkdir(p, "scratch");
+ // The open makes the pane and hands back its serial; the read only
+ // observes, so two reads of one fid answer the same pane.
+ const made = call(p, .{ .tag = 2, .op = .open, .node = @intFromEnum(TopFile.new) });
try testing.expectEqual(Status.ok, made.reply.status);
try testing.expectEqual(before + 1, p.next_serial);
const serial = before + 1;
- try testing.expectEqual(Node.of(serial, .dir), made.reply.attr.node);
- try testing.expect(made.reply.attr.dir);
+ try testing.expectEqual(serial, made.reply.handle);
var expected: [16]u8 = undefined;
- try testing.expectEqualStrings(try std.fmt.bufPrint(&expected, "{d}", .{serial}), made.reply.attr.name);
+ const want = try std.fmt.bufPrint(&expected, "{d}\n", .{serial});
+ for (0..2) |_| {
+ const answer = call(p, .{ .tag = 3, .op = .read, .node = @intFromEnum(TopFile.new), .handle = made.reply.handle, .size = 64 });
+ try testing.expectEqualStrings(want, answer.bytes);
+ }
const id = p.paneBySerial(serial).?;
try testing.expectEqualStrings("", p.panes[id].?.file.?.content);
_ = wr(p, Node.of(serial, .body), "hi");
- const second = th.mkdir(p, "another");
+
+ // A second open is a second pane, and releasing a clone fid leaves the
+ // pane it made standing.
+ const second = call(p, .{ .tag = 4, .op = .open, .node = @intFromEnum(TopFile.new) });
try testing.expectEqual(Status.ok, second.reply.status);
- try testing.expect(second.reply.attr.node != made.reply.attr.node);
+ try testing.expect(second.reply.handle != made.reply.handle);
+ _ = call(p, .{ .tag = 5, .op = .release, .node = @intFromEnum(TopFile.new), .handle = made.reply.handle, .opened = true });
+ try testing.expect(p.paneBySerial(serial) != null);
try testing.expectEqualStrings("hi", p.panes[id].?.file.?.content);
- // 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());
+ // The tree declares no create, so the engine refuses every Tcreate
+ // (cloud9 fs.zig: `.tcreate => if (features.create) ... else fail(e_perm)`),
+ // and `new` is the only name in /pane that is not a serial.
+ try testing.expect(!features.create);
+ try testing.expectEqual(E.NOENT, look_up(p, panes_dir, "scratch").errno());
+ try testing.expectEqual(E.NOENT, look_up(p, root, new_pane).errno());
+ // ...and it is listed, because the user asked to see it in `ls`.
+ var listing: [32]th.Dirent = undefined;
+ const shown = rdir(p, panes_dir, 0);
+ try testing.expect(th.nameAt(th.dirents(shown.bytes, &listing), new_pane) != null);
// 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());
@@ -858,4 +928,10 @@ test "editor paths resolve to the same nodes the wire serves" {
try testing.expect(resolveSelf(p, "status/ctl") == null);
try testing.expect(resolveSelf(p, "cons") == null);
try testing.expect(resolveSelf(p, "pane/0") == null);
+ // The editor resolves its own paths to inspect them, so `new` names
+ // nothing here; only a client's walk makes a pane.
+ const before = p.next_serial;
+ try testing.expect(resolveSelf(p, "pane/new") == null);
+ try testing.expect(resolveSelf(p, "pane/new/body") == null);
+ try testing.expectEqual(before, p.next_serial);
}
diff --git a/src/tutor.txt b/src/tutor.txt
index adf4ae3a..ab73be33 100644
--- a/src/tutor.txt
+++ b/src/tutor.txt
@@ -396,7 +396,7 @@ typed
/pane/<serial>/body
/index
- /pane mkdir here makes a pane, rmdir closes it
+ /pane/new open it to make a pane; rmdir closes one
/look /exec `FILE:12` and `Save`, as the mouse does
A terminal pane also has `pty/`: