diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-28 09:32:02 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-10-01 00:12:14 -0300 |
| commit | 0ee8d748c028ad5257f95e15db38646aecd2851b (patch) | |
| tree | 48b34e5604cd55f64bd9b323fdb9a2a6d0d60588 | |
| parent | cc5323772d5acd07376c561e8e08eaf365b7d22c (diff) | |
| download | pardes-0ee8d748c028ad5257f95e15db38646aecd2851b.tar.gz pardes-0ee8d748c028ad5257f95e15db38646aecd2851b.zip | |
A root focus file names the pane with the keyboard and moves it; a pane's ctl says whether it is current
Scripts had no way to ask which pane has the keyboard or to give it one.
rio answers both through a window's wctl: its read ends with current or
notcurrent, and a write of current takes the keyboard (rio(4)). A pane's
ctl line now ends the same way, and since there is one keyboard for the
whole tree, /focus reads the focused pane's serial and takes a serial to
move the keyboard there, off any header that had it; a serial no pane has
fails with "no such window". fs-bench's stale readdir check is fixed too.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
| -rw-r--r-- | .agents/skills/pardes-9p/SKILL.md | 6 | ||||
| -rw-r--r-- | docs/fs.md | 10 | ||||
| -rw-r--r-- | src/fs-help.txt | 6 | ||||
| -rw-r--r-- | src/ninep/ctl.zig | 61 | ||||
| -rw-r--r-- | src/ninep/tree.zig | 15 | ||||
| -rw-r--r-- | test/fs.py | 13 | ||||
| -rw-r--r-- | test/fs_bench.zig | 5 |
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. @@ -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); @@ -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; } |
