summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.agents/skills/pardes-9p/SKILL.md6
-rw-r--r--docs/fs.md10
-rw-r--r--src/fs-help.txt6
-rw-r--r--src/ninep/ctl.zig61
-rw-r--r--src/ninep/tree.zig15
-rw-r--r--test/fs.py13
-rw-r--r--test/fs_bench.zig5
7 files changed, 102 insertions, 14 deletions
diff --git a/.agents/skills/pardes-9p/SKILL.md b/.agents/skills/pardes-9p/SKILL.md
index 354932b3..5e2280f9 100644
--- a/.agents/skills/pardes-9p/SKILL.md
+++ b/.agents/skills/pardes-9p/SKILL.md
@@ -52,6 +52,7 @@ $m/log recent events, then EOF: new|del|rename|save <serial> <name>, msg
(exec 3<>$m/log; echo follow >&3; cat <&3 waits for new ones; tail -f does not)
$m/screen the rendered screen as JSON, frozen per open
$m/listeners this session's dial addresses
+$m/focus the serial of the pane with the keyboard; echo a serial into it to move the keyboard
$m/pane/new open it to make a pane, read names it; rmdir $m/pane/<n> closes it
$m/os/ the host filesystem
```
@@ -130,7 +131,10 @@ written until someone writes or truncates it, so writing an address and reading
it back evaluates it, and two clients addressing the same pane will interfere.
`$pane/ctl` reads acme's window status line — serial, tag length, body length,
a reserved zero, the dirty flag, the width in cells, the font and the tab
-width — and takes the one verb `get`.
+width, then `current` or `notcurrent` — and takes `get` (reload from disk) and
+`lock`/`unlock`. The lock binds only clients that take it, and is held by the
+open that wrote it, so a shell holds an fd across the edit:
+`exec 3>$pane/ctl; echo lock >&3; ...; exec 3>&-`.
Terminal panes have no file: writing their `body` sends child input, and
truncation does not erase terminal history.
diff --git a/docs/fs.md b/docs/fs.md
index 589f4287..c2819adb 100644
--- a/docs/fs.md
+++ b/docs/fs.md
@@ -80,6 +80,7 @@ Existing Plan9port/v9fs clients need a userspace bridge for QUIC.
write follow to that open to wait for more
/screen rendered screen JSON; frozen per open handle
/listeners the session's dial addresses
+/focus the serial of the pane with the keyboard; write a serial to give it the keyboard
/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, plus pty/{ctl,status,data} on terminals
@@ -87,6 +88,12 @@ Existing Plan9port/v9fs clients need a userspace bridge for QUIC.
/src/ the editor's embedded sources, only when built with -Dembed-sources=true
```
+`/focus` reads the serial of the pane with the keyboard, and a serial written
+to it gives that pane the keyboard, off any column or workspace tag that had
+it -- rio's `current` written to a window's `wctl`, named once for the whole
+tree since there is one keyboard. A serial no pane has fails with `no such
+window`; anything but a number, with `ill-formed control message`.
+
A pane is made by **opening** `/pane/new`, and closed by Tremove on
`/pane/<n>` (`rmdir`), which is the only remove the tree serves; Tcreate is
refused everywhere, as it is in acme. Reading the open fid answers the serial
@@ -153,7 +160,8 @@ appends to the directory's `+Errors` pane. Holding `event` open redirects the
pane's Look and Exec clicks to that client; writing a record back performs the
action. `ctl` reads acme's window status line — serial, tag length, body
length, a reserved zero, the dirty flag, the width in cells, the font and the
-tab width — and takes `get`, which reloads the buffer from the name it
+tab width — followed by rio's `current` or `notcurrent` (rio(4), `wctl`):
+whether the pane has the keyboard. It takes `get`, which reloads the buffer from the name it
carries, and acme's `lock` and `unlock` (editors/acme/xfid.c:603-611), for an
edit of several writes to `addr` and `data` that another client must not
land in the middle of. As in acme the lock binds only the clients that take
diff --git a/src/fs-help.txt b/src/fs-help.txt
index efdecf46..235ce072 100644
--- a/src/fs-help.txt
+++ b/src/fs-help.txt
@@ -9,11 +9,11 @@ log recent events, one a line: new/del/rename/save <serial> <name>, and
msg <serial|-> <text>, what the editor said; write follow to wait (tail -f won't)
screen rendered screen as JSON, frozen from open to close
listeners the session's dial addresses
+focus the serial of the pane with the keyboard; write a serial to give it the keyboard
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; rmdir closes it
-os/ the host filesystem
-src/ the editor's own sources, only in a -Dembed-sources=true build
+os/, src/ the host filesystem; the editor's own sources (-Dembed-sources=true builds only)
Below, $m is the mount point (PARDES_MOUNT in a Tty9p shell; 9ns and 9p work too):
@@ -42,4 +42,4 @@ Pitfalls, one each:
tag reads path++text; writes append to the text (newlines too); truncating clears it, and dot or addr.
A terminal's body is a history snapshot frozen per open; pty/data is the live stream.
A failing command is reported in the editor and in log, not as a write error; a bad line fails the write.
- pane/<n>/ctl: acme's status line; takes get (reload), lock/unlock on a held fd (exec 3>ctl; echo lock >&3).
+ pane/<n>/ctl: acme's status line, then current or notcurrent; takes get (reload), lock/unlock on a held fd.
diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig
index 484b08c9..5cc238a7 100644
--- a/src/ninep/ctl.zig
+++ b/src/ninep/ctl.zig
@@ -10,6 +10,7 @@ const look = @import("../look.zig");
const panes = @import("../panes.zig");
const config = @import("../config.zig");
const fs = @import("../fs.zig");
+const tagline = @import("../tagline.zig");
const tree = @import("tree.zig");
const pane_files = @import("pane.zig");
@@ -154,10 +155,13 @@ fn writeQuoted(w: *std.Io.Writer, s: []const u8) !void {
try w.writeByte('\'');
}
-pub const pane_text_cap = 7 * 12 + 2 * 256 + 4;
+pub const pane_text_cap = 7 * 12 + 2 * 256 + 4 + "notcurrent ".len;
/// acme's winctlprint: serial, tag length, body length, a reserved zero, the
-/// dirty flag, then the width in cells, the font and the tab width.
+/// dirty flag, then the width in cells, the font and the tab width. Then
+/// rio's word for whether the window has the keyboard, `current` or
+/// `notcurrent` (rio(4), wctl), which acme's line has no room for because
+/// acme has one window for input at a time only by the mouse.
pub fn paneText(p: *Pardes, pane: *Pane, buf: []u8) []const u8 {
var w = std.Io.Writer.fixed(buf);
w.print("{d:>11} {d:>11} {d:>11} {d:>11} {d:>11} {d:>11} ", .{
@@ -169,10 +173,32 @@ pub fn paneText(p: *Pardes, pane: *Pane, buf: []u8) []const u8 {
pane.cols,
}) catch return w.buffered();
writeQuoted(&w, fontName(p)) catch return w.buffered();
- w.print(" {d:>11} ", .{config.tab_width}) catch {};
+ w.print(" {d:>11} {s} ", .{ config.tab_width, if (p.panes[p.active] == pane) "current" else "notcurrent" }) catch {};
return w.buffered();
}
+// ---- /focus ----
+
+/// The pane with the keyboard, which a write of a serial moves, the way
+/// writing `current` to rio's wctl does (rio(4)).
+pub fn readFocus(p: *Pardes, req: Req) Reply {
+ const pane = p.panes[p.active] orelse return tree.stagedReply(p, req);
+ p.fs.stage(p.gpa).print(p.gpa, "{d}\n", .{pane.serial}) catch return Reply.fail(req.tag, E.NOMEM);
+ return tree.stagedReply(p, req);
+}
+
+pub fn writeFocus(p: *Pardes, req: Req) Reply {
+ const text = std.mem.trim(u8, req.data, " \t\r\n");
+ const serial = std.fmt.parseInt(u32, text, 10) catch return tree.failText(req.tag, E.INVAL, tree.e_bad_ctl);
+ const id = p.paneBySerial(serial) orelse return tree.failText(req.tag, E.NOENT, e_no_pane);
+ // The keyboard goes to the pane itself, off any header that had it.
+ tagline.exitHeader(p);
+ p.active = id;
+ return .{ .tag = req.tag, .written = @intCast(req.data.len) };
+}
+
+const e_no_pane = "no such window";
+
pub fn readPane(p: *Pardes, req: Req, pane: *Pane) Reply {
var buf: [pane_text_cap]u8 = undefined;
const out = p.fs.stage(p.gpa);
@@ -266,7 +292,7 @@ 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" {
+test "pane ctl read is index's five fields plus width in cells, font, tab width and whether it is current" {
const gpa = testing.allocator;
const p = try withFile(gpa, "x\n");
defer p.deinit();
@@ -276,7 +302,7 @@ test "pane ctl read is index's five fields plus width in cells, font and tab wid
try testing.expectEqual(Status.ok, a.reply.status);
var want: std.ArrayList(u8) = .empty;
defer want.deinit(gpa);
- try want.print(gpa, "{d:>11} {d:>11} {d:>11} {d:>11} {d:>11} {d:>11} {s} {d:>11} ", .{
+ try want.print(gpa, "{d:>11} {d:>11} {d:>11} {d:>11} {d:>11} {d:>11} {s} {d:>11} current ", .{
pane.serial, pane_files.tagOf(p, pane).len, @as(usize, 2), 0, 0, pane.cols, "default", config.tab_width,
});
try testing.expectEqualStrings(want.items, a.bytes);
@@ -291,6 +317,31 @@ 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 "focus reads the pane with the keyboard, and a serial written to it moves the keyboard there" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const first = serialOf(p);
+ const second = try th.newPane(p);
+ const focus = @intFromEnum(tree.TopFile.focus);
+ var line: [16]u8 = undefined;
+ try testing.expectEqualStrings(try std.fmt.bufPrint(&line, "{d}\n", .{second}), rd(p, focus, 0, 64).bytes);
+ try testing.expectEqual(@as(u64, rd(p, focus, 0, 64).bytes.len), call(p, .{ .tag = 1, .op = .getattr, .node = focus }).reply.attr.size);
+ try testing.expect(std.mem.endsWith(u8, rd(p, Node.of(first, .ctl), 0, 4096).bytes, " notcurrent "));
+
+ // A header had the keyboard: the write takes it back to the pane.
+ tagline.enterHeader(p, null);
+ try testing.expectEqual(Status.ok, wr(p, focus, try std.fmt.bufPrint(&line, "{d}\n", .{first})).reply.status);
+ try testing.expect(!p.header_focus);
+ try testing.expectEqual(p.paneBySerial(first).?, p.active);
+ try testing.expect(std.mem.endsWith(u8, rd(p, Node.of(first, .ctl), 0, 4096).bytes, " current "));
+ try testing.expect(std.mem.endsWith(u8, rd(p, Node.of(second, .ctl), 0, 4096).bytes, " notcurrent "));
+
+ try testing.expectEqualStrings(e_no_pane, wr(p, focus, "99999\n").reply.ename);
+ try testing.expectEqual(E.NOENT, wr(p, focus, "99999").errno());
+ for ([_][]const u8{ "", "two", "-1", "1 2" }) |bad| try testing.expectEqual(E.INVAL, wr(p, focus, bad).errno());
+ try testing.expectEqual(p.paneBySerial(first).?, p.active);
+}
+
test "the pane ctl takes get, lock and unlock, and nothing that a file of its own now answers" {
const gpa = testing.allocator;
const p = try withFile(gpa, "one\ntwo\n");
diff --git a/src/ninep/tree.zig b/src/ninep/tree.zig
index f93ab203..b0de446e 100644
--- a/src/ninep/tree.zig
+++ b/src/ninep/tree.zig
@@ -174,6 +174,7 @@ pub const TopFile = enum(u4) {
listeners,
pane,
new,
+ focus,
pub fn fileName(f: TopFile) []const u8 {
return if (f == .root) "/" else @tagName(f);
@@ -182,7 +183,7 @@ pub const TopFile = enum(u4) {
pub fn mode(f: TopFile) u16 {
return switch (f) {
.root, .pane => 0o755,
- .look, .exec, .log => 0o666,
+ .look, .exec, .log, .focus => 0o666,
.README, .index, .status, .screen, .listeners, .new => 0o444,
};
}
@@ -444,6 +445,11 @@ fn topSize(p: *Pardes, f: TopFile) u64 {
.look, .exec => ctl.resultsLen(p),
.log => events.logLen(&p.fs.log),
.listeners => screen.listenersLen(p),
+ .focus => focus: {
+ const pn = p.panes[p.active] orelse break :focus 0;
+ var digits: [16]u8 = undefined;
+ break :focus (std.fmt.bufPrint(&digits, "{d}\n", .{pn.serial}) catch unreachable).len;
+ },
};
}
@@ -516,6 +522,10 @@ fn readdir(p: *Pardes, req: Req, target: Target) Reply {
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");
+ // After the first ten, which older clients list by position.
+ inline for (.{TopFile.focus}) |t| {
+ if (skip > 0) skip -= 1 else stageDirent(out, p.gpa, @intFromEnum(t), t.dir(), t.fileName());
+ }
sources.stage(p, out, "", &skip);
},
.pane => {
@@ -705,6 +715,7 @@ fn read(p: *Pardes, req: Req, target: Target) Reply {
.log => events.readLog(p, req),
.screen => screen.readSnapshot(p, req, null),
.listeners => screen.readListeners(p, req),
+ .focus => ctl.readFocus(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: {
@@ -737,6 +748,7 @@ fn write(p: *Pardes, req: Req, target: Target) Reply {
.top => |f| return switch (f) {
.look, .exec => ctl.command(p, req, null, f == .exec),
.log => events.writeLog(p, req),
+ .focus => ctl.writeFocus(p, req),
else => Reply.fail(req.tag, E.PERM),
},
.pane => |t| {
@@ -894,6 +906,7 @@ test "readdir lists the root and a pane directory without creating anything" {
}
try testing.expectEqual(@intFromEnum(TopFile.pane), top[8].node);
try testing.expectEqual(fs.os_root, top[9].node);
+ try testing.expectEqualStrings("focus", top[10].name);
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);
diff --git a/test/fs.py b/test/fs.py
index fe7e4c13..ccf3eb89 100644
--- a/test/fs.py
+++ b/test/fs.py
@@ -234,6 +234,17 @@ def discovery(binary, embedded=False):
assert locked, 'a lock waiting on another open goes once it unlocks'
client.close(mine)
other.close(theirs)
+ # /focus names the pane with the keyboard and moves it, as rio's
+ # wctl `current` does; a pane's ctl line ends saying which it is.
+ client.write('/focus', f'{first}\n'.encode())
+ assert client.read('/focus') == f'{first}\n'.encode()
+ assert client.read(f'/pane/{first}/ctl').endswith(b' current ')
+ assert client.read(f'/pane/{second}/ctl').endswith(b' notcurrent ')
+ try:
+ client.write('/focus', b'99999\n')
+ raise AssertionError('focus took a serial no pane has')
+ except OSError as refused:
+ assert 'no such window' in str(refused), refused
assert set(client.list('/pane')) == {'new', str(fixture), str(first), str(second)}
client.write(f'/pane/{first}/body', b'first pane', truncate=True)
assert client.read(f'/pane/{first}/body') == b'first pane'
@@ -304,7 +315,7 @@ def discovery(binary, embedded=False):
assert client.read('/src/pardes.zig').startswith(b'const std')
assert client.stat('/src/pardes.zig')['mode'] == 0o444
assert b'pub const Pardes' in client.read(f'/pane/{look(client, "/virtual/src/pardes.zig")}/body')
- print('9P discovery: listing/stat/find are inert; new, remove, look, exec, name, sel, log and ctl lock behave')
+ print('9P discovery: listing/stat/find are inert; new, remove, look, exec, name, sel, log, ctl lock and focus behave')
def run_file(binary):
diff --git a/test/fs_bench.zig b/test/fs_bench.zig
index da9da47c..75b2eb11 100644
--- a/test/fs_bench.zig
+++ b/test/fs_bench.zig
@@ -123,7 +123,8 @@ fn checkReply(s: *Session, req: ctlfs.Req, reply: ctlfs.Reply) !void {
if (req.node == s.node(.ctl)) {
const tail = try checkNumbers(rest, &.{pane.cols});
if (!std.mem.startsWith(u8, tail, "default ")) return error.IncorrectReply;
- if ((try checkNumbers(tail[8..], &.{pardes.config.tab_width})).len != 0)
+ const current = try checkNumbers(tail[8..], &.{pardes.config.tab_width});
+ if (!std.mem.eql(u8, current, "current ") and !std.mem.eql(u8, current, "notcurrent "))
return error.IncorrectReply;
} else return error.UnmeasuredOperation;
}
@@ -143,7 +144,7 @@ fn checkReply(s: *Session, req: ctlfs.Req, reply: ctlfs.Reply) !void {
const size: usize = bytes[off + 9];
off += 10;
if (dir > 1 or size == 0 or size > bytes.len - off) return error.IncorrectReply;
- if (entry < names.len and (node != nodes[entry] or dir != @intFromBool(entry >= 7) or
+ if (entry < names.len and (node != nodes[entry] or dir != @intFromBool(entry >= 8) or
!std.mem.eql(u8, names[entry], bytes[off..][0..size]))) return error.IncorrectReply;
off += size;
}